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

Allow Fast-Path Int Versions #11

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pkg/version/perl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,8 @@ func TestParsePerl(t *testing.T) {
} else {
require.NoError(t, err)
assert.Equal(t, pa, actual.ParsedAs, "got expected ParsedAs value")
assertDecimalEqualString(t, tt.expected, actual.Decimal)
assertDecimalEqualDecimal(t, tt.expected, actual.Decimal)
assertStringEquality(t, tt.expected, actual)
assertNumericEquality(t, tt.expected, actual)
}
})
}
Expand Down
4 changes: 2 additions & 2 deletions pkg/version/python_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@ func TestParsePython(t *testing.T) {
actual, err := ParsePython(tt.version)
require.NoError(t, err)
assert.Equal(t, pa, actual.ParsedAs, "got expected ParsedAs value")
assertDecimalEqualString(t, tt.expected, actual.Decimal)
assertDecimalEqualDecimal(t, tt.expected, actual.Decimal)
assertStringEquality(t, tt.expected, actual)
assertNumericEquality(t, tt.expected, actual)
})
}
}
Expand Down
44 changes: 37 additions & 7 deletions pkg/version/shared_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package version

import (
"fmt"
"strconv"
"testing"

"github.com/ericlagergren/decimal"
Expand Down Expand Up @@ -54,8 +55,8 @@ func TestParseGeneric(t *testing.T) {
actual, err := ParseGeneric(tt.version)
require.NoError(t, err)
assert.Equal(t, Generic, actual.ParsedAs, "got expected ParsedAs value")
assertDecimalEqualString(t, tt.expected, actual.Decimal)
assertDecimalEqualDecimal(t, tt.expected, actual.Decimal)
assertStringEquality(t, tt.expected, actual)
assertNumericEquality(t, tt.expected, actual)
})
}
}
Expand Down Expand Up @@ -150,8 +151,8 @@ func TestParseSemVer(t *testing.T) {
} else {
require.NoError(t, err)
assert.Equal(t, SemVer, actual.ParsedAs, "got expected ParsedAs value")
assertDecimalEqualString(t, test.expected, actual.Decimal)
assertDecimalEqualDecimal(t, test.expected, actual.Decimal)
assertStringEquality(t, test.expected, actual)
assertNumericEquality(t, test.expected, actual)
}
})
}
Expand Down Expand Up @@ -237,13 +238,42 @@ func TestIsNumber(t *testing.T) {
assert.False(t, isNumber("1.2.3"))
}

func assertStringEquality(t *testing.T, expected []string, actual *Version) {
if actual.Decimal != nil {
assertDecimalEqualString(t, expected, actual.Decimal)
} else {
assertIntsEqualString(t, expected, actual.Ints)
}
}

func assertNumericEquality(t *testing.T, expected []string, actual *Version) {
if actual.Decimal != nil {
assertDecimalEqualDecimal(t, expected, actual.Decimal)
} else {
assertIntsEqualInts(t, expected, actual.Ints)
}
}

func assertIntsEqualString(t *testing.T, expected []string, actual []int64) {
require.Equal(t, len(expected), len(actual))
for i := range expected {
assert.Equal(t, expected[i], strconv.FormatInt(actual[i], 10))
}
}

func assertDecimalEqualString(t *testing.T, expected []string, actual []*decimal.Big) {
require.Equal(t, len(expected), len(actual))
for i := range expected {
assert.Equal(t, expected[i], actual[i].String())
}
}

func assertIntsEqualInts(t *testing.T, expected []string, actual []int64) {
expectedInts, err := stringsToInts(expected)
assert.NoError(t, err)
assert.Equal(t, expectedInts, actual)
}

