Add secondary sort parameter

Extend the sorting functionality in the partition store to support
primary and secondary sorting criteria. Sorting parameters are specified
by a single 'sort' query and comma separated. Example:

Sort by name and creation time:

GET /v1/secrets?sort=metadata.name,metadata.creationTimestamp

Reverse sort by name, normal sort by creation time:

GET /v1/secrets?sort=-metadata.name,metadata.creationTimestamp

Normal sort by name, reverse sort by creation time:

GET /v1/secrets?sort=metadata.name,-metadata.creationTimestamp
This commit is contained in:
Colleen Murphy 2022-11-14 14:07:23 -08:00
parent 52e189b1ff
commit b151f25581
3 changed files with 476 additions and 21 deletions

View File

@ -62,16 +62,26 @@ const (
// The subfield is internally represented as a slice, 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. // The order is represented by prefixing the sort key by '-', e.g. sort=-metadata.name.
type Sort struct { type Sort struct {
field []string primaryField []string
order SortOrder secondaryField []string
primaryOrder SortOrder
secondaryOrder SortOrder
} }
// String returns the sort parameters as a query string. // String returns the sort parameters as a query string.
func (s Sort) String() string { func (s Sort) String() string {
field := strings.Join(s.field, ".") field := ""
if s.order == DESC { if s.primaryOrder == DESC {
field = "-" + field 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 return field
} }
@ -107,13 +117,27 @@ func ParseQuery(apiOp *types.APIRequest) *ListOptions {
return fieldI < fieldJ return fieldI < fieldJ
}) })
sortOpts := Sort{} sortOpts := Sort{}
sortKey := q.Get(sortParam) sortKeys := q.Get(sortParam)
if sortKey != "" && sortKey[0] == '-' { if sortKeys != "" {
sortOpts.order = DESC sortParts := strings.SplitN(sortKeys, ",", 2)
sortKey = sortKey[1:] primaryField := sortParts[0]
} if primaryField != "" && primaryField[0] == '-' {
if sortKey != "" { sortOpts.primaryOrder = DESC
sortOpts.field = strings.Split(sortKey, ".") 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 var err error
pagination := Pagination{} pagination := Pagination{}
@ -233,16 +257,24 @@ func matchesAll(obj map[string]interface{}, filters []Filter) bool {
// SortList sorts the slice by the provided sort criteria. // SortList sorts the slice by the provided sort criteria.
func SortList(list []unstructured.Unstructured, s Sort) []unstructured.Unstructured { func SortList(list []unstructured.Unstructured, s Sort) []unstructured.Unstructured {
if len(s.field) == 0 { if len(s.primaryField) == 0 {
return list return list
} }
sort.Slice(list, func(i, j int) bool { sort.Slice(list, func(i, j int) bool {
iField := convert.ToString(data.GetValueN(list[i].Object, s.field...)) leftPrime := convert.ToString(data.GetValueN(list[i].Object, s.primaryField...))
jField := convert.ToString(data.GetValueN(list[j].Object, s.field...)) rightPrime := convert.ToString(data.GetValueN(list[j].Object, s.primaryField...))
if s.order == ASC { if leftPrime == rightPrime && len(s.secondaryField) > 0 {
return iField < jField 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 return list
} }

View File

@ -842,7 +842,7 @@ func TestSortList(t *testing.T) {
}, },
}, },
sort: Sort{ sort: Sort{
field: []string{"metadata", "name"}, primaryField: []string{"metadata", "name"},
}, },
want: []unstructured.Unstructured{ want: []unstructured.Unstructured{
{ {
@ -918,8 +918,8 @@ func TestSortList(t *testing.T) {
}, },
}, },
sort: Sort{ sort: Sort{
field: []string{"metadata", "name"}, primaryField: []string{"metadata", "name"},
order: DESC, primaryOrder: DESC,
}, },
want: []unstructured.Unstructured{ want: []unstructured.Unstructured{
{ {
@ -995,7 +995,7 @@ func TestSortList(t *testing.T) {
}, },
}, },
sort: Sort{ sort: Sort{
field: []string{"data", "productType"}, primaryField: []string{"data", "productType"},
}, },
want: []unstructured.Unstructured{ 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 { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {

View File

@ -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", name: "multi-partition sort=metadata.name",
apiOps: []*types.APIRequest{ apiOps: []*types.APIRequest{