1
0
mirror of https://github.com/rancher/steve.git synced 2025-04-27 11:00:48 +00:00

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.
This commit is contained in:
Colleen Murphy 2022-10-27 14:11:17 -07:00
parent f8eaa11d83
commit adecbd9122
4 changed files with 447 additions and 0 deletions

View File

@ -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
}

View File

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

View File

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

View File

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