Skip to content

Commit b4ad6ea

Browse files
authored
Merge pull request #21 from bugst/string_order_encoding
Introduce lexicographic string encoding
2 parents 6ae0217 + e5badc1 commit b4ad6ea

File tree

5 files changed

+240
-32
lines changed

5 files changed

+240
-32
lines changed

Diff for: README.md

+32
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,35 @@ The `Version` and `RelaxedVersion` provides optimized `MarshalBinary`/`Unmarshal
9595
## Yaml parsable with `gopkg.in/yaml.v3`
9696

9797
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.
98+
99+
## Lexicographic sortable strings that keeps semantic versioning order
100+
101+
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`:
102+
* if `a.LessThan(b)` is true then `a.SortableString() < b.SortableString()` is true and vice-versa.
103+
* if `a.Equals(b)` is true then `a.SortableString() == b.SortableString()` is true and vice-versa.
104+
* more generally, the following assertion is always true: `a.CompareTo(b) == cmp.Compare(a.SortableString(), b.SortableString())`
105+
106+
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:
107+
* to allow comparison of numeric values (keeping digits aligned by unit, tenths, hundhereds, etc...).
108+
* 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).
109+
110+
To give you an idea on how it works, the following table shows some examples of semver versions and their `SortableString` counter part:
111+
112+
| semver | `SortableString()` |
113+
| ------------------ | ------------------ |
114+
| `1.2.4` | `1.2.4;` |
115+
| `1.3.0-rc` | `1.3.0-;rc` |
116+
| `1.3.0-rc.0` | `1.3.0-;rc.:0` |
117+
| `1.3.0-rc.5` | `1.3.0-;rc.:5` |
118+
| `1.3.0-rc.5+build` | `1.3.0-;rc.:5` |
119+
| `1.3.0-rc.20` | `1.3.0-;rc.::20` |
120+
| `1.3.0` | `1.3.0;` |
121+
| `1.20.0` | `1.:20.0;` |
122+
| `1.90.0` | `1.:90.0;` |
123+
| `1.300.0-6` | `1.::300.0-:6` |
124+
| `1.300.0-30` | `1.::300.0-::30` |
125+
| `1.300.0-1pre` | `1.::300.0-;1pre` |
126+
| `1.300.0-pre` | `1.::300.0-;pre` |
127+
| `1.300.0` | `1.::300.0;` |
128+
129+
The `SortableString()` can be used in SQL databases to simplify the ordering of a set of versions in a table.

Diff for: relaxed_version.go

+15
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,18 @@ func (v *RelaxedVersion) CompatibleWith(u *RelaxedVersion) bool {
103103
}
104104
return v.Equal(u)
105105
}
106+
107+
// SortableString returns the version encoded as a string that when compared
108+
// with alphanumeric ordering it respects the original semver ordering:
109+
//
110+
// (v1.SortableString() < v2.SortableString()) == v1.LessThan(v2)
111+
// cmp.Compare[string](v1.SortableString(), v2.SortableString()) == v1.CompareTo(v2)
112+
//
113+
// This may turn out useful when the version is saved in a database or is
114+
// introduced in a system that doesn't support semver ordering.
115+
func (v *RelaxedVersion) SortableString() string {
116+
if v.version != nil {
117+
return ";" + v.version.SortableString()
118+
}
119+
return ":" + string(v.customversion)
120+
}

Diff for: relaxed_version_test.go

+79-32
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
package semver
88

