Skip to content

Commit

Permalink
Semantic equals implementation (#1546)
Browse files Browse the repository at this point in the history
* normalize atlasproject spec

* reflect normalize

* add error handling and skip byte slices

* restructure, add byte slice skipping

* add sortable

* add sortable and unit tests

Co-authored-by: Sergiusz Urbaniak <sergiusz.urbaniak@gmail.com>
  • Loading branch information
igor-karpukhin and s-urbaniak committed Apr 25, 2024
1 parent d4f4033 commit 61bf70f
Show file tree
Hide file tree
Showing 10 changed files with 610 additions and 141 deletions.
2 changes: 1 addition & 1 deletion .licenses-gomod.sha256
Original file line number Diff line number Diff line change
@@ -1 +1 @@
100644 d537760138665c24225e4f5725c5bd80775033a6 go.mod
100644 60d2b0501dda29d0d258c6a0f401c95854606d5c go.mod
6 changes: 4 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ require (
github.com/google/go-cmp v0.6.0
github.com/google/uuid v1.6.0
github.com/mongodb-forks/digest v1.1.0
github.com/mongodb/mongodb-atlas-kubernetes v1.9.3
github.com/onsi/ginkgo/v2 v2.17.1
github.com/onsi/gomega v1.33.0
github.com/sethvargo/go-password v0.3.0
github.com/stretchr/testify v1.9.0
go.mongodb.org/atlas v0.36.0
go.mongodb.org/atlas-sdk/v20231115004 v20231115004.1.0
go.mongodb.org/atlas-sdk/v20231115008 v20231115008.5.0
go.mongodb.org/mongo-driver v1.15.0
go.uber.org/zap v1.27.0
Expand Down Expand Up @@ -132,12 +134,12 @@ require (
google.golang.org/grpc v1.63.2 // indirect
google.golang.org/protobuf v1.33.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
k8s.io/apiextensions-apiserver v0.29.2 // indirect
k8s.io/apiextensions-apiserver v0.29.2
k8s.io/component-base v0.29.2 // indirect
k8s.io/klog/v2 v2.110.1 // indirect
k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect
k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
sigs.k8s.io/yaml v1.4.0
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mongodb-forks/digest v1.1.0 h1:7eUdsR1BtqLv0mdNm4OXs6ddWvR4X2/OsLwdKksrOoc=
github.com/mongodb-forks/digest v1.1.0/go.mod h1:rb+EX8zotClD5Dj4NdgxnJXG9nwrlx3NWKJ8xttz1Dg=
github.com/mongodb/mongodb-atlas-kubernetes v1.9.3 h1:jBOSVf+xYUcDSuycibLQRAPwoY8YnOJ6mgegrOpYVU0=
github.com/mongodb/mongodb-atlas-kubernetes v1.9.3/go.mod h1:S7JpgyRq6Asp/PBNFWppwUTwao7EIFgFau3ltpt3rpA=
github.com/montanaflynn/stats v0.7.0 h1:r3y12KyNxj/Sb/iOE46ws+3mS1+MZca1wlHQFPsY/JU=
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
Expand Down Expand Up @@ -258,6 +260,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/atlas v0.36.0 h1:m05S3AO7zkl+bcG1qaNsEKBnAqnKx2FDwLooHpIG3j4=
go.mongodb.org/atlas v0.36.0/go.mod h1:nfPldE9dSama6G2IbIzmEza02Ly7yFZjMMVscaM0uEc=
go.mongodb.org/atlas-sdk/v20231115004 v20231115004.1.0 h1:vOvfk8bPedcphaTHIm6p8UB/ZPeVOqJZ7+MmTuI1eGs=
go.mongodb.org/atlas-sdk/v20231115004 v20231115004.1.0/go.mod h1:FzD5DLz+WPB+z3OGgNBjXMPQJjJ7Y+hKn4iupXZuoOc=
go.mongodb.org/atlas-sdk/v20231115008 v20231115008.5.0 h1:OuV1HfIpZUZa4+BKvtrvDlNqnilkCkdHspuZok6KAbM=
go.mongodb.org/atlas-sdk/v20231115008 v20231115008.5.0/go.mod h1:0707RpWIrNFZ6Msy/dwRDCzC5JVDon61JoOqcbfCujg=
go.mongodb.org/mongo-driver v1.15.0 h1:rJCKC8eEliewXjZGf0ddURtl7tTVy1TK3bfl0gkUSLc=
Expand Down
104 changes: 104 additions & 0 deletions internal/cmp/normalize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package cmp

import (
"cmp"
"fmt"
"math/rand"
"reflect"
"slices"
"sort"
)

type Normalizer[T any] interface {
Normalize() T
}

func SemanticEqual[T Normalizer[T]](this, that T) bool {
return reflect.DeepEqual(this.Normalize(), that.Normalize())
}

func NormalizeSlice[S ~[]E, E any](slice S, cmp func(a, b E) int) S {
if len(slice) == 0 {
return nil
}
slices.SortFunc(slice, cmp)
return slice
}

func Normalize(data any) error {
var err error
traverse(data, func(slice reflect.Value) {
sort.Slice(slice.Interface(), func(i, j int) bool {
iIface, jIface := slice.Index(i).Interface(), slice.Index(j).Interface()

if ok, result := compareSortable(iIface, jIface); ok {
return result < 0
}

result, e := ByJSON(iIface, jIface)
if e != nil {
err = fmt.Errorf("error converting slice %v to JSON: %w", slice, e)
return false
}
return result < 0
})
})
return err
}

func compareSortable(i, j any) (bool, int) {
iSortable, iSortableOK := i.(Sortable)
jSortable, jSortableOK := j.(Sortable)

if iSortableOK && jSortableOK {
return true, cmp.Compare(iSortable.Key(), jSortable.Key())
}

return false, -1
}

func PermuteOrder(data any, r *rand.Rand) {
traverse(data, func(slice reflect.Value) {
r.Shuffle(slice.Len(), func(i, j int) {
reflect.Swapper(slice.Interface())(i, j)
})
})
}

func traverse(data any, f func(slice reflect.Value)) {
traverseValue(reflect.ValueOf(data), f)
}

func traverseValue(value reflect.Value, f func(slice reflect.Value)) {
switch value.Kind() {
case reflect.Pointer:
// if it is a pointer, traverse over its dereferenced value
traverseValue(value.Elem(), f)

case reflect.Struct:
for i := 0; i < value.NumField(); i++ {
// skip unexported fields
if value.Type().Field(i).PkgPath != "" {
continue
}
// traverse over each field in the struct
traverseValue(value.Field(i), f)
}

case reflect.Slice:
// omit zero length slices
if value.Len() == 0 {
return
}
// skip []byte slices
if value.Type().Elem().Kind() == reflect.Uint8 {
return
}
// traverse over each element in the slice
for j := 0; j < value.Len(); j++ {
traverseValue(value.Index(j), f)
}
// base case: we can apply the given function
f(value)
}
}
121 changes: 121 additions & 0 deletions internal/cmp/normalize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package cmp

import (
"reflect"
"testing"

v1apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
)

func TestEndlessRecursion(t *testing.T) {
for _, tc := range []struct {
name string
data any
want any
}{
{
name: "pointer",
data: &struct {
Slice []string
}{
Slice: []string{"C", "B", "A", "sort"},
},
want: &struct {
Slice []string
}{
Slice: []string{"A", "B", "C", "sort"},
},
},
{
name: "nested JSON",
data: struct {
NestedJSON v1apiextensions.JSON
}{
NestedJSON: v1apiextensions.JSON{Raw: []byte("CBA")},
},
want: struct {
NestedJSON v1apiextensions.JSON
}{
NestedJSON: v1apiextensions.JSON{Raw: []byte("CBA")},
},
},
{
name: "ignore byte slices",
data: struct {
ByteSlice []byte
}{
ByteSlice: []byte("CBA"),
},
want: struct {
ByteSlice []byte
}{
ByteSlice: []byte("CBA"),
},
},
{
name: "ignore zero length slices",
data: struct {
EmptySlice []string
}{},
want: struct {
EmptySlice []string
}{},
},
{
name: "ignore unexported fields",
data: struct {
ExportedFields []string
unExportedField []string
}{
ExportedFields: []string{"C", "B", "A", "sort"},
unExportedField: []string{"C", "B", "A", `don't sort'`},
},
want: struct {
ExportedFields []string
unExportedField []string
}{
ExportedFields: []string{"A", "B", "C", "sort"},
unExportedField: []string{"C", "B", "A", `don't sort'`},
},
},
{
name: "nested slices",
data: struct {
Nested []struct {
Slice []string
}
}{
Nested: []struct {
Slice []string
}{
{Slice: []string{"Z", "Y", "X"}},
{Slice: []string{"C", "B", "A"}},
},
},
want: struct {
Nested []struct {
Slice []string
}
}{
Nested: []struct {
Slice []string
}{
{Slice: []string{"A", "B", "C"}},
{Slice: []string{"X", "Y", "Z"}},
},
},
},
} {
t.Run(tc.name, func(t *testing.T) {
err := Normalize(tc.data)
if err != nil {
t.Error(err)
return
}
if !reflect.DeepEqual(tc.data, tc.want) {
t.Errorf("want normalized value %+v, got %v", tc.want, tc.data)
return
}
})
}
}
51 changes: 51 additions & 0 deletions internal/cmp/sort.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package cmp

import (
"cmp"
"encoding/json"
"fmt"
"strings"
)

type Sortable interface {
Key() string
}

func PointerKey[T Sortable](in *T) string {
if in == nil {
return "nil"
}
return (*in).Key()
}

func SliceKey[K Sortable](in []K) string {
result := make([]string, 0, len(in))
for i := range in {
result = append(result, in[i].Key())
}
return "[" + strings.Join(result, ",") + "]"
}

func ByKey[S Sortable](x, y S) int {
return cmp.Compare(x.Key(), y.Key())
}

func ByJSON[T any](x, y T) (int, error) {
xJSON, err := marshalJSON(x)
if err != nil {
return -1, fmt.Errorf("error converting %v to JSON: %w", x, err)
}
yJSON, err := marshalJSON(y)
if err != nil {
return -1, fmt.Errorf("error converting %v to JSON: %w", x, err)
}
return cmp.Compare(xJSON, yJSON), nil
}

func marshalJSON[T any](obj T) (string, error) {
jObj, err := json.Marshal(obj)
if err != nil {
return "", err
}
return string(jObj), nil
}
Loading

0 comments on commit 61bf70f

Please sign in to comment.