From adecbd91226dc37a76c39be5f79f55e711b8e3ea Mon Sep 17 00:00:00 2001 From: Colleen Murphy Date: Thu, 27 Oct 2022 14:11:17 -0700 Subject: [PATCH] Add sorting to partition store Extend the partition store to parse the "sort" query parameter as a sorting condition. Dot notation is used to denote the object field. Preceding the key with "-" denotes descending (reverse) order. Example sorting by name: GET /v1/secrets?sort=metadata.name Reverse sorting by name: GET /v1/secrets?sort=-metadata.name All values are converted to strings and sorted lexicographically. --- .../partition/listprocessor/processor.go | 48 +++ .../partition/listprocessor/processor_test.go | 319 ++++++++++++++++++ pkg/stores/partition/store.go | 1 + pkg/stores/partition/store_test.go | 79 +++++ 4 files changed, 447 insertions(+) diff --git a/pkg/stores/partition/listprocessor/processor.go b/pkg/stores/partition/listprocessor/processor.go index 5ecbe925..c311d1db 100644 --- a/pkg/stores/partition/listprocessor/processor.go +++ b/pkg/stores/partition/listprocessor/processor.go @@ -2,6 +2,7 @@ package listprocessor import ( + "sort" "strconv" "strings" @@ -16,6 +17,7 @@ const ( continueParam = "continue" limitParam = "limit" filterParam = "filter" + sortParam = "sort" ) // ListOptions represents the query parameters that may be included in a list request. @@ -23,6 +25,7 @@ type ListOptions struct { ChunkSize int Resume string Filters []Filter + Sort Sort } // Filter represents a field to filter by. @@ -33,6 +36,25 @@ type Filter struct { match string } +// SortOrder represents whether the list should be ascending or descending. +type SortOrder int + +const ( + // ASC stands for ascending order. + ASC SortOrder = iota + // DESC stands for descending (reverse) order. + DESC +) + +// Sort represents the criteria to sort on. +// The subfield to sort by is represented in a request query using . notation, e.g. 'metadata.name'. +// The subfield is internally represented as a slice, e.g. [metadata, name]. +// The order is represented by prefixing the sort key by '-', e.g. sort=-metadata.name. +type Sort struct { + field []string + order SortOrder +} + // ParseQuery parses the query params of a request and returns a ListOptions. func ParseQuery(apiOp *types.APIRequest) *ListOptions { chunkSize := getLimit(apiOp) @@ -47,10 +69,20 @@ func ParseQuery(apiOp *types.APIRequest) *ListOptions { } filterOpts = append(filterOpts, Filter{field: strings.Split(filter[0], "."), match: filter[1]}) } + sort := Sort{} + sortKey := q.Get(sortParam) + if sortKey != "" && sortKey[0] == '-' { + sort.order = DESC + sortKey = sortKey[1:] + } + if sortKey != "" { + sort.field = strings.Split(sortKey, ".") + } return &ListOptions{ ChunkSize: chunkSize, Resume: cont, Filters: filterOpts, + Sort: sort, } } @@ -148,3 +180,19 @@ func matchesAll(obj map[string]interface{}, filters []Filter) bool { } return true } + +// SortList sorts the slice by the provided sort criteria. +func SortList(list []unstructured.Unstructured, s Sort) []unstructured.Unstructured { + if len(s.field) == 0 { + return list + } + sort.Slice(list, func(i, j int) bool { + iField := convert.ToString(data.GetValueN(list[i].Object, s.field...)) + jField := convert.ToString(data.GetValueN(list[j].Object, s.field...)) + if s.order == ASC { + return iField < jField + } + return jField < iField + }) + return list +} diff --git a/pkg/stores/partition/listprocessor/processor_test.go b/pkg/stores/partition/listprocessor/processor_test.go index 5757b7a0..b418ff6c 100644 --- a/pkg/stores/partition/listprocessor/processor_test.go +++ b/pkg/stores/partition/listprocessor/processor_test.go @@ -796,3 +796,322 @@ func TestFilterList(t *testing.T) { }) } } + +func TestSortList(t *testing.T) { + tests := []struct { + name string + objects []unstructured.Unstructured + sort Sort + want []unstructured.Unstructured + }{ + { + name: "sort metadata.name", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + sort: Sort{ + field: []string{"metadata", "name"}, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + }, + }, + { + name: "reverse sort metadata.name", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + }, + sort: Sort{ + field: []string{"metadata", "name"}, + order: DESC, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + }, + }, + { + name: "invalid field", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + }, + sort: Sort{ + field: []string{"data", "productType"}, + }, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + }, + }, + { + name: "unsorted", + objects: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + }, + sort: Sort{}, + want: []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "granny-smith", + }, + "data": map[string]interface{}{ + "color": "green", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := SortList(test.objects, test.sort) + assert.Equal(t, test.want, got) + }) + } +} diff --git a/pkg/stores/partition/store.go b/pkg/stores/partition/store.go index fcdecb0c..0b3dcc05 100644 --- a/pkg/stores/partition/store.go +++ b/pkg/stores/partition/store.go @@ -128,6 +128,7 @@ func (s *Store) List(apiOp *types.APIRequest, schema *types.APISchema) (types.AP } list := listprocessor.FilterList(stream, opts.Filters) + list = listprocessor.SortList(list, opts.Sort) for _, item := range list { item := item diff --git a/pkg/stores/partition/store_test.go b/pkg/stores/partition/store_test.go index a6577a22..fd651878 100644 --- a/pkg/stores/partition/store_test.go +++ b/pkg/stores/partition/store_test.go @@ -278,6 +278,85 @@ func TestList(t *testing.T) { }, }, }, + { + name: "with sorting", + apiOps: []*types.APIRequest{ + newRequest("sort=metadata.name"), + newRequest("sort=-metadata.name"), + }, + partitions: []Partition{ + mockPartition{ + name: "all", + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "all": { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + newApple("bramley").Unstructured, + newApple("crispin").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Objects: []types.APIObject{ + newApple("bramley").toObj(), + newApple("crispin").toObj(), + newApple("fuji").toObj(), + newApple("granny-smith").toObj(), + }, + }, + { + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + newApple("fuji").toObj(), + newApple("crispin").toObj(), + newApple("bramley").toObj(), + }, + }, + }, + }, + { + name: "multi-partition sort=metadata.name", + apiOps: []*types.APIRequest{ + newRequest("sort=metadata.name"), + }, + partitions: []Partition{ + mockPartition{ + name: "green", + }, + mockPartition{ + name: "yellow", + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "pink": { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + }, + }, + "green": { + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + }, + }, + "yellow": { + Items: []unstructured.Unstructured{ + newApple("crispin").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Objects: []types.APIObject{ + newApple("crispin").toObj(), + newApple("granny-smith").toObj(), + }, + }, + }, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) {