diff --git a/pkg/stores/partition/listprocessor/processor.go b/pkg/stores/partition/listprocessor/processor.go index 614a3ee..2cff396 100644 --- a/pkg/stores/partition/listprocessor/processor.go +++ b/pkg/stores/partition/listprocessor/processor.go @@ -62,16 +62,26 @@ const ( // 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 + primaryField []string + secondaryField []string + primaryOrder SortOrder + secondaryOrder SortOrder } // String returns the sort parameters as a query string. func (s Sort) String() string { - field := strings.Join(s.field, ".") - if s.order == DESC { + field := "" + if s.primaryOrder == DESC { field = "-" + field } + field += strings.Join(s.primaryField, ".") + if len(s.secondaryField) > 0 { + field += "," + if s.secondaryOrder == DESC { + field += "-" + } + field += strings.Join(s.secondaryField, ".") + } return field } @@ -107,13 +117,27 @@ func ParseQuery(apiOp *types.APIRequest) *ListOptions { return fieldI < fieldJ }) sortOpts := Sort{} - sortKey := q.Get(sortParam) - if sortKey != "" && sortKey[0] == '-' { - sortOpts.order = DESC - sortKey = sortKey[1:] - } - if sortKey != "" { - sortOpts.field = strings.Split(sortKey, ".") + sortKeys := q.Get(sortParam) + if sortKeys != "" { + sortParts := strings.SplitN(sortKeys, ",", 2) + primaryField := sortParts[0] + if primaryField != "" && primaryField[0] == '-' { + sortOpts.primaryOrder = DESC + primaryField = primaryField[1:] + } + if primaryField != "" { + sortOpts.primaryField = strings.Split(primaryField, ".") + } + if len(sortParts) > 1 { + secondaryField := sortParts[1] + if secondaryField != "" && secondaryField[0] == '-' { + sortOpts.secondaryOrder = DESC + secondaryField = secondaryField[1:] + } + if secondaryField != "" { + sortOpts.secondaryField = strings.Split(secondaryField, ".") + } + } } var err error pagination := Pagination{} @@ -233,16 +257,24 @@ func matchesAll(obj map[string]interface{}, filters []Filter) bool { // SortList sorts the slice by the provided sort criteria. func SortList(list []unstructured.Unstructured, s Sort) []unstructured.Unstructured { - if len(s.field) == 0 { + if len(s.primaryField) == 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 + leftPrime := convert.ToString(data.GetValueN(list[i].Object, s.primaryField...)) + rightPrime := convert.ToString(data.GetValueN(list[j].Object, s.primaryField...)) + if leftPrime == rightPrime && len(s.secondaryField) > 0 { + leftSecond := convert.ToString(data.GetValueN(list[i].Object, s.secondaryField...)) + rightSecond := convert.ToString(data.GetValueN(list[j].Object, s.secondaryField...)) + if s.secondaryOrder == ASC { + return leftSecond < rightSecond + } + return rightSecond < leftSecond } - return jField < iField + if s.primaryOrder == ASC { + return leftPrime < rightPrime + } + return rightPrime < leftPrime }) return list } diff --git a/pkg/stores/partition/listprocessor/processor_test.go b/pkg/stores/partition/listprocessor/processor_test.go index 4e867fc..dfc5f9d 100644 --- a/pkg/stores/partition/listprocessor/processor_test.go +++ b/pkg/stores/partition/listprocessor/processor_test.go @@ -842,7 +842,7 @@ func TestSortList(t *testing.T) { }, }, sort: Sort{ - field: []string{"metadata", "name"}, + primaryField: []string{"metadata", "name"}, }, want: []unstructured.Unstructured{ { @@ -918,8 +918,8 @@ func TestSortList(t *testing.T) { }, }, sort: Sort{ - field: []string{"metadata", "name"}, - order: DESC, + primaryField: []string{"metadata", "name"}, + primaryOrder: DESC, }, want: []unstructured.Unstructured{ { @@ -995,7 +995,7 @@ func TestSortList(t *testing.T) { }, }, sort: Sort{ - field: []string{"data", "productType"}, + primaryField: []string{"data", "productType"}, }, want: []unstructured.Unstructured{ { @@ -1107,6 +1107,318 @@ func TestSortList(t *testing.T) { }, }, }, + { + name: "primary sort ascending, secondary sort ascending", + 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{ + primaryField: []string{"data", "color"}, + secondaryField: []string{"metadata", "name"}, + }, + 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: "primary sort ascending, secondary sort descending", + 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{ + primaryField: []string{"data", "color"}, + secondaryField: []string{"metadata", "name"}, + secondaryOrder: DESC, + }, + 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": "honeycrisp", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + { + Object: map[string]interface{}{ + "kind": "apple", + "metadata": map[string]interface{}{ + "name": "fuji", + }, + "data": map[string]interface{}{ + "color": "pink", + }, + }, + }, + }, + }, + { + name: "primary sort descending, secondary sort ascending", + 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{ + primaryField: []string{"data", "color"}, + primaryOrder: DESC, + secondaryField: []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": "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", + }, + }, + }, + }, + }, + { + name: "primary sort descending, secondary sort descending", + 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{ + primaryField: []string{"data", "color"}, + primaryOrder: DESC, + secondaryField: []string{"metadata", "name"}, + secondaryOrder: 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": "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", + }, + }, + }, + }, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/pkg/stores/partition/store_test.go b/pkg/stores/partition/store_test.go index 88f9056..f1e295f 100644 --- a/pkg/stores/partition/store_test.go +++ b/pkg/stores/partition/store_test.go @@ -413,6 +413,117 @@ func TestList(t *testing.T) { }, }, }, + { + name: "sorting with secondary sort", + apiOps: []*types.APIRequest{ + newRequest("sort=data.color,metadata.name,", "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "all": { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("honeycrisp").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 3, + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + newApple("fuji").toObj(), + newApple("honeycrisp").toObj(), + }, + }, + }, + }, + { + name: "sorting with missing primary sort is unsorted", + apiOps: []*types.APIRequest{ + newRequest("sort=,metadata.name", "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "all": { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("honeycrisp").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 3, + Objects: []types.APIObject{ + newApple("fuji").toObj(), + newApple("honeycrisp").toObj(), + newApple("granny-smith").toObj(), + }, + }, + }, + }, + { + name: "sorting with missing secondary sort is single-column sorted", + apiOps: []*types.APIRequest{ + newRequest("sort=metadata.name,", "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "all": { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("honeycrisp").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 3, + Objects: []types.APIObject{ + newApple("fuji").toObj(), + newApple("granny-smith").toObj(), + newApple("honeycrisp").toObj(), + }, + }, + }, + }, { name: "multi-partition sort=metadata.name", apiOps: []*types.APIRequest{