Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Constraint(s): introduce Equals() and sort.Interface #88

Merged
merged 1 commit into from Nov 22, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
98 changes: 87 additions & 11 deletions constraint.go
Expand Up @@ -4,37 +4,48 @@ import (
"fmt"
"reflect"
"regexp"
"sort"
"strings"
)

// Constraint represents a single constraint for a version, such as
// ">= 1.0".
type Constraint struct {
f constraintFunc
op operator
check *Version
original string
}

func (c *Constraint) Equals(con *Constraint) bool {
return c.op == con.op && c.check.Equal(con.check)
}

// Constraints is a slice of constraints. We make a custom type so that
// we can add methods to it.
type Constraints []*Constraint

type constraintFunc func(v, c *Version) bool

var constraintOperators map[string]constraintFunc
var constraintOperators map[string]constraintOperation

type constraintOperation struct {
op operator
f constraintFunc
}

var constraintRegexp *regexp.Regexp

func init() {
constraintOperators = map[string]constraintFunc{
"": constraintEqual,
"=": constraintEqual,
"!=": constraintNotEqual,
">": constraintGreaterThan,
"<": constraintLessThan,
">=": constraintGreaterThanEqual,
"<=": constraintLessThanEqual,
"~>": constraintPessimistic,
constraintOperators = map[string]constraintOperation{
"": {op: equal, f: constraintEqual},
"=": {op: equal, f: constraintEqual},
"!=": {op: notEqual, f: constraintNotEqual},
">": {op: greaterThan, f: constraintGreaterThan},
"<": {op: lessThan, f: constraintLessThan},
">=": {op: greaterThanEqual, f: constraintGreaterThanEqual},
"<=": {op: lessThanEqual, f: constraintLessThanEqual},
"~>": {op: pessimistic, f: constraintPessimistic},
}

ops := make([]string, 0, len(constraintOperators))
Expand Down Expand Up @@ -77,6 +88,56 @@ func (cs Constraints) Check(v *Version) bool {
return true
}

// Equals compares Constraints with other Constraints
// for equality. This may not represent logical equivalence
// of compared constraints.
// e.g. even though '>0.1,>0.2' is logically equivalent
// to '>0.2' it is *NOT* treated as equal.
//
// Missing operator is treated as equal to '=', whitespaces
// are ignored and constraints are sorted before comaparison.
func (cs Constraints) Equals(c Constraints) bool {
if len(cs) != len(c) {
return false
}

// make copies to retain order of the original slices
left := make(Constraints, len(cs))
copy(left, cs)
sort.Stable(left)
right := make(Constraints, len(c))
copy(right, c)
sort.Stable(right)

// compare sorted slices
for i, con := range left {
if !con.Equals(right[i]) {
return false
}
}

return true
}

func (cs Constraints) Len() int {
return len(cs)
}

func (cs Constraints) Less(i, j int) bool {
if cs[i].op < cs[j].op {
return true
}
if cs[i].op > cs[j].op {
return false
}

return cs[i].check.LessThan(cs[j].check)
}

func (cs Constraints) Swap(i, j int) {
cs[i], cs[j] = cs[j], cs[i]
}

// Returns the string format of the constraints
func (cs Constraints) String() string {
csStr := make([]string, len(cs))
Expand Down Expand Up @@ -107,8 +168,11 @@ func parseSingle(v string) (*Constraint, error) {
return nil, err
}

cop := constraintOperators[matches[1]]

return &Constraint{
f: constraintOperators[matches[1]],
f: cop.f,
op: cop.op,
check: check,
original: v,
}, nil
Expand Down Expand Up @@ -138,6 +202,18 @@ func prereleaseCheck(v, c *Version) bool {
// Constraint functions
//-------------------------------------------------------------------

type operator rune

const (
equal operator = '='
notEqual operator = '≠'
greaterThan operator = '>'
lessThan operator = '<'
greaterThanEqual operator = '≥'
lessThanEqual operator = '≤'
pessimistic operator = '~'
Comment on lines +208 to +214
Copy link

@azr azr Nov 22, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this is a good suggestion: but have you considered making of these strings, in case in a far future we want to be writing//rewriting these operators out to a file ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have considered that, but I concluded that exposing the operators would make them a public API and I wasn't sure what that API should really look like - e.g. whether equal should be empty string or =. i.e. there were some edge cases that I didn't want to decide, and so rune makes it more obvious that this decision is yet to be made and it's more consciously not meant to be used directly as a public API, but purely for comparison.

You could argue we are already making it public API in the context of sort.Interface, as turning runes to strings would likely affect order. However, I still think that displaying operators somewhere is a separate use case and so the potential future duplication (each operator having its own rune + string representation) is fine.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me 🙂

)

func constraintEqual(v, c *Version) bool {
return v.Equal(c)
}
Expand Down
101 changes: 101 additions & 0 deletions constraint_test.go
@@ -1,6 +1,9 @@
package version

import (
"fmt"
"reflect"
"sort"
"testing"
)

Expand Down Expand Up @@ -97,6 +100,104 @@ func TestConstraintCheck(t *testing.T) {
}
}

func TestConstraintEqual(t *testing.T) {
cases := []struct {
leftConstraint string
rightConstraint string
expectedEqual bool
}{
{
"0.0.1",
"0.0.1",
true,
},
{ // whitespaces
" 0.0.1 ",
"0.0.1",
true,
},
{ // equal op implied
"=0.0.1 ",
"0.0.1",
true,
},
{ // version difference
"=0.0.1",
"=0.0.2",
false,
},
{ // operator difference
">0.0.1",
"=0.0.1",
false,
},
{ // different order
">0.1.0, <=1.0.0",
"<=1.0.0, >0.1.0",
true,
},
}

for _, tc := range cases {
leftCon, err := NewConstraint(tc.leftConstraint)
if err != nil {
t.Fatalf("err: %s", err)
}
rightCon, err := NewConstraint(tc.rightConstraint)
if err != nil {
t.Fatalf("err: %s", err)
}

actual := leftCon.Equals(rightCon)
if actual != tc.expectedEqual {
t.Fatalf("Constraints: %s vs %s\nExpected: %t\nActual: %t",
tc.leftConstraint, tc.rightConstraint, tc.expectedEqual, actual)
}
}
}

func TestConstraint_sort(t *testing.T) {
cases := []struct {
constraint string
expectedConstraints string
}{
{
">= 0.1.0,< 1.12",
"< 1.12,>= 0.1.0",
},
{
"< 1.12,>= 0.1.0",
"< 1.12,>= 0.1.0",
},
{
"< 1.12,>= 0.1.0,0.2.0",
"< 1.12,0.2.0,>= 0.1.0",
},
{
">1.0,>0.1.0,>0.3.0,>0.2.0",
">0.1.0,>0.2.0,>0.3.0,>1.0",
},
}

for i, tc := range cases {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
c, err := NewConstraint(tc.constraint)
if err != nil {
t.Fatalf("err: %s", err)
}

sort.Sort(c)

actual := c.String()

if !reflect.DeepEqual(actual, tc.expectedConstraints) {
t.Fatalf("unexpected order\nexpected: %#v\nactual: %#v",
tc.expectedConstraints, actual)
}
})
}
}

func TestConstraintsString(t *testing.T) {
cases := []struct {
constraint string
Expand Down