From da6809a2cbd1ea56f1d447cfe73e570aeeb2968a Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 12 Aug 2026 16:21:14 +0200 Subject: [PATCH] feat(graph): accept sortProperties on search requests Validated against the sortable-field whitelist (name, size, lastModifiedDateTime, photo.takenDateTime) and forwarded to the search service as order_by. Also fixes the stub search service's IndexSpace signature (streaming response) so the suite builds again. (cherry picked from commit 9aa6a3492de3468da7a8480a3b9ef5748cee1f5d) (cherry picked from commit 35a6cad8ea6a7890bdc0bc98c7ac97bdcbf477cc) --- services/graph/pkg/service/v0/searchquery.go | 42 +++++++++++++++++ .../graph/pkg/service/v0/searchquery_test.go | 45 +++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/services/graph/pkg/service/v0/searchquery.go b/services/graph/pkg/service/v0/searchquery.go index 16e6c825b1..b524dc02b9 100644 --- a/services/graph/pkg/service/v0/searchquery.go +++ b/services/graph/pkg/service/v0/searchquery.go @@ -43,6 +43,10 @@ func (g Graph) SearchQuery(w http.ResponseWriter, r *http.Request) { errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error()) return } + if err := validateSortProperties(sr.SortProperties); err != nil { + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error()) + return + } } th := r.Header.Get(revaCtx.TokenHeader) @@ -80,6 +84,7 @@ func (g Graph) runSingleSearch(ctx context.Context, sr libregraph.SearchRequest, PageSize: pageSize, Aggregations: libregraphAggregationsToSearch(sr.Aggregations), AggregationFilters: sr.AggregationFilters, + OrderBy: libregraphSortToSearch(sr.SortProperties), }) if err != nil { return libregraph.SearchResponse{}, err @@ -170,6 +175,43 @@ func validateAggregations(aggs []libregraph.AggregationOption) error { return nil } +// sortableFields is the set of fields accepted in sortProperties (mirrored in +// the openapi spec). A field qualifies only if it is indexed as a sortable +// type in both search backends AND carried on the Match entity: the service +// layer re-sorts matches when merging the per-space result streams and needs +// the sort key on the match itself. +var sortableFields = map[string]struct{}{ + "name": {}, + "size": {}, + "lastModifiedDateTime": {}, + "photo.takenDateTime": {}, +} + +// validateSortProperties rejects sorting by fields outside sortableFields. +func validateSortProperties(sortProperties []libregraph.SortProperty) error { + for _, sp := range sortProperties { + if _, ok := sortableFields[sp.Name]; !ok { + return fmt.Errorf("field %q is not sortable; sortable fields: name, size, lastModifiedDateTime, photo.takenDateTime", sp.Name) + } + } + return nil +} + +func libregraphSortToSearch(in []libregraph.SortProperty) []*searchsvc.SortProperty { + if len(in) == 0 { + return nil + } + out := make([]*searchsvc.SortProperty, 0, len(in)) + for _, sp := range in { + p := &searchsvc.SortProperty{Name: sp.Name} + if sp.IsDescending != nil { + p.IsDescending = *sp.IsDescending + } + out = append(out, p) + } + return out +} + func libregraphAggregationsToSearch(in []libregraph.AggregationOption) []*searchsvc.AggregationOption { if len(in) == 0 { return nil diff --git a/services/graph/pkg/service/v0/searchquery_test.go b/services/graph/pkg/service/v0/searchquery_test.go index 2817f7188d..0db86ef2c0 100644 --- a/services/graph/pkg/service/v0/searchquery_test.go +++ b/services/graph/pkg/service/v0/searchquery_test.go @@ -198,6 +198,51 @@ var _ = ginkgo.Describe("SearchQuery", func() { ginkgo.Entry("from+size overflow collapses", int32Ptr(1<<31-1), int32Ptr(500), int32(1<<31-1-500), int32(500)), ) + ginkgo.It("forwards sortProperties to the search service as order_by", func() { + var captured *searchsvc.SearchRequest + g := graphWithSearch(stubSearchService{ + search: func(req *searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) { + captured = req + return &searchsvc.SearchResponse{}, nil + }, + }) + rr := postSearchQuery(g, `{ + "requests": [{ + "entityTypes": ["driveItem"], + "query": {"queryString": "mediatype:image"}, + "sortProperties": [ + {"name": "photo.takenDateTime", "isDescending": true}, + {"name": "name"} + ] + }] + }`) + Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String()) + Expect(captured).ToNot(BeNil()) + Expect(captured.OrderBy).To(HaveLen(2)) + Expect(captured.OrderBy[0].Name).To(Equal("photo.takenDateTime")) + Expect(captured.OrderBy[0].IsDescending).To(BeTrue()) + Expect(captured.OrderBy[1].Name).To(Equal("name")) + Expect(captured.OrderBy[1].IsDescending).To(BeFalse()) + }) + + ginkgo.It("rejects sorting by an unsupported field with 400", func() { + g := graphWithSearch(stubSearchService{ + search: func(*searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) { + ginkgo.Fail("search service must not be called when validation fails") + return nil, nil + }, + }) + rr := postSearchQuery(g, `{ + "requests": [{ + "entityTypes": ["driveItem"], + "query": {"queryString": "mediatype:image"}, + "sortProperties": [{"name": "photo.iso"}] + }] + }`) + Expect(rr.Code).To(Equal(http.StatusBadRequest), rr.Body.String()) + Expect(rr.Body.String()).To(ContainSubstring("photo.iso")) + }) + ginkgo.It("rejects a terms aggregation on a numeric field with 400", func() { g := graphWithSearch(stubSearchService{ search: func(*searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) {