func assertDecimalEqualDecimal(t *testing.T, expected []string, actual []*decimal.Big) {
expectedDecimals, err := stringsToDecimals(expected)
assert.NoError(t, err)
Expand Down Expand Up @@ -354,8 +384,8 @@ func TestClone(t *testing.T) {
assert.Equal(t, v1.Original, v2.Original, "cloned version has same Original string")
assert.Equal(t, v1.ParsedAs, v2.ParsedAs, "cloned version has same ParsedAs value")

v1.Decimal[0] = decimal.New(0, 0)
assert.NotEqual(t, 0, Compare(v1, v2), "changing Decimal slice in original does not change clone")
v1.Ints[0] = 0
assert.NotEqual(t, 0, Compare(v1, v2), "changing slice in original does not change clone")
}

func TestString(t *testing.T) {
Expand Down Expand Up @@ -385,7 +415,7 @@ func TestTrimTrailingZeros(t *testing.T) {
for _, tt := range tests {
t.Run(fmt.Sprint(tt.input), func(t *testing.T) {
input := mustStringsToDecimal(t, tt.input)
actual := trimTrailingZeros(input)
actual := trimTrailingZerosDecimals(input)
expected := mustStringsToDecimal(t, tt.expected)
assert.Equal(t, expected, actual, "expected %v got %v", expected, actual)
})
Expand Down
192 changes: 178 additions & 14 deletions pkg/version/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ package version
//go:generate enumer -type ParsedAs .

import (
"encoding/json"
"errors"
"fmt"
"strconv"

"github.com/ericlagergren/decimal"
)
Expand Down Expand Up @@ -69,31 +71,104 @@ const (
type Version struct {
// Original is the string that was passed to the parsing func.
Original string `json:"version"`
// The simplest form of data
Ints []int64
// Decimal contains a slice of `*decimal.Big` values. This will always
// contain at least one element.
Decimal []*decimal.Big `json:"sortable_version"`
// ParsedAs indicates which type the version was parsed as.
ParsedAs ParsedAs `json:"-"`
}

func (v *Version) MarshalJSON() ([]byte, error) {
if v.Decimal != nil {
return json.Marshal(&struct {
Original string `json:"version"`
Decimal []*decimal.Big `json:"sortable_version"`
ParsedAs ParsedAs `json:"-"`
}{
Original: v.Original,
Decimal: v.Decimal,
ParsedAs: v.ParsedAs,
})
}
return json.Marshal(&struct {
Original string `json:"version"`
Ints []int64 `json:"sortable_version"`
ParsedAs ParsedAs `json:"-"`
}{
Original: v.Original,
Ints: v.Ints,
ParsedAs: v.ParsedAs,
})
}

// fromStringSlice take a version type and a slice of strings and returns a
// new Version struct. Each element of the string slice should contain a
// string representation of a number. This returns an error if any element of
// the slice cannot be converted to a *decimal.Big value.
func fromStringSlice(pa ParsedAs, original string, strings []string) (*Version, error) {
if isOnlyInts(strings) {
ints, err := stringsToInts(strings)
if err == nil {
ints = trimTrailingZerosInts(ints)
return &Version{
Original: original,
Ints: ints,
Decimal: nil,
ParsedAs: pa,
}, nil
}
}

// fall back to this case if there aren't only ints
// or if string to int64 conversion failed at any point
return makeDecimalVersions(pa, original, strings)
}

func makeDecimalVersions(pa ParsedAs, original string, strings []string) (*Version, error) {
decimals, err := stringsToDecimals(strings)
if err != nil {
return nil, err
}

decimals = trimTrailingZeros(decimals)
decimals = trimTrailingZerosDecimals(decimals)
return &Version{
Original: original,
Ints: nil,
Decimal: decimals,
ParsedAs: pa,
}, nil
}

// 2^63 is 19 digits long, so play it safe
func isOnlyInts(strings []string) bool {
for _, s := range strings {
if len(s) > 18 {
return false
}
}

return true
}

func stringsToInts(strings []string) ([]int64, error) {
if len(strings) == 0 {
return nil, errors.New("The provided string slice must have at least one element")
}

ints := make([]int64, len(strings))
for i, s := range strings {
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return nil, errors.New("Failed to create int64 from " + s)
}
ints[i] = n
}

return ints, nil
}

func stringsToDecimals(strings []string) ([]*decimal.Big, error) {
if len(strings) == 0 {
return nil, errors.New("The provided string slice must have at least one element")
Expand All @@ -111,7 +186,19 @@ func stringsToDecimals(strings []string) ([]*decimal.Big, error) {
return decimals, nil
}

func trimTrailingZeros(decimals []*decimal.Big) []*decimal.Big {
func trimTrailingZerosInts(ints []int64) []int64 {
indexOfLastZero := len(ints)
for i := len(ints) - 1; i > 0; i-- {
if ints[i] != 0 {
break
}
indexOfLastZero = i
}

return ints[0:indexOfLastZero]
}

func trimTrailingZerosDecimals(decimals []*decimal.Big) []*decimal.Big {
indexOfLastZero := len(decimals)
for i := len(decimals) - 1; i > 0; i-- {
if decimals[i].Cmp(bigZero) != 0 {
Expand All @@ -133,7 +220,35 @@ var bigZero = decimal.New(0, 0)
// Versions that differ only by trailing zeros (e.g. "1.2" and "1.2.0") are
// equal.
func Compare(v1, v2 *Version) int {
min, max, longest, flip := minMax(v1.Decimal, v2.Decimal)
if v1.Decimal != nil && v2.Decimal != nil {
return compareDecimals(v1, v2)
} else if v1.Decimal != nil && v2.Decimal == nil {
v2.Decimal = promoteDecimals(v2)
cmp := compareDecimals(v1, v2)
v2.Decimal = nil
return cmp
} else if v1.Decimal == nil && v2.Decimal != nil {
v1.Decimal = promoteDecimals(v1)
cmp := compareDecimals(v1, v2)
v1.Decimal = nil
return cmp
} else {
return compareInts(v1, v2)
}
}

func promoteDecimals(v *Version) []*decimal.Big {
decimals := make([]*decimal.Big, len(v.Ints))
for i, n := range v.Ints {
d := decimal.New(n, 0)
decimals[i] = d
}

return decimals
}

func compareDecimals(v1 *Version, v2 *Version) int {
min, max, longest, flip := minMaxDecimals(v1.Decimal, v2.Decimal)

// find any difference between these versions where they have the same number of segments
for i := 0; i < min; i++ {
Expand All @@ -154,8 +269,47 @@ func Compare(v1, v2 *Version) int {
return 0
}

// helper function to find the lengths of and longest version segment array
func minMax(v1 []*decimal.Big, v2 []*decimal.Big) (int, int, []*decimal.Big, int) {
func compareInts(v1 *Version, v2 *Version) int {
min, max, longest, flip := minMaxInts(v1.Ints, v2.Ints)

// find any difference between these versions where they have the same number of segments
for i := 0; i < min; i++ {
if v1.Ints[i] != v2.Ints[i] {
if v1.Ints[i] < v2.Ints[i] {
return -1
} else {
return 1
}
}
}

// compare remaining segments to zero
for i := min; i < max; i++ {
if longest[i] != 0 {
if longest[i] < 0 {
return -1 * flip
} else {
return flip
}
}
}

return 0
}

// helper function to find the lengths of and longest version segment array of Decimals
func minMaxDecimals(v1 []*decimal.Big, v2 []*decimal.Big) (int, int, []*decimal.Big, int) {
l1 := len(v1)
l2 := len(v2)

if l1 < l2 {
return l1, l2, v2, -1
}
return l2, l1, v1, 1
}

// helper function to find the lengths of and longest version segment array of Ints
func minMaxInts(v1 []int64, v2 []int64) (int, int, []int64, int) {
l1 := len(v1)
l2 := len(v2)

Expand All @@ -168,15 +322,25 @@ func minMax(v1 []*decimal.Big, v2 []*decimal.Big) (int, int, []*decimal.Big, int
// Clone returns a new *Version that is a clone of the one passed as the
// method receiver.
func (v *Version) Clone() *Version {
d := make([]*decimal.Big, len(v.Decimal))
for i := range v.Decimal {
d[i] = decimal.New(0, 0)
d[i].Copy(v.Decimal[i])
}
return &Version{
Original: v.Original,
Decimal: d,
ParsedAs: v.ParsedAs,
if v.Decimal != nil {
d := make([]*decimal.Big, len(v.Decimal))
for i := range v.Decimal {
d[i] = decimal.New(0, 0)
d[i].Copy(v.Decimal[i])
}
return &Version{
Original: v.Original,
Decimal: d,
ParsedAs: v.ParsedAs,
}
} else {
i := make([]int64, len(v.Ints))
copy(i, v.Ints)
return &Version{
Original: v.Original,
Ints: i,
ParsedAs: v.ParsedAs,
}
}
}

Expand Down