Skip to content

Commit

Permalink
feat: Add EqualProperties rule (#54)
Browse files Browse the repository at this point in the history
## Release Notes

Added `rules.EqualProperties` rule which helps ensure selected
properties are equal.
The equality check is performed via a configurable function.
Two builtin functions are provided out of the box: `rules.CompareFunc`
which operates on `comparable` types and `rules.CompareDeepEqualFunc`
which uses `reflect.DeepEqual` and operates on any type.
  • Loading branch information
nieomylnieja authored Nov 20, 2024
1 parent aba1b13 commit d65c01e
Show file tree
Hide file tree
Showing 9 changed files with 287 additions and 19 deletions.
10 changes: 5 additions & 5 deletions internal/assert/assert.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func Require(t testing.TB, isPassing bool) {
func Equal(t testing.TB, expected, actual interface{}) bool {
t.Helper()
if !areEqual(expected, actual) {
return Fail(t, "Expected: %v, actual: %v", expected, actual)
return Fail(t, "Expected: %v\nActual: %v", expected, actual)
}
return true
}
Expand All @@ -56,7 +56,7 @@ func False(t testing.TB, actual bool) bool {
func Len(t testing.TB, object interface{}, length int) bool {
t.Helper()
if actual := getLen(object); actual != length {
return Fail(t, "Expected length: %d, actual: %d", length, actual)
return Fail(t, "Expected length: %d\nActual: %d", length, actual)
}
return true
}
Expand All @@ -69,7 +69,7 @@ func IsType[T any](t testing.TB, object interface{}) bool {
case T:
return true
default:
return Fail(t, "Expected type: %T, actual: %T", *new(T), object)
return Fail(t, "Expected type: %T\nActual: %T", *new(T), object)
}
}

Expand Down Expand Up @@ -98,7 +98,7 @@ func EqualError(t testing.TB, err error, expected string) bool {
return false
}
if err.Error() != expected {
return Fail(t, "Expected error message: %q, actual: %q", expected, err.Error())
return Fail(t, "Expected error message: %q\nActual: %q", expected, err.Error())
}
return true
}
Expand All @@ -110,7 +110,7 @@ func ErrorContains(t testing.TB, err error, contains string) bool {
return false
}
if !strings.Contains(err.Error(), contains) {
return Fail(t, "Expected error message to contain %q, actual %q", contains, err.Error())
return Fail(t, "Expected error message to contain: %q\nActual: %q", contains, err.Error())
}
return true
}
Expand Down
16 changes: 16 additions & 0 deletions internal/collections/maps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package collections

import (
"slices"

"golang.org/x/exp/constraints"
"golang.org/x/exp/maps"
)

// SortedKeys returns a sorted slice of keys of the input map.
// The keys must meet [constraints.Ordered] type constraint.
func SortedKeys[M ~map[K]V, K constraints.Ordered, V any](m M) []K {
keys := maps.Keys(m)
slices.Sort(keys)
return keys
}
28 changes: 28 additions & 0 deletions internal/collections/maps_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package collections

import (
"testing"

"github.com/nobl9/govy/internal/assert"
)

func TestSortedKeys(t *testing.T) {
t.Run("ints", func(t *testing.T) {
m := map[int]string{
3: "c",
1: "a",
2: "b",
}
keys := SortedKeys(m)
assert.Equal(t, []int{1, 2, 3}, keys)
})
t.Run("strings", func(t *testing.T) {
m := map[string]int{
"c": 3,
"a": 1,
"b": 2,
}
keys := SortedKeys(m)
assert.Equal(t, []string{"a", "b", "c"}, keys)
})
}
62 changes: 62 additions & 0 deletions pkg/rules/comparable.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ package rules
import (
"errors"
"fmt"
"reflect"
"strings"

"golang.org/x/exp/constraints"

"github.com/nobl9/govy/internal/collections"
"github.com/nobl9/govy/pkg/govy"
)

Expand Down Expand Up @@ -59,6 +62,65 @@ func LTE[T constraints.Ordered](compared T) govy.Rule[T] {
WithErrorCode(ErrorCodeLessThanOrEqualTo)
}