99
import (
10+
"cmp"
1011
"fmt"
1112
"testing"
1213

@@ -17,47 +18,93 @@ func TestRelaxedVersionComparator(t *testing.T) {
1718
sign := map[int]string{1: ">", 0: "=", -1: "<"}
1819
ascending := func(list ...*RelaxedVersion) {
1920
for i := range list[0 : len(list)-1] {
20-
a := list[i]
21-
b := list[i+1]
22-
comp := a.CompareTo(b)
23-
fmt.Printf("%s %s %s\n", a, sign[comp], b)
24-
require.Equal(t, comp, -1)
25-
require.True(t, a.LessThan(b))
26-
require.True(t, a.LessThanOrEqual(b))
27-
require.False(t, a.Equal(b))
28-
require.False(t, a.GreaterThanOrEqual(b))
29-
require.False(t, a.GreaterThan(b))
30-
31-
comp = b.CompareTo(a)
32-
fmt.Printf("%s %s %s\n", b, sign[comp], a)
33-
require.Equal(t, comp, 1)
34-
require.False(t, b.LessThan(a))
35-
require.False(t, b.LessThanOrEqual(a))
36-
require.False(t, b.Equal(a))
37-
require.True(t, b.GreaterThanOrEqual(a))
38-
require.True(t, b.GreaterThan(a))
39-
}
40-
}
41-
equal := func(list ...*RelaxedVersion) {
42-
for _, a := range list {
43-
for _, b := range list {
21+
{
22+
a := list[i]
23+
b := list[i+1]
4424
comp := a.CompareTo(b)
4525
fmt.Printf("%s %s %s\n", a, sign[comp], b)
46-
require.Equal(t, comp, 0)
47-
require.False(t, a.LessThan(b))
26+
require.Equal(t, comp, -1)
27+
require.True(t, a.LessThan(b))
4828
require.True(t, a.LessThanOrEqual(b))
49-
require.True(t, a.Equal(b))
50-
require.True(t, a.GreaterThanOrEqual(b))
29+
require.False(t, a.Equal(b))
30+
require.False(t, a.GreaterThanOrEqual(b))
5131
require.False(t, a.GreaterThan(b))
5232

5333
comp = b.CompareTo(a)
5434
fmt.Printf("%s %s %s\n", b, sign[comp], a)
55-
require.Equal(t, comp, 0)
35+
require.Equal(t, comp, 1)
5636
require.False(t, b.LessThan(a))
57-
require.True(t, b.LessThanOrEqual(a))
58-
require.True(t, b.Equal(a))
37+
require.False(t, b.LessThanOrEqual(a))
38+
require.False(t, b.Equal(a))
5939
require.True(t, b.GreaterThanOrEqual(a))
60-
require.False(t, b.GreaterThan(a))
40+
require.True(t, b.GreaterThan(a))
41+
}
42+
{
43+
a := list[i].SortableString()
44+
b := list[i+1].SortableString()
45+
comp := cmp.Compare(a, b)
46+
fmt.Printf("%s %s %s\n", a, sign[comp], b)
47+
require.Equal(t, comp, -1)
48+
require.True(t, a < b)
49+
require.True(t, a <= b)
50+
require.False(t, a == b)
51+
require.False(t, a >= b)
52+
require.False(t, a > b)
53+
54+
comp = cmp.Compare(b, a)
55+
fmt.Printf("%s %s %s\n", b, sign[comp], a)
56+
require.Equal(t, comp, 1)
57+
require.False(t, b < a)
58+
require.False(t, b <= a)
59+
require.False(t, b == a)
60+
require.True(t, b >= a)
61+
require.True(t, b > a)
62+
}
63+
}
64+
}
65+
equal := func(list ...*RelaxedVersion) {
66+
for _, a := range list {
67+
for _, b := range list {
68+
{
69+
comp := a.CompareTo(b)
70+
fmt.Printf("%s %s %s\n", a, sign[comp], b)
71+
require.Equal(t, comp, 0)
72+
require.False(t, a.LessThan(b))
73+
require.True(t, a.LessThanOrEqual(b))
74+
require.True(t, a.Equal(b))
75+
require.True(t, a.GreaterThanOrEqual(b))
76+
require.False(t, a.GreaterThan(b))
77+
78+
comp = b.CompareTo(a)
79+
fmt.Printf("%s %s %s\n", b, sign[comp], a)
80+
require.Equal(t, comp, 0)
81+
require.False(t, b.LessThan(a))
82+
require.True(t, b.LessThanOrEqual(a))
83+
require.True(t, b.Equal(a))
84+
require.True(t, b.GreaterThanOrEqual(a))
85+
require.False(t, b.GreaterThan(a))
86+
}
87+
{
88+
a := a.SortableString()
89+
b := b.SortableString()
90+
comp := cmp.Compare(a, b)
91+
fmt.Printf("%s %s %s\n", a, sign[comp], b)
92+
require.Equal(t, comp, 0)
93+
require.False(t, a < b)
94+
require.True(t, a <= b)
95+
require.True(t, a == b)
96+
require.True(t, a >= b)
97+
require.False(t, a > b)
98+
99+
comp = cmp.Compare(b, a)
100+
fmt.Printf("%s %s %s\n", b, sign[comp], a)
101+
require.Equal(t, comp, 0)
102+
require.False(t, b < a)
103+
require.True(t, b <= a)
104+
require.True(t, b == a)
105+
require.True(t, b >= a)
106+
require.False(t, b > a)
107+
}
61108
}
62109
}
63110
}

Diff for: version.go

+74
Original file line numberDiff line numberDiff line change
@@ -416,3 +416,77 @@ func (v *Version) CompatibleWith(u *Version) bool {
416416
}
417417
return compareNumber(vPatch, uPatch) == 0
418418
}
419+
420+
// SortableString returns the version encoded as a string that when compared
421+
// with alphanumeric ordering it respects the original semver ordering:
422+
//
423+
// (v1.SortableString() < v2.SortableString()) == v1.LessThan(v2)
424+
// cmp.Compare[string](v1.SortableString(), v2.SortableString()) == v1.CompareTo(v2)
425+
//
426+
// This may turn out useful when the version is saved in a database or is
427+
// introduced in a system that doesn't support semver ordering.
428+
func (v *Version) SortableString() string {
429+
// Encode a number in a string that when compared as string it respects
430+
// the original numeric order.
431+
// To allow longer numbers to be compared correctly, a prefix of ":"s
432+
// with the length of the number is added minus 1.
433+
// For example: 123 -> "::123"
434+
// 45 -> ":45"
435+
// The number written as string compare as ("123" < "99") but the encoded
436+
// version keeps the original integer ordering ("::123" > ":99").
437+
encodeNumber := func(in []byte) string {
438+
if len(in) == 0 {
439+
return "0"
440+
}
441+
p := ""
442+
for range in {
443+
p += ":"
444+
}
445+
return p[:len(p)-1] + string(in)
446+
}
447+
448+
var vMajor, vMinor, vPatch []byte
449+
vMajor = v.bytes[:v.major]
450+
if v.minor > v.major {
451+
vMinor = v.bytes[v.major+1 : v.minor]
452+
}
453+
if v.patch > v.minor {
454+
vPatch = v.bytes[v.minor+1 : v.patch]
455+
}
456+
457+
res := encodeNumber(vMajor) + "." + encodeNumber(vMinor) + "." + encodeNumber(vPatch)
458+
// If there is no pre-release, add a ";" to the end, otherwise add a "-" followed by the pre-release.
459+
// This ensure the correct ordering of the pre-release versions (that are always lower than the normal versions).
460+
if v.prerelease == v.patch {
461+
return res + ";"
462+
}
463+
res += "-"
464+
465+
isAlpha := false
466+
add := func(in []byte) {
467+
// if the pre-release piece is alphanumeric, add a ";" before the piece
468+
// otherwise add an ":" before the piece. This ensure the correct ordering
469+
// of the pre-release piece (numeric are lower than alphanumeric).
470+
if isAlpha {
471+
res += ";" + string(in)
472+
} else {
473+
res += ":" + encodeNumber(in)
474+
}
475+
isAlpha = false
476+
}
477+
prerelease := v.bytes[v.patch+1 : v.prerelease]
478+
start := 0
479+
for curr, c := range prerelease {
480+
if c == '.' {
481+
add(prerelease[start:curr])
482+
res += "."
483+
start = curr + 1
484+
continue
485+
}
486+
if !isNumeric(c) {
487+
isAlpha = true
488+
}
489+
}
490+
add(prerelease[start:])
491+
return res
492+
}

Diff for: version_test.go

+40
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
package semver
88

99
import (
10+
"cmp"
1011
"fmt"
1112
"testing"
1213

@@ -54,6 +55,41 @@ func ascending(t *testing.T, allowEqual bool, list ...string) {
5455
require.True(t, b.GreaterThan(a))
5556
}
5657
}
58+
59+
for i := range list[0 : len(list)-1] {
60+
a := MustParse(list[i]).SortableString()
61+
b := MustParse(list[i+1]).SortableString()
62+
comp := cmp.Compare(a, b)
63+
if allowEqual {
64+
fmt.Printf("%s %s= %s\n", list[i], sign[comp], list[i+1])
65+
require.LessOrEqual(t, comp, 0)
66+
require.True(t, a <= b)
67+
require.False(t, a > b)
68+
} else {
69+
fmt.Printf("%s %s %s\n", list[i], sign[comp], list[i+1])
70+
require.Equal(t, comp, -1, "cmp(%s, %s) (%s, %s) must return '<', but returned '%s'", list[i], list[i+1], a, b, sign[comp])
71+
require.True(t, a < b)
72+
require.True(t, a <= b)
73+
require.False(t, a == b)
74+
require.False(t, a >= b)
75+
require.False(t, a > b)
76+
}
77+
78+
comp = cmp.Compare(b, a)
79+
fmt.Printf("%s %s %s\n", b, sign[comp], a)
80+
if allowEqual {
81+
require.GreaterOrEqual(t, comp, 0, "cmp(%s, %s) must return '>=', but returned '%s'", b, a, sign[comp])
82+
require.False(t, b < a)
83+
require.True(t, b >= a)
84+
} else {
85+
require.Equal(t, comp, 1)
86+
require.False(t, b < a)
87+
require.False(t, b <= a)
88+
require.False(t, b == a)
89+
require.True(t, b >= a)
90+
require.True(t, b > a)
91+
}
92+
}
5793
}
5894

5995
func TestVersionComparator(t *testing.T) {
@@ -124,6 +160,10 @@ func TestVersionComparator(t *testing.T) {
124160
"17.3.0-atmel3a.16.1-arduino7",
125161
"17.3.0-atmel3a.16.12-arduino7",
126162
"17.3.0-atmel3a.16.2-arduino7",
163+
"34.0.0",
164+
"51.0.0",
165+
"99.0.0",
166+
"123.0.0",
127167
)
128168
equal(
129169
MustParse(""),

0 commit comments

Comments
 (0)