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) + } +}