// ComparisonFunc defines a shape for a function that compares two values.
// It should return true if the values are equal, false otherwise.
type ComparisonFunc[T any] func(v1, v2 T) bool

// CompareFunc compares two values of the same type.
// The type is constrained by the [comparable] interface.
func CompareFunc[T comparable](v1, v2 T) bool {
return v1 == v2
}

// CompareDeepEqualFunc compares two values of the same type using [reflect.DeepEqual].
// It is particularly useful when comparing pointers' values.
func CompareDeepEqualFunc[T any](v1, v2 T) bool {
return reflect.DeepEqual(v1, v2)
}

// EqualProperties checks if all of the specified properties are equal.
// It uses the provided [ComparisonFunc] to compare the values.
// The following built-in comparison functions are available:
// - [CompareFunc]
// - [CompareDeepEqualFunc]
//
// If builtin [ComparisonFunc] are not enough, a custom function can be used.
func EqualProperties[S, T any](compare ComparisonFunc[T], getters map[string]func(s S) T) govy.Rule[S] {
sortedKeys := collections.SortedKeys(getters)
return govy.NewRule(func(s S) error {
if len(getters) < 2 {
return nil
}
var (
i = 0
lastValue T
lastProp string
)
for _, prop := range sortedKeys {
v := getters[prop](s)
if i != 0 && !compare(v, lastValue) {
return fmt.Errorf(
"all of %s properties must be equal, but '%s' is not equal to '%s'",
prettyOneOfList(collections.SortedKeys(getters)),
lastProp,
prop,
)
}
lastProp = prop
lastValue = v
i++
}
return nil
}).
WithErrorCode(ErrorCodeEqualProperties).
WithDescription(func() string {
return fmt.Sprintf(
"all of the properties must be equal: %s",
strings.Join(collections.SortedKeys(getters), ", "),
)
}())
}

var comparisonFmt = "should be %s '%v'"

func orderedComparisonRule[T constraints.Ordered](op comparisonOperator, compared T) govy.Rule[T] {
Expand Down
74 changes: 74 additions & 0 deletions pkg/rules/comparable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,77 @@ func BenchmarkLTE(b *testing.B) {
}
}
}

var equalPropertiesTestCases = []*struct {
run func() error
expectedError string
}{
{
run: func() error {
return EqualProperties(CompareDeepEqualFunc, paymentMethodGetters).Validate(paymentMethod{
Cash: ptr("2$"),
Card: ptr("2$"),
Transfer: ptr("2$"),
})
},
},
{
run: func() error {
return EqualProperties(CompareFunc, paymentMethodGetters).Validate(paymentMethod{
Cash: nil,
Card: ptr("2$"),
Transfer: ptr("2$"),
})
},
expectedError: "all of [Card, Cash, Transfer] properties must be equal, but 'Card' is not equal to 'Cash'",
},
{
run: func() error {
return EqualProperties(CompareFunc, paymentMethodGetters).Validate(paymentMethod{
Cash: nil,
Card: nil,
Transfer: nil,
})
},
},
{
run: func() error {
return EqualProperties(CompareDeepEqualFunc, paymentMethodGetters).Validate(paymentMethod{
Cash: ptr("2$"),
Card: ptr("2$"),
Transfer: ptr("3$"),
})
},
expectedError: "all of [Card, Cash, Transfer] properties must be equal, but 'Cash' is not equal to 'Transfer'",
},
{
run: func() error {
return EqualProperties(CompareDeepEqualFunc, paymentMethodGetters).Validate(paymentMethod{
Cash: ptr("1$"),
Card: ptr("2$"),
Transfer: ptr("3$"),
})
},
expectedError: "all of [Card, Cash, Transfer] properties must be equal, but 'Card' is not equal to 'Cash'",
},
}

func TestEqualProperties(t *testing.T) {
for _, tc := range equalPropertiesTestCases {
err := tc.run()
if tc.expectedError != "" {
assert.EqualError(t, err, tc.expectedError)
assert.True(t, govy.HasErrorCode(err, ErrorCodeEqualProperties))
} else {
assert.NoError(t, err)
}
}
}

