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

Introduce lexicographic string encoding #21

Merged
merged 3 commits into from
Feb 21, 2025
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,35 @@ The `Version` and `RelaxedVersion` provides optimized `MarshalBinary`/`Unmarshal
## Yaml parsable with `gopkg.in/yaml.v3`

The `Version` and `RelaxedVersion` have the YAML un/marshaler implemented so they can be YAML decoded/encoded with the excellent `gopkg.in/yaml.v3` library.

## Lexicographic sortable strings that keeps semantic versioning order

The `Version` and `RelaxedVersion` objects provides the `SortableString()` method that returns a string with a peculiar property: the alphanumeric sorting of two `Version.SortableString()` matches the semantic versioning ordering of the underling `Version` objects. In other words, given two `Version` object `a` and `b`:
* if `a.LessThan(b)` is true then `a.SortableString() < b.SortableString()` is true and vice-versa.
* if `a.Equals(b)` is true then `a.SortableString() == b.SortableString()` is true and vice-versa.
* more generally, the following assertion is always true: `a.CompareTo(b) == cmp.Compare(a.SortableString(), b.SortableString())`

This is accomplished by adding some adjustment characters to the original semver `Version` string with the purpose to change the behaviour of the natural alphabetic ordering, in particular:
* to allow comparison of numeric values (keeping digits aligned by unit, tenths, hundhereds, etc...).
* to invert the ordering of versions with and without prelease (a version with prelease should be lower priority compared to the same version without prerelease, but being longer alphanumerically it naturally follows it).

To give you an idea on how it works, the following table shows some examples of semver versions and their `SortableString` counter part:

| semver | `SortableString()` |
| ------------------ | ------------------ |
| `1.2.4` | `1.2.4;` |
| `1.3.0-rc` | `1.3.0-;rc` |
| `1.3.0-rc.0` | `1.3.0-;rc.:0` |
| `1.3.0-rc.5` | `1.3.0-;rc.:5` |
| `1.3.0-rc.5+build` | `1.3.0-;rc.:5` |
| `1.3.0-rc.20` | `1.3.0-;rc.::20` |
| `1.3.0` | `1.3.0;` |
| `1.20.0` | `1.:20.0;` |
| `1.90.0` | `1.:90.0;` |
| `1.300.0-6` | `1.::300.0-:6` |
| `1.300.0-30` | `1.::300.0-::30` |
| `1.300.0-1pre` | `1.::300.0-;1pre` |
| `1.300.0-pre` | `1.::300.0-;pre` |
| `1.300.0` | `1.::300.0;` |

The `SortableString()` can be used in SQL databases to simplify the ordering of a set of versions in a table.
15 changes: 15 additions & 0 deletions relaxed_version.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,18 @@ func (v *RelaxedVersion) CompatibleWith(u *RelaxedVersion) bool {
}
return v.Equal(u)
}

// SortableString returns the version encoded as a string that when compared
// with alphanumeric ordering it respects the original semver ordering:
//
// (v1.SortableString() < v2.SortableString()) == v1.LessThan(v2)
// cmp.Compare[string](v1.SortableString(), v2.SortableString()) == v1.CompareTo(v2)
//
// This may turn out useful when the version is saved in a database or is
// introduced in a system that doesn't support semver ordering.
func (v *RelaxedVersion) SortableString() string {
if v.version != nil {
return ";" + v.version.SortableString()
}
return ":" + string(v.customversion)
}
111 changes: 79 additions & 32 deletions relaxed_version_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
package semver

import (
"cmp"
"fmt"
"testing"

Expand All @@ -17,47 +18,93 @@ func TestRelaxedVersionComparator(t *testing.T) {
sign := map[int]string{1: ">", 0: "=", -1: "<"}
ascending := func(list ...*RelaxedVersion) {
for i := range list[0 : len(list)-1] {
a := list[i]
b := list[i+1]
comp := a.CompareTo(b)
fmt.Printf("%s %s %s\n", a, sign[comp], b)
require.Equal(t, comp, -1)
require.True(t, a.LessThan(b))
require.True(t, a.LessThanOrEqual(b))
require.False(t, a.Equal(b))
require.False(t, a.GreaterThanOrEqual(b))
require.False(t, a.GreaterThan(b))

comp = b.CompareTo(a)
fmt.Printf("%s %s %s\n", b, sign[comp], a)
require.Equal(t, comp, 1)
require.False(t, b.LessThan(a))
require.False(t, b.LessThanOrEqual(a))
require.False(t, b.Equal(a))
require.True(t, b.GreaterThanOrEqual(a))
require.True(t, b.GreaterThan(a))
}
}
equal := func(list ...*RelaxedVersion) {
for _, a := range list {
for _, b := range list {
{
a := list[i]
b := list[i+1]
comp := a.CompareTo(b)
fmt.Printf("%s %s %s\n", a, sign[comp], b)
require.Equal(t, comp, 0)
require.False(t, a.LessThan(b))
require.Equal(t, comp, -1)
require.True(t, a.LessThan(b))
require.True(t, a.LessThanOrEqual(b))
require.True(t, a.Equal(b))
require.True(t, a.GreaterThanOrEqual(b))
require.False(t, a.Equal(b))
require.False(t, a.GreaterThanOrEqual(b))
require.False(t, a.GreaterThan(b))

comp = b.CompareTo(a)
fmt.Printf("%s %s %s\n", b, sign[comp], a)
require.Equal(t, comp, 0)
require.Equal(t, comp, 1)
require.False(t, b.LessThan(a))
require.True(t, b.LessThanOrEqual(a))
require.True(t, b.Equal(a))
require.False(t, b.LessThanOrEqual(a))
require.False(t, b.Equal(a))
require.True(t, b.GreaterThanOrEqual(a))
require.False(t, b.GreaterThan(a))
require.True(t, b.GreaterThan(a))
}
{
a := list[i].SortableString()
b := list[i+1].SortableString()
comp := cmp.Compare(a, b)
fmt.Printf("%s %s %s\n", a, sign[comp], b)
require.Equal(t, comp, -1)
require.True(t, a < b)
require.True(t, a <= b)
require.False(t, a == b)
require.False(t, a >= b)
require.False(t, a > b)

comp = cmp.Compare(b, a)
fmt.Printf("%s %s %s\n", b, sign[comp], a)
require.Equal(t, comp, 1)
require.False(t, b < a)
require.False(t, b <= a)
require.False(t, b == a)
require.True(t, b >= a)
require.True(t, b > a)
}
}
}
equal := func(list ...*RelaxedVersion) {
for _, a := range list {
for _, b := range list {
{
comp := a.CompareTo(b)
fmt.Printf("%s %s %s\n", a, sign[comp], b)
require.Equal(t, comp, 0)
require.False(t, a.LessThan(b))
require.True(t, a.LessThanOrEqual(b))
require.True(t, a.Equal(b))
require.True(t, a.GreaterThanOrEqual(b))
require.False(t, a.GreaterThan(b))

comp = b.CompareTo(a)
fmt.Printf("%s %s %s\n", b, sign[comp], a)
require.Equal(t, comp, 0)
require.False(t, b.LessThan(a))
require.True(t, b.LessThanOrEqual(a))
require.True(t, b.Equal(a))
require.True(t, b.GreaterThanOrEqual(a))
require.False(t, b.GreaterThan(a))
}
{
a := a.SortableString()
b := b.SortableString()
comp := cmp.Compare(a, b)
fmt.Printf("%s %s %s\n", a, sign[comp], b)
require.Equal(t, comp, 0)
require.False(t, a < b)
require.True(t, a <= b)
require.True(t, a == b)
require.True(t, a >= b)
require.False(t, a > b)

comp = cmp.Compare(b, a)
fmt.Printf("%s %s %s\n", b, sign[comp], a)
require.Equal(t, comp, 0)
require.False(t, b < a)
require.True(t, b <= a)
require.True(t, b == a)
require.True(t, b >= a)
require.False(t, b > a)
}
}
}
}
Expand Down
74 changes: 74 additions & 0 deletions version.go
Original file line number Diff line number Diff line change
Expand Up @@ -416,3 +416,77 @@ func (v *Version) CompatibleWith(u *Version) bool {
}
return compareNumber(vPatch, uPatch) == 0
}

// SortableString returns the version encoded as a string that when compared
// with alphanumeric ordering it respects the original semver ordering:
//
// (v1.SortableString() < v2.SortableString()) == v1.LessThan(v2)
// cmp.Compare[string](v1.SortableString(), v2.SortableString()) == v1.CompareTo(v2)
//
// This may turn out useful when the version is saved in a database or is
// introduced in a system that doesn't support semver ordering.
func (v *Version) SortableString() string {
// Encode a number in a string that when compared as string it respects
// the original numeric order.
// To allow longer numbers to be compared correctly, a prefix of ":"s
// with the length of the number is added minus 1.
// For example: 123 -> "::123"
// 45 -> ":45"
// The number written as string compare as ("123" < "99") but the encoded
// version keeps the original integer ordering ("::123" > ":99").
encodeNumber := func(in []byte) string {
if len(in) == 0 {
return "0"
}
p := ""
for range in {
p += ":"
}
return p[:len(p)-1] + string(in)
}

var vMajor, vMinor, vPatch []byte
vMajor = v.bytes[:v.major]
if v.minor > v.major {
vMinor = v.bytes[v.major+1 : v.minor]
}
if v.patch > v.minor {
vPatch = v.bytes[v.minor+1 : v.patch]
}

res := encodeNumber(vMajor) + "." + encodeNumber(vMinor) + "." + encodeNumber(vPatch)
// If there is no pre-release, add a ";" to the end, otherwise add a "-" followed by the pre-release.
// This ensure the correct ordering of the pre-release versions (that are always lower than the normal versions).
if v.prerelease == v.patch {
return res + ";"
}
res += "-"

isAlpha := false
add := func(in []byte) {
// if the pre-release piece is alphanumeric, add a ";" before the piece
// otherwise add an ":" before the piece. This ensure the correct ordering
// of the pre-release piece (numeric are lower than alphanumeric).
if isAlpha {
res += ";" + string(in)
} else {
res += ":" + encodeNumber(in)
}
isAlpha = false
}
prerelease := v.bytes[v.patch+1 : v.prerelease]
start := 0
for curr, c := range prerelease {
if c == '.' {
add(prerelease[start:curr])
res += "."
start = curr + 1
continue
}
if !isNumeric(c) {
isAlpha = true
}
}
add(prerelease[start:])
return res
}
40 changes: 40 additions & 0 deletions version_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
package semver

import (
"cmp"
"fmt"
"testing"

Expand Down Expand Up @@ -54,6 +55,41 @@ func ascending(t *testing.T, allowEqual bool, list ...string) {
require.True(t, b.GreaterThan(a))
}
}

for i := range list[0 : len(list)-1] {
a := MustParse(list[i]).SortableString()
b := MustParse(list[i+1]).SortableString()
comp := cmp.Compare(a, b)
if allowEqual {
fmt.Printf("%s %s= %s\n", list[i], sign[comp], list[i+1])
require.LessOrEqual(t, comp, 0)
require.True(t, a <= b)
require.False(t, a > b)
} else {
fmt.Printf("%s %s %s\n", list[i], sign[comp], list[i+1])
require.Equal(t, comp, -1, "cmp(%s, %s) (%s, %s) must return '<', but returned '%s'", list[i], list[i+1], a, b, sign[comp])
require.True(t, a < b)
require.True(t, a <= b)
require.False(t, a == b)
require.False(t, a >= b)
require.False(t, a > b)
}

comp = cmp.Compare(b, a)
fmt.Printf("%s %s %s\n", b, sign[comp], a)
if allowEqual {
require.GreaterOrEqual(t, comp, 0, "cmp(%s, %s) must return '>=', but returned '%s'", b, a, sign[comp])
require.False(t, b < a)
require.True(t, b >= a)
} else {
require.Equal(t, comp, 1)
require.False(t, b < a)
require.False(t, b <= a)
require.False(t, b == a)
require.True(t, b >= a)
require.True(t, b > a)
}
}
}

func TestVersionComparator(t *testing.T) {
Expand Down Expand Up @@ -124,6 +160,10 @@ func TestVersionComparator(t *testing.T) {
"17.3.0-atmel3a.16.1-arduino7",
"17.3.0-atmel3a.16.12-arduino7",
"17.3.0-atmel3a.16.2-arduino7",
"34.0.0",
"51.0.0",
"99.0.0",
"123.0.0",
)
equal(
MustParse(""),
Expand Down