diff --git a/internal/api/graphql/graph/baseResolver/common.go b/internal/api/graphql/graph/baseResolver/common.go index c76db9d1..b5fdc47b 100644 --- a/internal/api/graphql/graph/baseResolver/common.go +++ b/internal/api/graphql/graph/baseResolver/common.go @@ -103,5 +103,6 @@ func GetListOptions(requestedFields []string) *entity.ListOptions { ShowTotalCount: lo.Contains(requestedFields, "totalCount"), ShowPageInfo: lo.Contains(requestedFields, "pageInfo"), IncludeAggregations: lo.Contains(requestedFields, "edges.node.objectMetadata"), + Order: []entity.Order{}, } } diff --git a/internal/api/graphql/graph/baseResolver/issue_match.go b/internal/api/graphql/graph/baseResolver/issue_match.go index 5ae2aa9f..808ddba6 100644 --- a/internal/api/graphql/graph/baseResolver/issue_match.go +++ b/internal/api/graphql/graph/baseResolver/issue_match.go @@ -54,7 +54,7 @@ func SingleIssueMatchBaseResolver(app app.Heureka, ctx context.Context, parent * return &issueMatch, nil } -func IssueMatchBaseResolver(app app.Heureka, ctx context.Context, filter *model.IssueMatchFilter, first *int, after *string, parent *model.NodeParent) (*model.IssueMatchConnection, error) { +func IssueMatchBaseResolver(app app.Heureka, ctx context.Context, filter *model.IssueMatchFilter, first *int, after *string, orderBy []*model.IssueMatchOrderBy, parent *model.NodeParent) (*model.IssueMatchConnection, error) { requestedFields := GetPreloads(ctx) logrus.WithFields(logrus.Fields{ "requestedFields": requestedFields, @@ -119,6 +119,9 @@ func IssueMatchBaseResolver(app app.Heureka, ctx context.Context, filter *model. } opt := GetListOptions(requestedFields) + for _, o := range orderBy { + opt.Order = append(opt.Order, o.ToOrderEntity()) + } issueMatches, err := app.ListIssueMatches(f, opt) diff --git a/internal/api/graphql/graph/model/models.go b/internal/api/graphql/graph/model/models.go index 184ec176..50d45683 100644 --- a/internal/api/graphql/graph/model/models.go +++ b/internal/api/graphql/graph/model/models.go @@ -49,6 +49,32 @@ var AllIssueMatchStatusValuesOrdered = []IssueMatchStatusValues{ IssueMatchStatusValuesMitigated, } +type HasToEntity interface { + ToOrderEntity() entity.Order +} + +func (od *OrderDirection) ToOrderDirectionEntity() entity.OrderDirection { + direction := entity.OrderDirectionAsc + if *od == OrderDirectionDesc { + direction = entity.OrderDirectionDesc + } + return direction +} + +func (imo *IssueMatchOrderBy) ToOrderEntity() entity.Order { + var order entity.Order + switch *imo.By { + case IssueMatchOrderByFieldPrimaryName: + order.By = entity.IssuePrimaryName + case IssueMatchOrderByFieldComponentInstanceCcrn: + order.By = entity.ComponentInstanceCcrn + case IssueMatchOrderByFieldTargetRemediationDate: + order.By = entity.IssueMatchTargetRemediationDate + } + order.Direction = imo.Direction.ToOrderDirectionEntity() + return order +} + func NewPageInfo(p *entity.PageInfo) *PageInfo { if p == nil { return nil diff --git a/internal/api/graphql/graph/queryCollection/issueMatch/withOrder.graphql b/internal/api/graphql/graph/queryCollection/issueMatch/withOrder.graphql new file mode 100644 index 00000000..90ca7c99 --- /dev/null +++ b/internal/api/graphql/graph/queryCollection/issueMatch/withOrder.graphql @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Greenhouse contributors +# SPDX-License-Identifier: Apache-2.0 + +query ($filter: IssueMatchFilter, $first: Int, $after: String, $orderBy: [IssueMatchOrderBy]) { + IssueMatches ( + filter: $filter, + first: $first, + after: $after + orderBy: $orderBy + ) { + totalCount + edges { + node { + id + targetRemediationDate + severity { + value + score + } + issueId + issue { + id + primaryName + } + componentInstanceId + componentInstance { + id + ccrn + } + } + cursor + } + pageInfo { + hasNextPage + hasPreviousPage + isValidPage + pageNumber + nextPageAfter + pages { + after + isCurrent + pageNumber + pageCount + } + } + } +} diff --git a/internal/api/graphql/graph/resolver/component_instance.go b/internal/api/graphql/graph/resolver/component_instance.go index 9dc4d4ef..77997258 100644 --- a/internal/api/graphql/graph/resolver/component_instance.go +++ b/internal/api/graphql/graph/resolver/component_instance.go @@ -33,7 +33,7 @@ func (r *componentInstanceResolver) ComponentVersion(ctx context.Context, obj *m } func (r *componentInstanceResolver) IssueMatches(ctx context.Context, obj *model.ComponentInstance, filter *model.IssueMatchFilter, first *int, after *string) (*model.IssueMatchConnection, error) { - return baseResolver.IssueMatchBaseResolver(r.App, ctx, filter, first, after, &model.NodeParent{ + return baseResolver.IssueMatchBaseResolver(r.App, ctx, filter, first, after, nil, &model.NodeParent{ Parent: obj, ParentName: model.ComponentInstanceNodeName, }) diff --git a/internal/api/graphql/graph/resolver/evidence.go b/internal/api/graphql/graph/resolver/evidence.go index 76175a33..268e20d6 100644 --- a/internal/api/graphql/graph/resolver/evidence.go +++ b/internal/api/graphql/graph/resolver/evidence.go @@ -48,7 +48,7 @@ func (r *evidenceResolver) Activity(ctx context.Context, obj *model.Evidence) (* } func (r *evidenceResolver) IssueMatches(ctx context.Context, obj *model.Evidence, filter *model.IssueMatchFilter, first *int, after *string) (*model.IssueMatchConnection, error) { - return baseResolver.IssueMatchBaseResolver(r.App, ctx, filter, first, after, &model.NodeParent{ + return baseResolver.IssueMatchBaseResolver(r.App, ctx, filter, first, after, nil, &model.NodeParent{ Parent: obj, ParentName: model.EvidenceNodeName, }) diff --git a/internal/api/graphql/graph/resolver/issue.go b/internal/api/graphql/graph/resolver/issue.go index 36b06d40..1657474a 100644 --- a/internal/api/graphql/graph/resolver/issue.go +++ b/internal/api/graphql/graph/resolver/issue.go @@ -30,7 +30,7 @@ func (r *issueResolver) Activities(ctx context.Context, obj *model.Issue, filter } func (r *issueResolver) IssueMatches(ctx context.Context, obj *model.Issue, filter *model.IssueMatchFilter, first *int, after *string) (*model.IssueMatchConnection, error) { - return baseResolver.IssueMatchBaseResolver(r.App, ctx, filter, first, after, &model.NodeParent{ + return baseResolver.IssueMatchBaseResolver(r.App, ctx, filter, first, after, nil, &model.NodeParent{ Parent: obj, ParentName: model.IssueNodeName, }) diff --git a/internal/api/graphql/graph/resolver/query.go b/internal/api/graphql/graph/resolver/query.go index 7fb5d14d..78785c4f 100644 --- a/internal/api/graphql/graph/resolver/query.go +++ b/internal/api/graphql/graph/resolver/query.go @@ -21,8 +21,8 @@ func (r *queryResolver) Issues(ctx context.Context, filter *model.IssueFilter, f return baseResolver.IssueBaseResolver(r.App, ctx, filter, first, after, nil) } -func (r *queryResolver) IssueMatches(ctx context.Context, filter *model.IssueMatchFilter, first *int, after *string) (*model.IssueMatchConnection, error) { - return baseResolver.IssueMatchBaseResolver(r.App, ctx, filter, first, after, nil) +func (r *queryResolver) IssueMatches(ctx context.Context, filter *model.IssueMatchFilter, first *int, after *string, orderBy []*model.IssueMatchOrderBy) (*model.IssueMatchConnection, error) { + return baseResolver.IssueMatchBaseResolver(r.App, ctx, filter, first, after, orderBy, nil) } func (r *queryResolver) IssueMatchChanges(ctx context.Context, filter *model.IssueMatchChangeFilter, first *int, after *string) (*model.IssueMatchChangeConnection, error) { diff --git a/internal/api/graphql/graph/schema/common.graphqls b/internal/api/graphql/graph/schema/common.graphqls index 11b632ae..cdd91a45 100644 --- a/internal/api/graphql/graph/schema/common.graphqls +++ b/internal/api/graphql/graph/schema/common.graphqls @@ -114,3 +114,8 @@ type Metadata { updated_at: DateTime updated_by: String } + +enum OrderDirection { + asc + desc +} diff --git a/internal/api/graphql/graph/schema/issue_match.graphqls b/internal/api/graphql/graph/schema/issue_match.graphqls index 70108ed7..481ed87a 100644 --- a/internal/api/graphql/graph/schema/issue_match.graphqls +++ b/internal/api/graphql/graph/schema/issue_match.graphqls @@ -65,3 +65,14 @@ enum IssueMatchStatusValues { false_positive mitigated } + +input IssueMatchOrderBy { + by: IssueMatchOrderByField + direction: OrderDirection +} + +enum IssueMatchOrderByField { + primaryName + targetRemediationDate + componentInstanceCcrn +} diff --git a/internal/api/graphql/graph/schema/query.graphqls b/internal/api/graphql/graph/schema/query.graphqls index 334c1316..e63cdc12 100644 --- a/internal/api/graphql/graph/schema/query.graphqls +++ b/internal/api/graphql/graph/schema/query.graphqls @@ -3,7 +3,7 @@ type Query { Issues(filter: IssueFilter, first: Int, after: String): IssueConnection - IssueMatches(filter: IssueMatchFilter, first: Int, after: String): IssueMatchConnection + IssueMatches(filter: IssueMatchFilter, first: Int, after: String, orderBy: [IssueMatchOrderBy]): IssueMatchConnection IssueMatchChanges(filter: IssueMatchChangeFilter, first: Int, after: String): IssueMatchChangeConnection Services(filter: ServiceFilter, first: Int, after: String): ServiceConnection Components(filter: ComponentFilter, first: Int, after: String): ComponentConnection diff --git a/internal/app/issue/issue_handler_events.go b/internal/app/issue/issue_handler_events.go index f553601d..5b09deb8 100644 --- a/internal/app/issue/issue_handler_events.go +++ b/internal/app/issue/issue_handler_events.go @@ -158,7 +158,7 @@ func createIssueMatches( issue_matches, err := db.GetIssueMatches(&entity.IssueMatchFilter{ IssueId: []*int64{&issueId}, ComponentInstanceId: []*int64{&componentInstanceId}, - }) + }, []entity.Order{}) if err != nil { l.WithField("event-step", "FetchIssueMatches").WithError(err).Error("Error while fetching issue matches related to assigned Component Instance") diff --git a/internal/app/issue/issue_handler_events_test.go b/internal/app/issue/issue_handler_events_test.go index e6422882..06856757 100644 --- a/internal/app/issue/issue_handler_events_test.go +++ b/internal/app/issue/issue_handler_events_test.go @@ -88,7 +88,7 @@ var _ = Describe("OnComponentVersionAttachmentToIssue", Label("app", "ComponentV db.On("GetIssueMatches", &entity.IssueMatchFilter{ ComponentInstanceId: []*int64{&componentInstance.Id}, IssueId: []*int64{&issueEntity.Id}, - }).Return([]entity.IssueMatch{}, nil) + }, []entity.Order{}).Return([]entity.IssueMatch{}, nil) db.On("GetServiceIssueVariants", &entity.ServiceIssueVariantFilter{ ComponentInstanceId: []*int64{&componentInstance.Id}, @@ -123,7 +123,7 @@ var _ = Describe("OnComponentVersionAttachmentToIssue", Label("app", "ComponentV db.On("GetIssueMatches", &entity.IssueMatchFilter{ ComponentInstanceId: []*int64{&componentInstance.Id}, IssueId: []*int64{&issueEntity.Id}, - }).Return([]entity.IssueMatch{existingMatch}, nil) + }, []entity.Order{}).Return([]entity.IssueMatch{existingMatch}, nil) issue.OnComponentVersionAttachmentToIssue(db, event) db.AssertNotCalled(GinkgoT(), "CreateIssueMatch", mock.Anything) diff --git a/internal/app/issue_match/issue_match_handler.go b/internal/app/issue_match/issue_match_handler.go index 6191b451..5ed01aa9 100644 --- a/internal/app/issue_match/issue_match_handler.go +++ b/internal/app/issue_match/issue_match_handler.go @@ -42,9 +42,9 @@ func (e *IssueMatchHandlerError) Error() string { return e.message } -func (h *issueMatchHandler) getIssueMatchResults(filter *entity.IssueMatchFilter) ([]entity.IssueMatchResult, error) { +func (h *issueMatchHandler) getIssueMatchResults(filter *entity.IssueMatchFilter, order []entity.Order) ([]entity.IssueMatchResult, error) { var results []entity.IssueMatchResult - ims, err := h.database.GetIssueMatches(filter) + ims, err := h.database.GetIssueMatches(filter, order) if err != nil { return nil, err } @@ -65,7 +65,8 @@ func (im *issueMatchHandler) GetIssueMatch(issueMatchId int64) (*entity.IssueMat "id": issueMatchId, }) issueMatchFilter := entity.IssueMatchFilter{Id: []*int64{&issueMatchId}} - issueMatches, err := im.ListIssueMatches(&issueMatchFilter, &entity.ListOptions{}) + options := entity.ListOptions{Order: []entity.Order{}} + issueMatches, err := im.ListIssueMatches(&issueMatchFilter, &options) if err != nil { l.Error(err) @@ -95,7 +96,7 @@ func (im *issueMatchHandler) ListIssueMatches(filter *entity.IssueMatchFilter, o "filter": filter, }) - res, err := im.getIssueMatchResults(filter) + res, err := im.getIssueMatchResults(filter, options.Order) if err != nil { l.Error(err) diff --git a/internal/app/issue_match/issue_match_handler_events.go b/internal/app/issue_match/issue_match_handler_events.go index 3003e2ce..1460b30d 100644 --- a/internal/app/issue_match/issue_match_handler_events.go +++ b/internal/app/issue_match/issue_match_handler_events.go @@ -171,7 +171,7 @@ func OnComponentVersionAssignmentToComponentInstance(db database.Database, compo issue_matches, err := db.GetIssueMatches(&entity.IssueMatchFilter{ IssueId: []*int64{&issueId}, ComponentInstanceId: []*int64{&componentInstanceID}, - }) + }, nil) if err != nil { l.WithField("event-step", "FetchIssueMatches").WithError(err).Error("Error while fetching issue matches related to assigned Component Instance") diff --git a/internal/app/issue_match/issue_match_handler_test.go b/internal/app/issue_match/issue_match_handler_test.go index 5155e289..1eeb6411 100644 --- a/internal/app/issue_match/issue_match_handler_test.go +++ b/internal/app/issue_match/issue_match_handler_test.go @@ -71,7 +71,7 @@ var _ = Describe("When listing IssueMatches", Label("app", "ListIssueMatches"), BeforeEach(func() { options.ShowTotalCount = true - db.On("GetIssueMatches", filter).Return([]entity.IssueMatch{}, nil) + db.On("GetIssueMatches", filter, []entity.Order{}).Return([]entity.IssueMatch{}, nil) db.On("CountIssueMatches", filter).Return(int64(1337), nil) }) @@ -97,7 +97,7 @@ var _ = Describe("When listing IssueMatches", Label("app", "ListIssueMatches"), i++ ids = append(ids, i) } - db.On("GetIssueMatches", filter).Return(matches, nil) + db.On("GetIssueMatches", filter, []entity.Order{}).Return(matches, nil) db.On("GetAllIssueMatchIds", filter).Return(ids, nil) issueMatchHandler = im.NewIssueMatchHandler(db, er, nil) res, err := issueMatchHandler.ListIssueMatches(filter, options) @@ -121,7 +121,7 @@ var _ = Describe("When listing IssueMatches", Label("app", "ListIssueMatches"), Context("and the given filter does not have any matches in the database", func() { BeforeEach(func() { - db.On("GetIssueMatches", filter).Return([]entity.IssueMatch{}, nil) + db.On("GetIssueMatches", filter, []entity.Order{}).Return([]entity.IssueMatch{}, nil) }) It("should return an empty result", func() { @@ -134,7 +134,7 @@ var _ = Describe("When listing IssueMatches", Label("app", "ListIssueMatches"), }) Context("and the filter does have results in the database", func() { BeforeEach(func() { - db.On("GetIssueMatches", filter).Return(test.NNewFakeIssueMatches(15), nil) + db.On("GetIssueMatches", filter, []entity.Order{}).Return(test.NNewFakeIssueMatches(15), nil) }) It("should return the expected matches in the result", func() { issueMatchHandler = im.NewIssueMatchHandler(db, er, nil) @@ -146,7 +146,7 @@ var _ = Describe("When listing IssueMatches", Label("app", "ListIssueMatches"), Context("and the database operations throw an error", func() { BeforeEach(func() { - db.On("GetIssueMatches", filter).Return([]entity.IssueMatch{}, errors.New("some error")) + db.On("GetIssueMatches", filter, []entity.Order{}).Return([]entity.IssueMatch{}, errors.New("some error")) }) It("should return the expected matches in the result", func() { @@ -250,7 +250,7 @@ var _ = Describe("When updating IssueMatch", Label("app", "UpdateIssueMatch"), f issueMatch.Status = entity.NewIssueMatchStatusValue("new") } filter.Id = []*int64{&issueMatch.Id} - db.On("GetIssueMatches", filter).Return([]entity.IssueMatch{issueMatch}, nil) + db.On("GetIssueMatches", filter, []entity.Order{}).Return([]entity.IssueMatch{issueMatch}, nil) updatedIssueMatch, err := issueMatchHandler.UpdateIssueMatch(&issueMatch) Expect(err).To(BeNil(), "no error should be thrown") By("setting fields", func() { @@ -273,6 +273,7 @@ var _ = Describe("When deleting IssueMatch", Label("app", "DeleteIssueMatch"), f issueMatchHandler im.IssueMatchHandler id int64 filter *entity.IssueMatchFilter + options *entity.ListOptions ) BeforeEach(func() { @@ -287,17 +288,18 @@ var _ = Describe("When deleting IssueMatch", Label("app", "DeleteIssueMatch"), f After: &after, }, } + options = entity.NewListOptions() }) It("deletes issueMatch", func() { db.On("DeleteIssueMatch", id).Return(nil) issueMatchHandler = im.NewIssueMatchHandler(db, er, nil) - db.On("GetIssueMatches", filter).Return([]entity.IssueMatch{}, nil) + db.On("GetIssueMatches", filter, []entity.Order{}).Return([]entity.IssueMatch{}, nil) err := issueMatchHandler.DeleteIssueMatch(id) Expect(err).To(BeNil(), "no error should be thrown") filter.Id = []*int64{&id} - issueMatches, err := issueMatchHandler.ListIssueMatches(filter, &entity.ListOptions{}) + issueMatches, err := issueMatchHandler.ListIssueMatches(filter, options) Expect(err).To(BeNil(), "no error should be thrown") Expect(issueMatches.Elements).To(BeEmpty(), "no error should be thrown") }) @@ -330,7 +332,7 @@ var _ = Describe("When modifying relationship of evidence and issueMatch", Label It("adds evidence to issueMatch", func() { db.On("AddEvidenceToIssueMatch", issueMatch.Id, evidence.Id).Return(nil) - db.On("GetIssueMatches", filter).Return([]entity.IssueMatch{issueMatch}, nil) + db.On("GetIssueMatches", filter, []entity.Order{}).Return([]entity.IssueMatch{issueMatch}, nil) issueMatchHandler = im.NewIssueMatchHandler(db, er, nil) issueMatch, err := issueMatchHandler.AddEvidenceToIssueMatch(issueMatch.Id, evidence.Id) Expect(err).To(BeNil(), "no error should be thrown") @@ -339,7 +341,7 @@ var _ = Describe("When modifying relationship of evidence and issueMatch", Label It("removes evidence from issueMatch", func() { db.On("RemoveEvidenceFromIssueMatch", issueMatch.Id, evidence.Id).Return(nil) - db.On("GetIssueMatches", filter).Return([]entity.IssueMatch{issueMatch}, nil) + db.On("GetIssueMatches", filter, []entity.Order{}).Return([]entity.IssueMatch{issueMatch}, nil) issueMatchHandler = im.NewIssueMatchHandler(db, er, nil) issueMatch, err := issueMatchHandler.RemoveEvidenceFromIssueMatch(issueMatch.Id, evidence.Id) Expect(err).To(BeNil(), "no error should be thrown") @@ -467,7 +469,7 @@ var _ = Describe("OnComponentInstanceCreate", Label("app", "OnComponentInstanceC }) It("should create issue matches for each issue", func() { - db.On("GetIssueMatches", mock.Anything).Return([]entity.IssueMatch{}, nil) + db.On("GetIssueMatches", mock.Anything, mock.Anything).Return([]entity.IssueMatch{}, nil) // Mock CreateIssueMatch db.On("CreateIssueMatch", mock.AnythingOfType("*entity.IssueMatch")).Return(&entity.IssueMatch{}, nil).Twice() im.OnComponentVersionAssignmentToComponentInstance(db, componentInstanceID, componentVersionID) @@ -482,7 +484,7 @@ var _ = Describe("OnComponentInstanceCreate", Label("app", "OnComponentInstanceC issueMatch := test.NewFakeIssueMatch() issueMatch.IssueId = 2 // issue2.Id //when issueid is 2 return a fake issue match - db.On("GetIssueMatches", mock.Anything).Return([]entity.IssueMatch{issueMatch}, nil).Once() + db.On("GetIssueMatches", mock.Anything, mock.Anything).Return([]entity.IssueMatch{issueMatch}, nil).Once() }) It("should should not create new issues", func() { diff --git a/internal/database/interface.go b/internal/database/interface.go index 8f4cfb85..c78ca1d1 100644 --- a/internal/database/interface.go +++ b/internal/database/interface.go @@ -35,7 +35,7 @@ type Database interface { GetDefaultIssuePriority() int64 GetDefaultRepositoryName() string - GetIssueMatches(*entity.IssueMatchFilter) ([]entity.IssueMatch, error) + GetIssueMatches(*entity.IssueMatchFilter, []entity.Order) ([]entity.IssueMatch, error) GetAllIssueMatchIds(*entity.IssueMatchFilter) ([]int64, error) CountIssueMatches(filter *entity.IssueMatchFilter) (int64, error) CreateIssueMatch(*entity.IssueMatch) (*entity.IssueMatch, error) diff --git a/internal/database/mariadb/database.go b/internal/database/mariadb/database.go index 55dd60a8..c5592fbc 100644 --- a/internal/database/mariadb/database.go +++ b/internal/database/mariadb/database.go @@ -391,3 +391,11 @@ func getCursor(p entity.Paginated, filterStr string, stmt string) entity.Cursor Limit: limit, } } + +func GetDefaultOrder(order []entity.Order, by entity.DbColumnName, direction entity.OrderDirection) []entity.Order { + if len(order) == 0 { + order = append([]entity.Order{{By: by, Direction: direction}}, order...) + } + + return order +} diff --git a/internal/database/mariadb/issue_match.go b/internal/database/mariadb/issue_match.go index 6ba5466a..1f613b7f 100644 --- a/internal/database/mariadb/issue_match.go +++ b/internal/database/mariadb/issue_match.go @@ -9,6 +9,7 @@ import ( "github.com/cloudoperators/heureka/internal/entity" "github.com/jmoiron/sqlx" + "github.com/samber/lo" "github.com/sirupsen/logrus" ) @@ -47,10 +48,16 @@ func (s *SqlDatabase) getIssueMatchFilterString(filter *entity.IssueMatchFilter) return combineFilterQueries(fl, OP_AND) } -func (s *SqlDatabase) getIssueMatchJoins(filter *entity.IssueMatchFilter) string { +func (s *SqlDatabase) getIssueMatchJoins(filter *entity.IssueMatchFilter, order []entity.Order) string { joins := "" + orderByIssuePrimaryName := lo.ContainsBy(order, func(o entity.Order) bool { + return o.By == entity.IssuePrimaryName + }) + orderByCiCcrn := lo.ContainsBy(order, func(o entity.Order) bool { + return o.By == entity.ComponentInstanceCcrn + }) - if len(filter.Search) > 0 || len(filter.IssueType) > 0 || len(filter.PrimaryName) > 0 { + if len(filter.Search) > 0 || len(filter.IssueType) > 0 || len(filter.PrimaryName) > 0 || orderByIssuePrimaryName { joins = fmt.Sprintf("%s\n%s", joins, ` LEFT JOIN Issue I on I.issue_id = IM.issuematch_issue_id `) @@ -93,6 +100,13 @@ func (s *SqlDatabase) getIssueMatchJoins(filter *entity.IssueMatchFilter) string `) } } + + if orderByCiCcrn { + joins = fmt.Sprintf("%s\n%s", joins, ` + LEFT JOIN ComponentInstance CI on CI.componentinstance_id = IM.issuematch_component_instance_id + `) + } + return joins } @@ -128,14 +142,16 @@ func (s *SqlDatabase) getIssueMatchUpdateFields(issueMatch *entity.IssueMatch) s return strings.Join(fl, ", ") } -func (s *SqlDatabase) buildIssueMatchStatement(baseQuery string, filter *entity.IssueMatchFilter, withCursor bool, l *logrus.Entry) (*sqlx.Stmt, []interface{}, error) { +func (s *SqlDatabase) buildIssueMatchStatement(baseQuery string, filter *entity.IssueMatchFilter, withCursor bool, order []entity.Order, l *logrus.Entry) (*sqlx.Stmt, []interface{}, error) { var query string filter = s.ensureIssueMatchFilter(filter) l.WithFields(logrus.Fields{"filter": filter}) filterStr := s.getIssueMatchFilterString(filter) - joins := s.getIssueMatchJoins(filter) + joins := s.getIssueMatchJoins(filter, order) cursor := getCursor(filter.Paginated, filterStr, "IM.issuematch_id > ?") + order = GetDefaultOrder(order, entity.IssueMatchId, entity.OrderDirectionAsc) + orderStr := entity.CreateOrderString(order) whereClause := "" if filterStr != "" || withCursor { @@ -144,9 +160,9 @@ func (s *SqlDatabase) buildIssueMatchStatement(baseQuery string, filter *entity. // construct final query if withCursor { - query = fmt.Sprintf(baseQuery, joins, whereClause, cursor.Statement) + query = fmt.Sprintf(baseQuery, joins, whereClause, cursor.Statement, orderStr) } else { - query = fmt.Sprintf(baseQuery, joins, whereClause) + query = fmt.Sprintf(baseQuery, joins, whereClause, orderStr) } //construct prepared statement and if where clause does exist add parameters @@ -197,10 +213,10 @@ func (s *SqlDatabase) GetAllIssueMatchIds(filter *entity.IssueMatchFilter) ([]in baseQuery := ` SELECT IM.issuematch_id FROM IssueMatch IM %s - %s GROUP BY IM.issuematch_id ORDER BY IM.issuematch_id + %s GROUP BY IM.issuematch_id ORDER BY %s ` - stmt, filterParameters, err := s.buildIssueMatchStatement(baseQuery, filter, false, l) + stmt, filterParameters, err := s.buildIssueMatchStatement(baseQuery, filter, false, []entity.Order{}, l) if err != nil { return nil, err @@ -209,7 +225,7 @@ func (s *SqlDatabase) GetAllIssueMatchIds(filter *entity.IssueMatchFilter) ([]in return performIdScan(stmt, filterParameters, l) } -func (s *SqlDatabase) GetIssueMatches(filter *entity.IssueMatchFilter) ([]entity.IssueMatch, error) { +func (s *SqlDatabase) GetIssueMatches(filter *entity.IssueMatchFilter, order []entity.Order) ([]entity.IssueMatch, error) { l := logrus.WithFields(logrus.Fields{ "filter": filter, "event": "database.GetIssueMatches", @@ -218,10 +234,10 @@ func (s *SqlDatabase) GetIssueMatches(filter *entity.IssueMatchFilter) ([]entity baseQuery := ` SELECT IM.* FROM IssueMatch IM %s - %s %s GROUP BY IM.issuematch_id ORDER BY IM.issuematch_id LIMIT ? + %s %s GROUP BY IM.issuematch_id ORDER BY %s LIMIT ? ` - stmt, filterParameters, err := s.buildIssueMatchStatement(baseQuery, filter, true, l) + stmt, filterParameters, err := s.buildIssueMatchStatement(baseQuery, filter, true, order, l) if err != nil { return nil, err @@ -247,9 +263,10 @@ func (s *SqlDatabase) CountIssueMatches(filter *entity.IssueMatchFilter) (int64, SELECT count(distinct IM.issuematch_id) FROM IssueMatch IM %s %s + ORDER BY %s ` - stmt, filterParameters, err := s.buildIssueMatchStatement(baseQuery, filter, false, l) + stmt, filterParameters, err := s.buildIssueMatchStatement(baseQuery, filter, false, []entity.Order{}, l) if err != nil { return -1, err diff --git a/internal/database/mariadb/issue_match_test.go b/internal/database/mariadb/issue_match_test.go index 46dfd8fa..9f26f82d 100644 --- a/internal/database/mariadb/issue_match_test.go +++ b/internal/database/mariadb/issue_match_test.go @@ -5,6 +5,8 @@ package mariadb_test import ( "math/rand" + "sort" + "time" "github.com/samber/lo" @@ -135,7 +137,7 @@ var _ = Describe("IssueMatch", Label("database", "IssueMatch"), func() { When("Getting IssueMatches", Label("GetIssueMatches"), func() { Context("and the database is empty", func() { It("can perform the query", func() { - res, err := db.GetIssueMatches(nil) + res, err := db.GetIssueMatches(nil, nil) By("throwing no error", func() { Expect(err).To(BeNil()) @@ -156,7 +158,7 @@ var _ = Describe("IssueMatch", Label("database", "IssueMatch"), func() { Context("and using no filter", func() { It("can fetch the items correctly", func() { - res, err := db.GetIssueMatches(nil) + res, err := db.GetIssueMatches(nil, nil) By("throwing no error", func() { Expect(err).Should(BeNil()) @@ -196,7 +198,7 @@ var _ = Describe("IssueMatch", Label("database", "IssueMatch"), func() { Id: []*int64{&im.Id.Int64}, } - entries, err := db.GetIssueMatches(filter) + entries, err := db.GetIssueMatches(filter, nil) By("throwing no error", func() { Expect(err).To(BeNil()) @@ -224,7 +226,7 @@ var _ = Describe("IssueMatch", Label("database", "IssueMatch"), func() { } } - entries, err := db.GetIssueMatches(filter) + entries, err := db.GetIssueMatches(filter, nil) By("throwing no error", func() { Expect(err).To(BeNil()) @@ -254,7 +256,7 @@ var _ = Describe("IssueMatch", Label("database", "IssueMatch"), func() { } } - entries, err := db.GetIssueMatches(filter) + entries, err := db.GetIssueMatches(filter, nil) By("throwing no error", func() { Expect(err).To(BeNil()) @@ -284,7 +286,7 @@ var _ = Describe("IssueMatch", Label("database", "IssueMatch"), func() { } } - entries, err := db.GetIssueMatches(filter) + entries, err := db.GetIssueMatches(filter, nil) By("throwing no error", func() { Expect(err).To(BeNil()) @@ -322,7 +324,7 @@ var _ = Describe("IssueMatch", Label("database", "IssueMatch"), func() { // fixture creation does not guarantee that a support group is always present if sgFound { - entries, err := db.GetIssueMatches(filter) + entries, err := db.GetIssueMatches(filter, nil) By("throwing no error", func() { Expect(err).To(BeNil()) @@ -342,7 +344,7 @@ var _ = Describe("IssueMatch", Label("database", "IssueMatch"), func() { }) Context("and and we use Pagination", func() { DescribeTable("can correctly paginate ", func(pageSize int) { - test.TestPaginationOfList( + test.TestPaginationOfListWithOrder( db.GetIssueMatches, func(first *int, after *int64) *entity.IssueMatchFilter { return &entity.IssueMatchFilter{ @@ -352,6 +354,7 @@ var _ = Describe("IssueMatch", Label("database", "IssueMatch"), func() { }, } }, + []entity.Order{}, func(entries []entity.IssueMatch) *int64 { return &entries[len(entries)-1].Id }, len(issueMatches), pageSize, @@ -472,7 +475,7 @@ var _ = Describe("IssueMatch", Label("database", "IssueMatch"), func() { Id: []*int64{&issueMatch.Id}, } - im, err := db.GetIssueMatches(issueMatchFilter) + im, err := db.GetIssueMatches(issueMatchFilter, nil) By("throwing no error", func() { Expect(err).To(BeNil()) }) @@ -517,7 +520,7 @@ var _ = Describe("IssueMatch", Label("database", "IssueMatch"), func() { Id: []*int64{&issueMatch.Id}, } - im, err := db.GetIssueMatches(issueMatchFilter) + im, err := db.GetIssueMatches(issueMatchFilter, nil) By("throwing no error", func() { Expect(err).To(BeNil()) }) @@ -556,7 +559,7 @@ var _ = Describe("IssueMatch", Label("database", "IssueMatch"), func() { Id: []*int64{&issueMatch.Id}, } - im, err := db.GetIssueMatches(issueMatchFilter) + im, err := db.GetIssueMatches(issueMatchFilter, nil) By("throwing no error", func() { Expect(err).To(BeNil()) }) @@ -597,7 +600,7 @@ var _ = Describe("IssueMatch", Label("database", "IssueMatch"), func() { EvidenceId: []*int64{&evidence.Id}, } - im, err := db.GetIssueMatches(issueMatchFilter) + im, err := db.GetIssueMatches(issueMatchFilter, nil) By("throwing no error", func() { Expect(err).To(BeNil()) }) @@ -626,7 +629,7 @@ var _ = Describe("IssueMatch", Label("database", "IssueMatch"), func() { EvidenceId: []*int64{&issueMatchEvidenceRow.EvidenceId.Int64}, } - issueMatches, err := db.GetIssueMatches(issueMatchFilter) + issueMatches, err := db.GetIssueMatches(issueMatchFilter, nil) By("throwing no error", func() { Expect(err).To(BeNil()) }) @@ -638,3 +641,340 @@ var _ = Describe("IssueMatch", Label("database", "IssueMatch"), func() { }) }) }) + +var _ = Describe("Ordering IssueMatches", func() { + var db *mariadb.SqlDatabase + var seeder *test.DatabaseSeeder + var seedCollection *test.SeedCollection + + BeforeEach(func() { + var err error + db = dbm.NewTestSchema() + seeder, err = test.NewDatabaseSeeder(dbm.DbConfig()) + Expect(err).To(BeNil(), "Database Seeder Setup should work") + }) + + var testOrder = func( + order []entity.Order, + verifyFunc func(res []entity.IssueMatch), + ) { + res, err := db.GetIssueMatches(nil, order) + + By("throwing no error", func() { + Expect(err).Should(BeNil()) + }) + + By("returning the correct number of results", func() { + Expect(len(res)).Should(BeIdenticalTo(len(seedCollection.IssueMatchRows))) + }) + + By("returning the correct order", func() { + verifyFunc(res) + }) + } + + When("with ASC order", Label("IssueMatchASCOrder"), func() { + + BeforeEach(func() { + seedCollection = seeder.SeedDbWithNFakeData(10) + }) + + It("can order by id", func() { + sort.Slice(seedCollection.IssueMatchRows, func(i, j int) bool { + return seedCollection.IssueMatchRows[i].Id.Int64 < seedCollection.IssueMatchRows[j].Id.Int64 + }) + + order := []entity.Order{ + {By: entity.IssueMatchId, Direction: entity.OrderDirectionAsc}, + } + + testOrder(order, func(res []entity.IssueMatch) { + for i, r := range res { + Expect(r.Id).Should(BeEquivalentTo(seedCollection.IssueMatchRows[i].Id.Int64)) + } + }) + }) + + It("can order by primaryName", func() { + sort.Slice(seedCollection.IssueMatchRows, func(i, j int) bool { + issueI := seedCollection.GetIssueById(seedCollection.IssueMatchRows[i].IssueId.Int64) + issueJ := seedCollection.GetIssueById(seedCollection.IssueMatchRows[j].IssueId.Int64) + return issueI.PrimaryName.String < issueJ.PrimaryName.String + }) + + order := []entity.Order{ + {By: entity.IssuePrimaryName, Direction: entity.OrderDirectionAsc}, + } + + testOrder(order, func(res []entity.IssueMatch) { + var prev string = "" + for _, r := range res { + issue := seedCollection.GetIssueById(r.IssueId) + Expect(issue).ShouldNot(BeNil()) + Expect(issue.PrimaryName.String >= prev).Should(BeTrue()) + prev = issue.PrimaryName.String + } + }) + }) + + It("can order by targetRemediationDate", func() { + sort.Slice(seedCollection.IssueMatchRows, func(i, j int) bool { + return seedCollection.IssueMatchRows[i].TargetRemediationDate.Time.After(seedCollection.IssueMatchRows[j].TargetRemediationDate.Time) + }) + + order := []entity.Order{ + {By: entity.IssueMatchTargetRemediationDate, Direction: entity.OrderDirectionAsc}, + } + + testOrder(order, func(res []entity.IssueMatch) { + var prev time.Time = time.Time{} + for _, r := range res { + Expect(r.TargetRemediationDate.After(prev)).Should(BeTrue()) + prev = r.TargetRemediationDate + + } + }) + }) + + It("can order by rating", func() { + sort.Slice(seedCollection.IssueMatchRows, func(i, j int) bool { + r1 := test.SeverityToNumerical(seedCollection.IssueMatchRows[i].Rating.String) + r2 := test.SeverityToNumerical(seedCollection.IssueMatchRows[j].Rating.String) + return r1 < r2 + }) + + order := []entity.Order{ + {By: entity.IssueMatchRating, Direction: entity.OrderDirectionAsc}, + } + + testOrder(order, func(res []entity.IssueMatch) { + for i, r := range res { + Expect(r.Id).Should(BeEquivalentTo(seedCollection.IssueMatchRows[i].Id.Int64)) + } + }) + }) + + It("can order by component instance ccrn", func() { + sort.Slice(seedCollection.IssueMatchRows, func(i, j int) bool { + ciI := seedCollection.GetComponentInstanceById(seedCollection.IssueMatchRows[i].ComponentInstanceId.Int64) + ciJ := seedCollection.GetComponentInstanceById(seedCollection.IssueMatchRows[j].ComponentInstanceId.Int64) + return ciI.CCRN.String < ciJ.CCRN.String + }) + + order := []entity.Order{ + {By: entity.ComponentInstanceCcrn, Direction: entity.OrderDirectionAsc}, + } + + testOrder(order, func(res []entity.IssueMatch) { + var prev string = "" + for _, r := range res { + ci := seedCollection.GetComponentInstanceById(r.ComponentInstanceId) + Expect(ci).ShouldNot(BeNil()) + Expect(ci.CCRN.String >= prev).Should(BeTrue()) + prev = ci.CCRN.String + } + }) + }) + }) + + When("with DESC order", Label("IssueMatchDESCOrder"), func() { + + BeforeEach(func() { + seedCollection = seeder.SeedDbWithNFakeData(10) + }) + + It("can order by id", func() { + sort.Slice(seedCollection.IssueMatchRows, func(i, j int) bool { + return seedCollection.IssueMatchRows[i].Id.Int64 > seedCollection.IssueMatchRows[j].Id.Int64 + }) + + order := []entity.Order{ + {By: entity.IssueMatchId, Direction: entity.OrderDirectionDesc}, + } + + testOrder(order, func(res []entity.IssueMatch) { + for i, r := range res { + Expect(r.Id).Should(BeEquivalentTo(seedCollection.IssueMatchRows[i].Id.Int64)) + } + }) + }) + + It("can order by primaryName", func() { + sort.Slice(seedCollection.IssueMatchRows, func(i, j int) bool { + issueI := seedCollection.GetIssueById(seedCollection.IssueMatchRows[i].IssueId.Int64) + issueJ := seedCollection.GetIssueById(seedCollection.IssueMatchRows[j].IssueId.Int64) + return issueI.PrimaryName.String > issueJ.PrimaryName.String + }) + + order := []entity.Order{ + {By: entity.IssuePrimaryName, Direction: entity.OrderDirectionDesc}, + } + + testOrder(order, func(res []entity.IssueMatch) { + var prev string = "\U0010FFFF" + for _, r := range res { + issue := seedCollection.GetIssueById(r.IssueId) + Expect(issue).ShouldNot(BeNil()) + Expect(issue.PrimaryName.String <= prev).Should(BeTrue()) + prev = issue.PrimaryName.String + } + }) + }) + + It("can order by targetRemediationDate", func() { + sort.Slice(seedCollection.IssueMatchRows, func(i, j int) bool { + return seedCollection.IssueMatchRows[i].TargetRemediationDate.Time.Before(seedCollection.IssueMatchRows[j].TargetRemediationDate.Time) + }) + + order := []entity.Order{ + {By: entity.IssueMatchTargetRemediationDate, Direction: entity.OrderDirectionDesc}, + } + + testOrder(order, func(res []entity.IssueMatch) { + var prev time.Time = time.Now() + for _, r := range res { + Expect(r.TargetRemediationDate.Before(prev)).Should(BeTrue()) + prev = r.TargetRemediationDate + + } + }) + }) + + It("can order by rating", func() { + sort.Slice(seedCollection.IssueMatchRows, func(i, j int) bool { + r1 := test.SeverityToNumerical(seedCollection.IssueMatchRows[i].Rating.String) + r2 := test.SeverityToNumerical(seedCollection.IssueMatchRows[j].Rating.String) + return r1 > r2 + }) + + order := []entity.Order{ + {By: entity.IssueMatchRating, Direction: entity.OrderDirectionDesc}, + } + + testOrder(order, func(res []entity.IssueMatch) { + for i, r := range res { + Expect(r.Id).Should(BeEquivalentTo(seedCollection.IssueMatchRows[i].Id.Int64)) + } + }) + }) + + It("can order by component instance ccrn", func() { + sort.Slice(seedCollection.IssueMatchRows, func(i, j int) bool { + ciI := seedCollection.GetComponentInstanceById(seedCollection.IssueMatchRows[i].ComponentInstanceId.Int64) + ciJ := seedCollection.GetComponentInstanceById(seedCollection.IssueMatchRows[j].ComponentInstanceId.Int64) + return ciI.CCRN.String > ciJ.CCRN.String + }) + + order := []entity.Order{ + {By: entity.ComponentInstanceCcrn, Direction: entity.OrderDirectionDesc}, + } + + testOrder(order, func(res []entity.IssueMatch) { + var prev string = "\U0010FFFF" + for _, r := range res { + ci := seedCollection.GetComponentInstanceById(r.ComponentInstanceId) + Expect(ci).ShouldNot(BeNil()) + Expect(ci.CCRN.String <= prev).Should(BeTrue()) + prev = ci.CCRN.String + } + }) + }) + }) + + When("multiple order by used", Label("IssueMatchMultipleOrderBy"), func() { + + BeforeEach(func() { + users := seeder.SeedUsers(10) + services := seeder.SeedServices(10) + components := seeder.SeedComponents(10) + componentVersions := seeder.SeedComponentVersions(10, components) + componentInstances := seeder.SeedComponentInstances(3, componentVersions, services) + issues := seeder.SeedIssues(3) + issueMatches := seeder.SeedIssueMatches(100, issues, componentInstances, users) + seedCollection = &test.SeedCollection{ + IssueRows: issues, + IssueMatchRows: issueMatches, + ComponentInstanceRows: componentInstances, + } + }) + + It("can order by asc issue primary name and asc targetRemediationDate", func() { + order := []entity.Order{ + {By: entity.IssuePrimaryName, Direction: entity.OrderDirectionAsc}, + {By: entity.IssueMatchTargetRemediationDate, Direction: entity.OrderDirectionAsc}, + } + + testOrder(order, func(res []entity.IssueMatch) { + var prevTrd time.Time = time.Time{} + var prevPn = "" + for _, r := range res { + issue := seedCollection.GetIssueById(r.IssueId) + if issue.PrimaryName.String == prevPn { + Expect(r.TargetRemediationDate.After(prevTrd)).Should(BeTrue()) + prevTrd = r.TargetRemediationDate + } else { + Expect(issue.PrimaryName.String > prevPn).To(BeTrue()) + prevTrd = time.Time{} + } + prevPn = issue.PrimaryName.String + } + }) + }) + + It("can order by asc issue primary name and desc targetRemediationDate", func() { + order := []entity.Order{ + {By: entity.IssuePrimaryName, Direction: entity.OrderDirectionAsc}, + {By: entity.IssueMatchTargetRemediationDate, Direction: entity.OrderDirectionDesc}, + } + + testOrder(order, func(res []entity.IssueMatch) { + var prevTrd time.Time = time.Now() + var prevPn = "" + for _, r := range res { + issue := seedCollection.GetIssueById(r.IssueId) + if issue.PrimaryName.String == prevPn { + Expect(r.TargetRemediationDate.Before(prevTrd)).Should(BeTrue()) + prevTrd = r.TargetRemediationDate + } else { + Expect(issue.PrimaryName.String > prevPn).To(BeTrue()) + prevTrd = time.Now() + } + prevPn = issue.PrimaryName.String + } + }) + }) + + It("can order by asc rating and asc component instance ccrn and asc targetRemediationDate", func() { + order := []entity.Order{ + {By: entity.IssueMatchRating, Direction: entity.OrderDirectionAsc}, + {By: entity.ComponentInstanceCcrn, Direction: entity.OrderDirectionAsc}, + {By: entity.IssueMatchTargetRemediationDate, Direction: entity.OrderDirectionAsc}, + } + + testOrder(order, func(res []entity.IssueMatch) { + var prevSeverity = 0 + var prevCiCcrn = "" + var prevTrd time.Time = time.Time{} + for _, r := range res { + ci := seedCollection.GetComponentInstanceById(r.ComponentInstanceId) + if test.SeverityToNumerical(r.Severity.Value) == prevSeverity { + if ci.CCRN.String == prevCiCcrn { + Expect(r.TargetRemediationDate.After(prevTrd)).To(BeTrue()) + prevTrd = r.TargetRemediationDate + } else { + Expect(ci.CCRN.String > prevCiCcrn).To(BeTrue()) + prevCiCcrn = ci.CCRN.String + prevTrd = time.Time{} + } + } else { + Expect(test.SeverityToNumerical(r.Severity.Value) > prevSeverity).To(BeTrue()) + prevSeverity = test.SeverityToNumerical(r.Severity.Value) + prevCiCcrn = "" + prevTrd = time.Time{} + } + } + }) + }) + + }) +}) diff --git a/internal/database/mariadb/test/common.go b/internal/database/mariadb/test/common.go index c050e0e9..b7eaf176 100644 --- a/internal/database/mariadb/test/common.go +++ b/internal/database/mariadb/test/common.go @@ -8,6 +8,42 @@ import ( . "github.com/onsi/gomega" ) +// Temporary used until order is used in all entities +func TestPaginationOfListWithOrder[F entity.HeurekaFilter, E entity.HeurekaEntity]( + listFunction func(*F, []entity.Order) ([]E, error), + filterFunction func(*int, *int64) *F, + order []entity.Order, + getAfterFunction func([]E) *int64, + elementCount int, + pageSize int, +) { + quotient, remainder := elementCount/pageSize, elementCount%pageSize + expectedPages := quotient + if remainder > 0 { + expectedPages = expectedPages + 1 + } + + var after *int64 + for i := expectedPages; i > 0; i-- { + entries, err := listFunction(filterFunction(&pageSize, after), order) + + Expect(err).To(BeNil()) + + if i == 1 && remainder > 0 { + Expect(len(entries)).To(BeEquivalentTo(remainder), "on the last page we expect") + } else { + if pageSize > elementCount { + Expect(len(entries)).To(BeEquivalentTo(elementCount), "on a page with a higher pageSize then element count we expect") + } else { + Expect(len(entries)).To(BeEquivalentTo(pageSize), "on a normal page we expect the element count to be equal to the page size") + + } + } + after = getAfterFunction(entries) + + } +} + func TestPaginationOfList[F entity.HeurekaFilter, E entity.HeurekaEntity]( listFunction func(*F) ([]E, error), filterFunction func(*int, *int64) *F, @@ -41,3 +77,23 @@ func TestPaginationOfList[F entity.HeurekaFilter, E entity.HeurekaEntity]( } } + +// DB stores rating as enum +// entity.Severity.Score is based on CVSS vector and has a range between x and y +// This means a rating "Low" can have a Score 3.1, 3.3, ... +// Ordering is done based on enum on DB layer, so Score can't be used for checking order +// and needs a numerical translation +func SeverityToNumerical(s string) int { + rating := map[string]int{ + "None": 0, + "Low": 1, + "Medium": 2, + "High": 3, + "Critical": 4, + } + if val, ok := rating[s]; ok { + return val + } else { + return -1 + } +} diff --git a/internal/database/mariadb/test/fixture.go b/internal/database/mariadb/test/fixture.go index a62921fe..e3e71056 100644 --- a/internal/database/mariadb/test/fixture.go +++ b/internal/database/mariadb/test/fixture.go @@ -9,7 +9,8 @@ import ( "math/rand" "strings" - "github.com/cloudoperators/heureka/internal/e2e/common" + e2e_common "github.com/cloudoperators/heureka/internal/e2e/common" + "github.com/cloudoperators/heureka/internal/entity" "github.com/goark/go-cvss/v3/metric" "github.com/onsi/ginkgo/v2/dsl/core" @@ -46,6 +47,24 @@ type SeedCollection struct { IssueRepositoryServiceRows []mariadb.IssueRepositoryServiceRow } +func (s *SeedCollection) GetComponentInstanceById(id int64) *mariadb.ComponentInstanceRow { + for _, ci := range s.ComponentInstanceRows { + if ci.Id.Int64 == id { + return &ci + } + } + return nil +} + +func (s *SeedCollection) GetIssueById(id int64) *mariadb.IssueRow { + for _, issue := range s.IssueRows { + if issue.Id.Int64 == id { + return &issue + } + } + return nil +} + func (s *SeedCollection) GetIssueVariantsByIssueId(id int64) []mariadb.IssueVariantRow { var r []mariadb.IssueVariantRow for _, iv := range s.IssueVariantRows { diff --git a/internal/e2e/issue_match_query_test.go b/internal/e2e/issue_match_query_test.go index a83c8e86..4cfba555 100644 --- a/internal/e2e/issue_match_query_test.go +++ b/internal/e2e/issue_match_query_test.go @@ -201,6 +201,102 @@ var _ = Describe("Getting IssueMatches via API", Label("e2e", "IssueMatches"), f Expect(*respData.IssueMatches.PageInfo.PageNumber).To(Equal(1), "Correct page number") }) }) + Context("we use ordering", Label("withOrder.graphql"), func() { + var respData struct { + IssueMatches model.IssueMatchConnection `json:"IssueMatches"` + } + + It("can order by primaryName", Label("withOrder.graphql"), func() { + // create a queryCollection (safe to share across requests) + client := graphql.NewClient(fmt.Sprintf("http://localhost:%s/query", cfg.Port)) + + //@todo may need to make this more fault proof?! What if the test is executed from the root dir? does it still work? + b, err := os.ReadFile("../api/graphql/graph/queryCollection/issueMatch/withOrder.graphql") + + Expect(err).To(BeNil()) + str := string(b) + req := graphql.NewRequest(str) + + req.Var("filter", map[string]string{}) + req.Var("first", 10) + req.Var("after", "0") + req.Var("orderBy", []map[string]string{ + {"by": "primaryName", "direction": "asc"}, + }) + + req.Header.Set("Cache-Control", "no-cache") + + ctx := context.Background() + + err = client.Run(ctx, req, &respData) + + Expect(err).To(BeNil(), "Error while unmarshaling") + + By("- returns the correct result count", func() { + Expect(respData.IssueMatches.TotalCount).To(Equal(len(seedCollection.IssueMatchRows))) + Expect(len(respData.IssueMatches.Edges)).To(Equal(10)) + }) + + By("- returns the expected content in order", func() { + var prev string = "" + for _, im := range respData.IssueMatches.Edges { + Expect(*im.Node.Issue.PrimaryName >= prev).Should(BeTrue()) + prev = *im.Node.Issue.PrimaryName + } + }) + }) + + It("can order by primaryName and targetRemediationDate", Label("withOrder.graphql"), func() { + // create a queryCollection (safe to share across requests) + client := graphql.NewClient(fmt.Sprintf("http://localhost:%s/query", cfg.Port)) + + //@todo may need to make this more fault proof?! What if the test is executed from the root dir? does it still work? + b, err := os.ReadFile("../api/graphql/graph/queryCollection/issueMatch/withOrder.graphql") + + Expect(err).To(BeNil()) + str := string(b) + req := graphql.NewRequest(str) + + req.Var("filter", map[string]string{}) + req.Var("first", 10) + req.Var("after", "0") + req.Var("orderBy", []map[string]string{ + {"by": "primaryName", "direction": "asc"}, + {"by": "targetRemediationDate", "direction": "desc"}, + }) + + req.Header.Set("Cache-Control", "no-cache") + + ctx := context.Background() + + err = client.Run(ctx, req, &respData) + + Expect(err).To(BeNil(), "Error while unmarshaling") + + By("- returns the correct result count", func() { + Expect(respData.IssueMatches.TotalCount).To(Equal(len(seedCollection.IssueMatchRows))) + Expect(len(respData.IssueMatches.Edges)).To(Equal(10)) + }) + + By("- returns the expected content in order", func() { + var prevPn string = "" + var prevTrd time.Time = time.Now() + for _, im := range respData.IssueMatches.Edges { + if *im.Node.Issue.PrimaryName == prevPn { + trd, err := time.Parse("2006-01-02T15:04:05Z", *im.Node.TargetRemediationDate) + Expect(err).To(BeNil()) + Expect(trd.Before(prevTrd)).Should(BeTrue()) + prevTrd = trd + } else { + Expect(*im.Node.Issue.PrimaryName > prevPn).To(BeTrue()) + prevTrd = time.Now() + } + prevPn = *im.Node.Issue.PrimaryName + } + }) + }) + + }) }) }) }) diff --git a/internal/entity/common.go b/internal/entity/common.go index dd1ecafc..92cd4b85 100644 --- a/internal/entity/common.go +++ b/internal/entity/common.go @@ -4,6 +4,7 @@ package entity import ( + "fmt" "math" "time" @@ -98,6 +99,7 @@ type ListOptions struct { ShowTotalCount bool `json:"show_total_count"` ShowPageInfo bool `json:"show_page_info"` IncludeAggregations bool `json:"include_aggregations"` + Order []Order } func NewListOptions() *ListOptions { @@ -105,6 +107,7 @@ func NewListOptions() *ListOptions { ShowTotalCount: false, ShowPageInfo: false, IncludeAggregations: false, + Order: []Order{}, } } @@ -227,3 +230,69 @@ type Metadata struct { UpdatedBy int64 `json:"updated_by"` DeletedAt time.Time `json:"deleted_at,omitempty"` } + +type DbColumnName int + +const ( + ComponentInstanceCcrn DbColumnName = iota + + IssuePrimaryName + + IssueMatchId + IssueMatchRating + IssueMatchTargetRemediationDate + + SupportGroupName +) + +func (d DbColumnName) String() string { + // order of string needs to match iota order + return [...]string{ + "componentinstance_ccrn", + "issue_primary_name", + "issuematch_id", + "issuematch_rating", + "issuematch_target_remediation_date", + "supportgroup_name", + }[d] +} + +type OrderDirection int + +const ( + OrderDirectionAsc OrderDirection = iota + OrderDirectionDesc +) + +func (o OrderDirection) String() string { + // order of string needs to match iota order + return [...]string{ + "ASC", + "DESC", + }[o] +} + +type Order struct { + By DbColumnName + Direction OrderDirection +} + +func CreateOrderMap(order []Order) map[DbColumnName]OrderDirection { + m := map[DbColumnName]OrderDirection{} + for _, o := range order { + m[o.By] = o.Direction + } + return m +} + +func CreateOrderString(order []Order) string { + orderStr := "" + for i, o := range order { + if i > 0 { + orderStr = fmt.Sprintf("%s, %s %s", orderStr, o.By, o.Direction) + } else { + orderStr = fmt.Sprintf("%s %s %s", orderStr, o.By, o.Direction) + } + } + return orderStr +} diff --git a/internal/entity/cursor.go b/internal/entity/cursor.go new file mode 100644 index 00000000..d1ed52f5 --- /dev/null +++ b/internal/entity/cursor.go @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package entity + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" +) + +type Field struct { + Name DbColumnName + Value any + Order OrderDirection +} + +type cursors struct { + fields []Field +} + +type NewCursor func(cursors *cursors) error + +func EncodeCursor(order []Order, opts ...NewCursor) (string, error) { + var cursors cursors + for _, opt := range opts { + err := opt(&cursors) + if err != nil { + fmt.Println("err") + return "", err + } + } + + m := CreateOrderMap(order) + for _, f := range cursors.fields { + if orderDirection, ok := m[f.Name]; ok { + f.Order = orderDirection + } + } + + var buf bytes.Buffer + encoder := base64.NewEncoder(base64.StdEncoding, &buf) + err := json.NewEncoder(encoder).Encode(cursors.fields) + if err != nil { + return "", err + } + encoder.Close() + return buf.String(), nil +} + +func DecodeCursor(cursor string) ([]Field, error) { + decoded, err := base64.StdEncoding.DecodeString(cursor) + if err != nil { + return nil, fmt.Errorf("failed to decode base64 string: %w", err) + } + + var fields []Field + if err := json.Unmarshal(decoded, &fields); err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON: %w", err) + } + + return fields, nil +} + +func WithIssueMatch(im IssueMatch) NewCursor { + return func(cursors *cursors) error { + cursors.fields = append(cursors.fields, Field{Name: IssueMatchId, Value: im.Id, Order: OrderDirectionAsc}) + // cursors.fields = append(cursors.fields, Field{Name: IssueMatchRating, Value: im.Rating, Order: OrderDirectionAsc}) + return nil + } +}