From fe4308607cc8d454255908dc44e64462759e303d Mon Sep 17 00:00:00 2001 From: Jim Myhrberg Date: Fri, 28 Feb 2025 02:16:32 +0000 Subject: [PATCH] feat(strings/uuidv7): add UUIDv7 generation (#10) The UUID v7 format is a time-ordered random UUID. It uses a timestamp with millisecond precision in the most significant bits, followed by random data. This provides both uniqueness and chronological ordering, making it ideal for database primary keys and situations where sorting by creation time is desired. References: - https://uuid7.com/ - https://www.ietf.org/archive/id/draft-peabody-dispatch-new-uuid-format-04.html#name-uuid-version-7 --- .github/workflows/ci.yml | 10 +- README.md | 2 + go.mod | 6 +- go.sum | 17 +- randsmust/strings.go | 23 +- randsmust/strings_example_test.go | 5 + randsmust/strings_test.go | 67 ++++- strings.go | 39 ++- strings_example_test.go | 9 + strings_test.go | 75 ++++- uuid/random.go | 24 ++ uuid/random_test.go | 39 +++ uuid/uuid.go | 101 +++++++ uuid/uuid_test.go | 475 ++++++++++++++++++++++++++++++ uuid/version_7.go | 56 ++++ uuid/version_7_test.go | 165 +++++++++++ 16 files changed, 1077 insertions(+), 36 deletions(-) create mode 100644 uuid/random.go create mode 100644 uuid/random_test.go create mode 100644 uuid/uuid.go create mode 100644 uuid/uuid_test.go create mode 100644 uuid/version_7.go create mode 100644 uuid/version_7_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87b29f7..7321f6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version-file: 'go.mod' + go-version-file: "go.mod" - name: golangci-lint uses: golangci/golangci-lint-action@v6 with: @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version-file: 'go.mod' + go-version-file: "go.mod" - name: Check if mods are tidy run: make check-tidy @@ -37,7 +37,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version-file: 'go.mod' + go-version-file: "go.mod" - name: Run benchmarks run: make bench | tee output.raw - name: Fix benchmark names @@ -61,7 +61,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version-file: 'go.mod' + go-version-file: "go.mod" - name: Publish coverage uses: paambaati/codeclimate-action@v9 env: @@ -109,7 +109,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version-file: 'go.mod' + go-version-file: "go.mod" - name: Run benchmarks run: make bench | tee output.raw - name: Fix benchmark names diff --git a/README.md b/README.md index 17bed75..1ade00b 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ s, err := rands.UnicodeString(16, []rune("九七二人入八力十下三千上 s, err := rands.DNSLabel(16) // => z0ij9o8qkbs0ru-h s, err := rands.UUID() // => a62b8712-f238-43ba-a47e-333f5fffe785 +s, err := rands.UUIDv7() // => 01954a31-867f-7ffb-876e-b818f960ec3b n, err := rands.Int(2147483647) // => 1334400235 n, err := rands.Int64(int64(9223372036854775807)) // => 8256935979116161233 @@ -95,6 +96,7 @@ s := randsmust.UnicodeString(16, []rune("九七二人入八力十下三千上口 s := randsmust.DNSLabel(16) // => pu31o0gqyk76x35f s := randsmust.UUID() // => d616c873-f3dd-4690-bcd6-ed307eec1105 +s := randsmust.UUIDv7() // => 01954a30-add2-7590-8238-6cf6b2790c1e n := randsmust.Int(2147483647) // => 1293388115 n := randsmust.Int64(int64(9223372036854775807)) // => 6168113630900161239 diff --git a/go.mod b/go.mod index f9183da..921ca0c 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,10 @@ module github.com/jimeh/rands go 1.17 -require github.com/stretchr/testify v1.7.0 +require github.com/stretchr/testify v1.10.0 require ( - github.com/davecgh/go-spew v1.1.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - gopkg.in/yaml.v3 v3.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index e6fb240..3ebac7b 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,19 @@ -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= -gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/randsmust/strings.go b/randsmust/strings.go index 7c354eb..8e7d4a3 100644 --- a/randsmust/strings.go +++ b/randsmust/strings.go @@ -180,7 +180,7 @@ func DNSLabel(n int) string { return r } -// UUID returns a random UUID v4 in string format as defined by RFC 4122, +// UUIDv4 returns a random UUID v4 in string format as defined by RFC 4122, // section 4.4. func UUID() string { r, err := rands.UUID() @@ -190,3 +190,24 @@ func UUID() string { return r } + +// UUIDv7 returns a time-ordered UUID v7 in string format. +// +// The UUID v7 format uses a timestamp with millisecond precision in the most +// significant bits, followed by random data. This provides both uniqueness and +// chronological ordering, making it ideal for database primary keys and +// situations where sorting by creation time is desired. +// +// References: +// - https://uuid7.com/ +// - https://www.ietf.org/archive/id/draft-peabody-dispatch-new-uuid-format-04.html#name-uuid-version-7 +// +//nolint:lll +func UUIDv7() string { + r, err := rands.UUIDv7() + if err != nil { + panic(err) + } + + return r +} diff --git a/randsmust/strings_example_test.go b/randsmust/strings_example_test.go index 01e78fd..041067f 100644 --- a/randsmust/strings_example_test.go +++ b/randsmust/strings_example_test.go @@ -75,3 +75,8 @@ func ExampleUUID() { s := randsmust.UUID() fmt.Println(s) // => 5baa35a6-9a46-49b4-91d0-9530173e118d } + +func ExampleUUIDv7() { + s := randsmust.UUIDv7() + fmt.Println(s) // => 01954a3a-a06f-7186-8774-51a770503eb2 +} diff --git a/randsmust/strings_test.go b/randsmust/strings_test.go index cebaafe..508bbaf 100644 --- a/randsmust/strings_test.go +++ b/randsmust/strings_test.go @@ -6,6 +6,7 @@ import ( "regexp" "strings" "testing" + "time" "github.com/jimeh/rands" "github.com/stretchr/testify/assert" @@ -487,19 +488,79 @@ func TestUUID(t *testing.T) { `^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`, ) + seen := make(map[string]struct{}) + for i := 0; i < 10000; i++ { got := UUID() require.Regexp(t, m, got) + if _, ok := seen[got]; ok { + require.FailNow(t, "duplicate UUID") + } + + seen[got] = struct{}{} + raw := strings.ReplaceAll(got, "-", "") b := make([]byte, 16) _, err := hex.Decode(b, []byte(raw)) require.NoError(t, err) require.Equal(t, 4, int(b[6]>>4), "version is not 4") - require.Equal(t, byte(0x80), b[8]&0xc0, - "variant is not RFC 4122", - ) + require.Equal(t, byte(0x80), b[8]&0xc0, "variant is not RFC 4122") + } +} + +func TestUUIDv7(t *testing.T) { + t.Parallel() + + m := regexp.MustCompile( + `^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`, + ) + + // Store timestamps to verify they're increasing + var lastTimestampBytes int64 + var lastUUID string + + seen := make(map[string]struct{}) + for i := 0; i < 10000; i++ { + got := UUIDv7() + require.Regexp(t, m, got) + + if _, ok := seen[got]; ok { + require.FailNow(t, "duplicate UUID") + } + + seen[got] = struct{}{} + + raw := strings.ReplaceAll(got, "-", "") + b := make([]byte, 16) + _, err := hex.Decode(b, []byte(raw)) + require.NoError(t, err) + + // Check version is 7 + require.Equal(t, 7, int(b[6]>>4), "version is not 7") + + // Check variant is RFC 4122 + require.Equal(t, byte(0x80), b[8]&0xc0, "variant is not RFC 4122") + + // Extract timestamp bytes + timestampBytes := int64(b[0])<<40 | int64(b[1])<<32 | int64(b[2])<<24 | + int64(b[3])<<16 | int64(b[4])<<8 | int64(b[5]) + + // Verify timestamp is within 100 milliseconds of current time + tsTime := time.UnixMilli(timestampBytes) + require.WithinDuration(t, time.Now(), tsTime, 100*time.Millisecond, + "timestamp is not within 100 milliseconds of current time") + + // After the first UUID, verify that UUIDs are monotonically increasing + if i > 0 && timestampBytes < lastTimestampBytes { + require.FailNow(t, "UUIDs are not monotonically increasing", + "current: %s (ts: %d), previous: %s (ts: %d)", + got, timestampBytes, lastUUID, lastTimestampBytes) + } + + lastTimestampBytes = timestampBytes + lastUUID = got } } diff --git a/strings.go b/strings.go index 03ca738..cc49755 100644 --- a/strings.go +++ b/strings.go @@ -7,6 +7,8 @@ import ( "fmt" "math/big" "unicode" + + "github.com/jimeh/rands/uuid" ) const ( @@ -18,7 +20,6 @@ const ( alphabeticChars = upperChars + lowerChars alphanumericChars = alphabeticChars + numericChars dnsLabelChars = lowerNumericChars + "-" - uuidHyphen = byte('-') ) var ( @@ -237,27 +238,33 @@ func DNSLabel(n int) (string, error) { // UUID returns a random UUID v4 in string format as defined by RFC 4122, // section 4.4. func UUID() (string, error) { - b, err := Bytes(16) + uuid, err := uuid.NewRandom() if err != nil { return "", err } - b[6] = (b[6] & 0x0f) | 0x40 // Version: 4 (random) - b[8] = (b[8] & 0x3f) | 0x80 // Variant: RFC 4122 + return uuid.String(), nil +} - // Construct a UUID v4 string according to RFC 4122 specifications. - dst := make([]byte, 36) - hex.Encode(dst[0:8], b[0:4]) // time-low - dst[8] = uuidHyphen - hex.Encode(dst[9:13], b[4:6]) // time-mid - dst[13] = uuidHyphen - hex.Encode(dst[14:18], b[6:8]) // time-high-and-version - dst[18] = uuidHyphen - hex.Encode(dst[19:23], b[8:10]) // clock-seq-and-reserved, clock-seq-low - dst[23] = uuidHyphen - hex.Encode(dst[24:], b[10:]) // node +// UUIDv7 returns a time-ordered UUID v7 in string format. +// +// The UUID v7 format uses a timestamp with millisecond precision in the most +// significant bits, followed by random data. This provides both uniqueness and +// chronological ordering, making it ideal for database primary keys and +// situations where sorting by creation time is desired. +// +// References: +// - https://uuid7.com/ +// - https://www.ietf.org/archive/id/draft-peabody-dispatch-new-uuid-format-04.html#name-uuid-version-7 +// +//nolint:lll +func UUIDv7() (string, error) { + uuid, err := uuid.NewV7() + if err != nil { + return "", err + } - return string(dst), nil + return uuid.String(), nil } func isASCII(s string) bool { diff --git a/strings_example_test.go b/strings_example_test.go index 110186f..3d8f658 100644 --- a/strings_example_test.go +++ b/strings_example_test.go @@ -132,3 +132,12 @@ func ExampleUUID() { fmt.Println(s) // => 6a1c4f65-d5d6-4a28-aa51-eaa94fa7ad4a } + +func ExampleUUIDv7() { + s, err := rands.UUIDv7() + if err != nil { + log.Fatal(err) + } + + fmt.Println(s) // => 01954a3a-a06f-7848-b836-bced92ae5a1a +} diff --git a/strings_test.go b/strings_test.go index 64651dd..d7eefe6 100644 --- a/strings_test.go +++ b/strings_test.go @@ -6,6 +6,7 @@ import ( "regexp" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -612,20 +613,26 @@ func TestUUID(t *testing.T) { `^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`, ) + seen := make(map[string]struct{}) + for i := 0; i < 10000; i++ { got, err := UUID() require.NoError(t, err) require.Regexp(t, m, got) + if _, ok := seen[got]; ok { + require.FailNow(t, "duplicate UUID") + } + + seen[got] = struct{}{} + raw := strings.ReplaceAll(got, "-", "") b := make([]byte, 16) _, err = hex.Decode(b, []byte(raw)) require.NoError(t, err) require.Equal(t, 4, int(b[6]>>4), "version is not 4") - require.Equal(t, byte(0x80), b[8]&0xc0, - "variant is not RFC 4122", - ) + require.Equal(t, byte(0x80), b[8]&0xc0, "variant is not RFC 4122") } } @@ -635,6 +642,68 @@ func BenchmarkUUID(b *testing.B) { } } +func TestUUIDv7(t *testing.T) { + t.Parallel() + + m := regexp.MustCompile( + `^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`, + ) + + // Store timestamps to verify they're increasing + var lastTimestampBytes int64 + var lastUUID string + + seen := make(map[string]struct{}) + for i := 0; i < 10000; i++ { + got, err := UUIDv7() + require.NoError(t, err) + require.Regexp(t, m, got) + + if _, ok := seen[got]; ok { + require.FailNow(t, "duplicate UUID") + } + + seen[got] = struct{}{} + + raw := strings.ReplaceAll(got, "-", "") + b := make([]byte, 16) + _, err = hex.Decode(b, []byte(raw)) + require.NoError(t, err) + + // Check version is 7 + require.Equal(t, 7, int(b[6]>>4), "version is not 7") + + // Check variant is RFC 4122 + require.Equal(t, byte(0x80), b[8]&0xc0, "variant is not RFC 4122") + + // Extract timestamp bytes + timestampBytes := int64(b[0])<<40 | int64(b[1])<<32 | + int64(b[2])<<24 | int64(b[3])<<16 | int64(b[4])<<8 | int64(b[5]) + + // Verify timestamp is within 100 milliseconds of current time + tsTime := time.UnixMilli(timestampBytes) + require.WithinDuration(t, time.Now(), tsTime, 100*time.Millisecond, + "timestamp is not within 100 milliseconds of current time", + ) + + // After the first UUID, verify that UUIDs are monotonically increasing + if i > 0 && timestampBytes < lastTimestampBytes { + require.FailNow(t, "UUIDs are not monotonically increasing", + "current: %s (ts: %d), previous: %s (ts: %d)", + got, timestampBytes, lastUUID, lastTimestampBytes) + } + + lastTimestampBytes = timestampBytes + lastUUID = got + } +} + +func BenchmarkUUIDv7(b *testing.B) { + for n := 0; n < b.N; n++ { + _, _ = UUIDv7() + } +} + // // Helpers // diff --git a/uuid/random.go b/uuid/random.go new file mode 100644 index 0000000..e3199f2 --- /dev/null +++ b/uuid/random.go @@ -0,0 +1,24 @@ +package uuid + +import ( + "crypto/rand" +) + +// NewRandom returns a random UUID v4 in string format as defined by RFC 4122, +// section 4.4. +func NewRandom() (UUID, error) { + var u UUID + + // Fill the entire UUID with random bytes. + _, err := rand.Read(u[:]) + if err != nil { + // This should never happen. + return u, err + } + + // Set the version and variant bits. + u[6] = (u[6] & 0x0f) | 0x40 // Version: 4 (random) + u[8] = (u[8] & 0x3f) | 0x80 // Variant: RFC 4122 + + return u, nil +} diff --git a/uuid/random_test.go b/uuid/random_test.go new file mode 100644 index 0000000..d18e1a7 --- /dev/null +++ b/uuid/random_test.go @@ -0,0 +1,39 @@ +package uuid + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewRandom(t *testing.T) { + t.Parallel() + + m := regexp.MustCompile( + `^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`, + ) + + seen := make(map[string]struct{}) + + for i := 0; i < 10000; i++ { + got, err := NewRandom() + require.NoError(t, err) + require.Regexp(t, m, got.String()) + + if _, ok := seen[got.String()]; ok { + require.FailNow(t, "duplicate UUID") + } + + seen[got.String()] = struct{}{} + + require.Equal(t, 4, int(got[6]>>4), "version is not 4") + require.Equal(t, byte(0x80), got[8]&0xc0, "variant is not RFC 4122") + } +} + +func BenchmarkNewRandom(b *testing.B) { + for n := 0; n < b.N; n++ { + _, _ = NewRandom() + } +} diff --git a/uuid/uuid.go b/uuid/uuid.go new file mode 100644 index 0000000..79f2418 --- /dev/null +++ b/uuid/uuid.go @@ -0,0 +1,101 @@ +// Package uuid provides a UUID type and associated utilities. +package uuid + +import ( + "encoding/hex" + "errors" + "fmt" + "math" + "strings" + "time" +) + +var ( + Err = errors.New("uuid") + ErrInvalidLength = fmt.Errorf("%w: invalid length", Err) +) + +const ( + hyphen = '-' +) + +// UUID represents a Universally Unique Identifier (UUID). +// It is implemented as a 16-byte array. +type UUID [16]byte + +// String returns the string representation of the UUID, +// formatted according to RFC 4122 (8-4-4-4-12 hex digits separated by hyphens). +func (u UUID) String() string { + dst := make([]byte, 36) + + hex.Encode(dst[0:8], u[0:4]) + dst[8] = hyphen + hex.Encode(dst[9:13], u[4:6]) + dst[13] = hyphen + hex.Encode(dst[14:18], u[6:8]) + dst[18] = hyphen + hex.Encode(dst[19:23], u[8:10]) + dst[23] = hyphen + hex.Encode(dst[24:], u[10:]) + + return string(dst) +} + +// FromBytes creates a UUID from a byte slice. +// +// If the slice isn't exactly 16 bytes, it returns an empty UUID. +func FromBytes(b []byte) (UUID, error) { + var u UUID + if len(b) != 16 { + return u, ErrInvalidLength + } + + copy(u[:], b) + + return u, nil +} + +// FromString creates a UUID from a string. +// +// If the string isn't exactly 36 characters, it returns an empty UUID. +func FromString(s string) (UUID, error) { + if len(s) != 36 { + return UUID{}, ErrInvalidLength + } + + raw := strings.ReplaceAll(s, "-", "") + + u := UUID{} + _, err := hex.Decode(u[:], []byte(raw)) + if err != nil { + return UUID{}, err + } + + return u, nil +} + +// Time returns the timestamp of the UUID if it's a version 7 (time-ordered) +// UUID. Otherwise, it returns the zero time. +func (u UUID) Time() (t time.Time, ok bool) { + if u.Version() != 7 { + return time.Time{}, false + } + + // Extract the timestamp from the UUID. + // For UUIDv7, only the first 6 bytes contain the timestamp in milliseconds + timestamp := uint64(u[0])<<40 | uint64(u[1])<<32 | uint64(u[2])<<24 | + uint64(u[3])<<16 | uint64(u[4])<<8 | uint64(u[5]) + + if timestamp > math.MaxInt64 { + // This shouldn't happen until year 292,272,993. + return time.Time{}, false + } + + return time.UnixMilli(int64(timestamp)), true +} + +// Version returns the version of the UUID. +func (u UUID) Version() int { + // The version is stored in the 4 most significant bits of byte 6 + return int(u[6] >> 4) +} diff --git a/uuid/uuid_test.go b/uuid/uuid_test.go new file mode 100644 index 0000000..ed816b4 --- /dev/null +++ b/uuid/uuid_test.go @@ -0,0 +1,475 @@ +package uuid + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUUID_String(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + uuid UUID + expected string + }{ + { + name: "Zero UUID", + uuid: UUID{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + expected: "00000000-0000-0000-0000-000000000000", + }, + { + name: "Random UUID", + uuid: UUID{ + 0x12, 0x34, 0x56, 0x78, + 0x9a, 0xbc, + 0xde, 0xf0, + 0x12, 0x34, + 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, + }, + expected: "12345678-9abc-def0-1234-56789abcdef0", + }, + { + name: "UUID with max values", + uuid: UUID{ + 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, + 0xff, 0xff, + 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + }, + expected: "ffffffff-ffff-ffff-ffff-ffffffffffff", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.uuid.String() + assert.Equal(t, tt.expected, got) + }) + } +} + +func BenchmarkUUID_String(b *testing.B) { + u := UUID{ + 0x12, 0x34, 0x56, 0x78, + 0x9a, 0xbc, + 0xde, 0xf0, + 0x12, 0x34, + 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = u.String() + } +} + +func TestFromBytes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + bytes []byte + want UUID + wantErr error + }{ + { + name: "Valid 16 bytes", + bytes: []byte{ + 0x12, + 0x34, + 0x56, + 0x78, + 0x9a, + 0xbc, + 0xde, + 0xf0, + 0x12, + 0x34, + 0x56, + 0x78, + 0x9a, + 0xbc, + 0xde, + 0xf0, + }, + want: UUID{ + 0x12, 0x34, 0x56, 0x78, + 0x9a, 0xbc, + 0xde, 0xf0, + 0x12, 0x34, + 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, + }, + wantErr: nil, + }, + { + name: "Empty bytes", + bytes: []byte{}, + want: UUID{}, + wantErr: ErrInvalidLength, + }, + { + name: "Too few bytes", + bytes: []byte{0x12, 0x34, 0x56, 0x78, 0x9a}, + want: UUID{}, + wantErr: ErrInvalidLength, + }, + { + name: "Too many bytes", + bytes: []byte{ + 0x12, + 0x34, + 0x56, + 0x78, + 0x9a, + 0xbc, + 0xde, + 0xf0, + 0x12, + 0x34, + 0x56, + 0x78, + 0x9a, + 0xbc, + 0xde, + 0xf0, + 0xab, + }, + want: UUID{}, + wantErr: ErrInvalidLength, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := FromBytes(tt.bytes) + + if tt.wantErr != nil { + assert.EqualError(t, err, tt.wantErr.Error()) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, tt.want, got) + }) + } +} + +func BenchmarkFromBytes(b *testing.B) { + bytes := []byte{ + 0x12, 0x34, 0x56, 0x78, + 0x9a, 0xbc, + 0xde, 0xf0, + 0x12, 0x34, + 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = FromBytes(bytes) + } +} + +func TestFromString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + str string + want UUID + wantErr error + }{ + { + name: "Valid UUID string", + str: "12345678-9abc-def0-1234-56789abcdef0", + want: UUID{ + 0x12, 0x34, 0x56, 0x78, + 0x9a, 0xbc, + 0xde, 0xf0, + 0x12, 0x34, + 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, + }, + wantErr: nil, + }, + { + name: "Empty string", + str: "", + want: UUID{}, + wantErr: ErrInvalidLength, + }, + { + name: "Too short string", + str: "12345678-9abc-def0-1234-56789abcde", + want: UUID{}, + wantErr: ErrInvalidLength, + }, + { + name: "Too long string", + str: "12345678-9abc-def0-1234-56789abcdef0a", + want: UUID{}, + wantErr: ErrInvalidLength, + }, + { + name: "Invalid characters", + str: "12345678-9abc-defg-1234-56789abcdef0", + want: UUID{}, + wantErr: errors.New("encoding/hex: invalid byte: U+0067 'g'"), + }, + { + name: "Zero UUID", + str: "00000000-0000-0000-0000-000000000000", + want: UUID{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + wantErr: nil, + }, + { + name: "Max value UUID", + str: "ffffffff-ffff-ffff-ffff-ffffffffffff", + want: UUID{ + 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, + 0xff, 0xff, + 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + }, + wantErr: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := FromString(tt.str) + + if tt.wantErr != nil { + assert.EqualError(t, err, tt.wantErr.Error()) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, tt.want, got) + }) + } +} + +func BenchmarkFromString(b *testing.B) { + uuidStr := "12345678-9abc-def0-1234-56789abcdef0" + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = FromString(uuidStr) + } +} + +func TestUUID_Time(t *testing.T) { + t.Parallel() + + // Define a reference time for testing. + refTime := time.Date(2023, 5, 15, 12, 0, 0, 0, time.UTC) + // The timestamp in milliseconds (Unix timestamp * 1000 in 6 bytes). + timestampMillis := refTime.UnixMilli() + + // Create bytes for the timestamp (first 6 bytes of UUID). + timestampBytes := []byte{ + byte(timestampMillis >> 40), + byte(timestampMillis >> 32), + byte(timestampMillis >> 24), + byte(timestampMillis >> 16), + byte(timestampMillis >> 8), + byte(timestampMillis), + } + + tests := []struct { + name string + uuid UUID + wantTime time.Time + wantOk bool + }{ + { + name: "Version 7 UUID", + uuid: func() UUID { + var u UUID + // Set first 6 bytes to timestamp. + copy(u[:6], timestampBytes) + // Set version to 7 (0111 as the high nibble of byte 6). + u[6] = (u[6] & 0x0F) | 0x70 + // Set variant to RFC 4122 (10xx as the high bits of byte 8). + u[8] = (u[8] & 0x3F) | 0x80 + + return u + }(), + wantTime: refTime, + wantOk: true, + }, + { + name: "Version 4 UUID (not time-based)", + uuid: func() UUID { + var u UUID + // Set first 6 bytes to same timestamp to verify it's ignored. + copy(u[:6], timestampBytes) + // Set version to 4 (0100 as the high nibble of byte 6). + u[6] = (u[6] & 0x0F) | 0x40 + + return u + }(), + wantTime: time.Time{}, // Zero time for non-V7 UUIDs + wantOk: false, + }, + { + name: "Zero UUID", + uuid: UUID{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + wantTime: time.Time{}, // Zero time for version 0 + wantOk: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotTime, gotOk := tt.uuid.Time() + + assert.Equal(t, tt.wantOk, gotOk) + + if tt.wantTime.IsZero() { + assert.True(t, gotTime.IsZero()) + } else { + // Compare time at millisecond precision. + assert.Equal(t, tt.wantTime.UnixMilli(), gotTime.UnixMilli()) + } + }) + } +} + +func BenchmarkUUID_Time(b *testing.B) { + uuid, err := NewV7() + require.NoError(b, err) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = uuid.Time() + } +} + +func TestUUID_Version(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + uuid UUID + want int + }{ + { + name: "Version 0 (invalid/nil UUID)", + uuid: UUID{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + want: 0, + }, + { + name: "Version 1 (time-based)", + uuid: UUID{ + 0x12, 0x34, 0x56, 0x78, + 0x9a, 0xbc, + 0x10, 0xf0, // 0x10 = version 1 in top nibble + 0x12, 0x34, + 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, + }, + want: 1, + }, + { + name: "Version 2 (DCE Security)", + uuid: UUID{ + 0x12, 0x34, 0x56, 0x78, + 0x9a, 0xbc, + 0x20, 0xf0, // 0x20 = version 2 in top nibble + 0x12, 0x34, + 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, + }, + want: 2, + }, + { + name: "Version 3 (name-based, MD5)", + uuid: UUID{ + 0x12, 0x34, 0x56, 0x78, + 0x9a, 0xbc, + 0x30, 0xf0, // 0x30 = version 3 in top nibble + 0x12, 0x34, + 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, + }, + want: 3, + }, + { + name: "Version 4 (random)", + uuid: UUID{ + 0x12, 0x34, 0x56, 0x78, + 0x9a, 0xbc, + 0x40, 0xf0, // 0x40 = version 4 in top nibble + 0x12, 0x34, + 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, + }, + want: 4, + }, + { + name: "Version 5 (name-based, SHA-1)", + uuid: UUID{ + 0x12, 0x34, 0x56, 0x78, + 0x9a, 0xbc, + 0x50, 0xf0, // 0x50 = version 5 in top nibble + 0x12, 0x34, + 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, + }, + want: 5, + }, + { + name: "Version 7 (time-ordered)", + uuid: UUID{ + 0x12, 0x34, 0x56, 0x78, + 0x9a, 0xbc, + 0x70, 0xf0, // 0x70 = version 7 in top nibble + 0x12, 0x34, + 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, + }, + want: 7, + }, + { + name: "Version 8 (custom)", + uuid: UUID{ + 0x12, 0x34, 0x56, 0x78, + 0x9a, 0xbc, + 0x80, 0xf0, // 0x80 = version 8 in top nibble + 0x12, 0x34, + 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, + }, + want: 8, + }, + { + name: "Version 15 (theoretical max)", + uuid: UUID{ + 0x12, 0x34, 0x56, 0x78, + 0x9a, 0xbc, + 0xf0, 0xf0, // 0xf0 = version 15 in top nibble + 0x12, 0x34, + 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, + }, + want: 15, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.uuid.Version() + + assert.Equal(t, tt.want, got) + }) + } +} + +func BenchmarkUUID_Version(b *testing.B) { + uuid, err := NewV7() + require.NoError(b, err) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = uuid.Version() + } +} diff --git a/uuid/version_7.go b/uuid/version_7.go new file mode 100644 index 0000000..d653d79 --- /dev/null +++ b/uuid/version_7.go @@ -0,0 +1,56 @@ +package uuid + +import ( + "crypto/rand" + "time" +) + +// NewV7 returns a time-ordered UUID v7 in string format. +// +// The UUID v7 format uses a timestamp with millisecond precision in the most +// significant bits, followed by random data. This provides both uniqueness and +// chronological ordering, making it ideal for database primary keys and +// situations where sorting by creation time is desired. +// +// References: +// - https://uuid7.com/ +// - https://www.ietf.org/archive/id/draft-peabody-dispatch-new-uuid-format-04.html#name-uuid-version-7 +// +//nolint:lll +func NewV7() (UUID, error) { + var u UUID + + // Write the timestamp to the first 6 bytes of the UUID. + timestamp := time.Now().UnixMilli() + u[0] = byte(timestamp >> 40) + u[1] = byte(timestamp >> 32) + u[2] = byte(timestamp >> 24) + u[3] = byte(timestamp >> 16) + u[4] = byte(timestamp >> 8) + u[5] = byte(timestamp) + + // Fill the remaining bytes with random data. + _, err := rand.Read(u[6:]) + if err != nil { + // This should never happen. + return u, err + } + + // Set the version and variant bits. + u[6] = (u[6] & 0x0f) | 0x70 // Version: 7 (time-ordered) + u[8] = (u[8] & 0x3f) | 0x80 // Variant: RFC 4122 + + return u, nil +} + +// V7Time returns the time of a UUIDv7 string. +// +// If the UUID is not a valid UUIDv7, it returns a zero time and false. +func V7Time(s string) (t time.Time, ok bool) { + u, err := FromString(s) + if err != nil { + return time.Time{}, false + } + + return u.Time() +} diff --git a/uuid/version_7_test.go b/uuid/version_7_test.go new file mode 100644 index 0000000..e0af351 --- /dev/null +++ b/uuid/version_7_test.go @@ -0,0 +1,165 @@ +package uuid + +import ( + "regexp" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUUIDv7(t *testing.T) { + t.Parallel() + + m := regexp.MustCompile( + `^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`, + ) + + // Store timestamps to verify they're increasing. + var lastTimestampBytes int64 + var lastUUID string + + seen := make(map[string]struct{}) + for i := 0; i < 10000; i++ { + got, err := NewV7() + require.NoError(t, err) + require.Regexp(t, m, got.String()) + + if _, ok := seen[got.String()]; ok { + require.FailNow(t, "duplicate UUID") + } + + seen[got.String()] = struct{}{} + + // Check version is 7. + require.Equal(t, 7, int(got[6]>>4), "version is not 7") + + // Check variant is RFC 4122. + require.Equal(t, byte(0x80), got[8]&0xc0, "variant is not RFC 4122") + + // Extract timestamp bytes. + timestampBytes := int64(got[0])<<40 | int64(got[1])<<32 | + int64(got[2])<<24 | int64(got[3])<<16 | int64(got[4])<<8 | + int64(got[5]) + + // Verify timestamp is within 100 milliseconds of current time. + tsTime := time.UnixMilli(timestampBytes) + require.WithinDuration(t, time.Now(), tsTime, 100*time.Millisecond, + "timestamp is not within 100 milliseconds of current time") + + // After the first UUID, verify that UUIDs are monotonically increasing + if i > 0 && timestampBytes < lastTimestampBytes { + require.FailNow(t, "UUIDs are not monotonically increasing", + "current: %s (ts: %d), previous: %s (ts: %d)", + got, timestampBytes, lastUUID, lastTimestampBytes) + } + + lastTimestampBytes = timestampBytes + lastUUID = got.String() + } +} + +func BenchmarkNewV7(b *testing.B) { + for n := 0; n < b.N; n++ { + _, _ = NewV7() + } +} + +func TestV7Time(t *testing.T) { + t.Parallel() + + // Define a reference time for testing. + refTime := time.Date(2023, 5, 15, 12, 0, 0, 0, time.UTC) + // The timestamp in milliseconds. + timestampMillis := refTime.UnixMilli() + + // Create bytes for the timestamp (first 6 bytes of UUID). + timestampBytes := []byte{ + byte(timestampMillis >> 40), + byte(timestampMillis >> 32), + byte(timestampMillis >> 24), + byte(timestampMillis >> 16), + byte(timestampMillis >> 8), + byte(timestampMillis), + } + + tests := []struct { + name string + uuidStr string + wantTime time.Time + wantOk bool + }{ + { + name: "Version 7 UUID", + uuidStr: func() string { + var u UUID + // Set first 6 bytes to timestamp. + copy(u[:6], timestampBytes) + // Set version to 7 (0111 as the high nibble of byte 6). + u[6] = (u[6] & 0x0F) | 0x70 + // Set variant to RFC 4122 (10xx as the high bits of byte 8). + u[8] = (u[8] & 0x3F) | 0x80 + + return u.String() + }(), + wantTime: refTime, + wantOk: true, + }, + { + name: "Version 4 UUID (not time-based)", + uuidStr: func() string { + var u UUID + // Set first 6 bytes to same timestamp to verify it's ignored. + copy(u[:6], timestampBytes) + // Set version to 4 (0100 as the high nibble of byte 6). + u[6] = (u[6] & 0x0F) | 0x40 + // Set variant to RFC 4122 (10xx as the high bits of byte 8). + u[8] = (u[8] & 0x3F) | 0x80 + + return u.String() + }(), + wantTime: time.Time{}, // Zero time for non-V7 UUIDs + wantOk: false, + }, + { + name: "Zero UUID", + uuidStr: "00000000-0000-0000-0000-000000000000", + wantTime: time.Time{}, // Zero time for version 0 + wantOk: false, + }, + { + name: "Invalid UUID string", + uuidStr: "not-a-valid-uuid", + wantTime: time.Time{}, + wantOk: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotTime, gotOk := V7Time(tt.uuidStr) + + assert.Equal(t, tt.wantOk, gotOk) + + if tt.wantTime.IsZero() { + assert.True(t, gotTime.IsZero()) + } else { + // Compare time at millisecond precision. + assert.Equal(t, tt.wantTime.UnixMilli(), gotTime.UnixMilli()) + } + }) + } +} + +func BenchmarkV7Time(b *testing.B) { + u, err := NewV7() + require.NoError(b, err) + + s := u.String() + + b.ResetTimer() + for n := 0; n < b.N; n++ { + _, _ = V7Time(s) + } +}