func BenchmarkEqualProperties(b *testing.B) {
for range b.N {
for _, tc := range equalPropertiesTestCases {
_ = tc.run()
}
}
}
1 change: 1 addition & 0 deletions pkg/rules/error_codes.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const (
ErrorCodeOneOf govy.ErrorCode = "one_of"
ErrorCodeOneOfProperties govy.ErrorCode = "one_of_properties"
ErrorCodeMutuallyExclusive govy.ErrorCode = "mutually_exclusive"
ErrorCodeEqualProperties govy.ErrorCode = "equal_properties"
ErrorCodeSliceUnique govy.ErrorCode = "slice_unique"
ErrorCodeURL govy.ErrorCode = "url"
ErrorCodeDurationPrecision govy.ErrorCode = "duration_precision"
Expand Down
92 changes: 91 additions & 1 deletion pkg/rules/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ type Teacher struct {
}

type Student struct {
Index string `json:"index"`
Index string `json:"index,omitempty"`
Name string `json:"name,omitempty"`
IndexCopy string `json:"indexCopy,omitempty"`
}

func ExampleSliceUnique() {
Expand Down Expand Up @@ -41,3 +43,91 @@ func ExampleSliceUnique() {
// - 'students' with value '[{"index":"foo"},{"index":"bar"},{"index":"baz"},{"index":"bar"}]':
// - elements are not unique, 2nd and 4th elements collide based on constraints: each student must have unique index
}

func ExampleMutuallyExclusive() {
v := govy.New(
govy.ForSlice(func(t Teacher) []Student { return t.Students }).
WithName("students").
RulesForEach(rules.MutuallyExclusive(true, map[string]func(Student) any{
"index": func(s Student) any { return s.Index },
"name": func(s Student) any { return s.Name },
})),
)
teacher := Teacher{
Students: []Student{
{Index: "foo"},
{Index: "bar", Name: "John"},
{Name: "Eve"},
{},
},
}
err := v.Validate(teacher)
if err != nil {
fmt.Println(err)
}

// Output:
// Validation has failed for the following properties:
// - 'students[1]' with value '{"index":"bar","name":"John"}':
// - [index, name] properties are mutually exclusive, provide only one of them
// - 'students[3]':
// - one of [index, name] properties must be set, none was provided
}

func ExampleOneOfProperties() {
v := govy.New(
govy.ForSlice(func(t Teacher) []Student { return t.Students }).
WithName("students").
RulesForEach(rules.OneOfProperties(map[string]func(Student) any{
"index": func(s Student) any { return s.Index },
"name": func(s Student) any { return s.Name },
})),
)
teacher := Teacher{
Students: []Student{
{Index: "foo"},
{},
{Name: "John"},
{Index: "bar", Name: "Eve"},
},
}
err := v.Validate(teacher)
if err != nil {
fmt.Println(err)
}

// Output:
// Validation has failed for the following properties:
// - 'students[1]':
// - one of [index, name] properties must be set, none was provided
}

func ExampleEqualProperties() {
v := govy.New(
govy.ForSlice(func(t Teacher) []Student { return t.Students }).
WithName("students").
RulesForEach(rules.EqualProperties(rules.CompareFunc, map[string]func(Student) any{
"index": func(s Student) any { return s.Index },
"indexCopy": func(s Student) any { return s.IndexCopy },
})),
)
teacher := Teacher{
Students: []Student{
{Index: "foo", IndexCopy: "foo"},
{Index: "bar"},
{IndexCopy: "foo"},
{}, // Both index and indexCopy are empty strings, and thus equal.
},
}
err := v.Validate(teacher)
if err != nil {
fmt.Println(err)
}

// Output:
// Validation has failed for the following properties:
// - 'students[1]' with value '{"index":"bar"}':
// - all of [index, indexCopy] properties must be equal, but 'index' is not equal to 'indexCopy'
// - 'students[2]' with value '{"indexCopy":"foo"}':
// - all of [index, indexCopy] properties must be equal, but 'index' is not equal to 'indexCopy'
}
Loading

0 comments on commit d65c01e

Please sign in to comment.