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)
This commit is contained in:
Dominik Schmidt committed 2026-09-08 00:25:02 +02:00
1 parent 43f6efe403
commit da6809a2cb
2 files changed
+87

No files matched your search

@@ -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
@@ -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) {