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
This commit is contained in:
2025-02-28 02:16:32 +00:00
committed by GitHub
parent e87d9c4726
commit fe4308607c
16 changed files with 1077 additions and 36 deletions

View File

@@ -10,7 +10,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version-file: 'go.mod' go-version-file: "go.mod"
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@v6 uses: golangci/golangci-lint-action@v6
with: with:
@@ -25,7 +25,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version-file: 'go.mod' go-version-file: "go.mod"
- name: Check if mods are tidy - name: Check if mods are tidy
run: make check-tidy run: make check-tidy
@@ -37,7 +37,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version-file: 'go.mod' go-version-file: "go.mod"
- name: Run benchmarks - name: Run benchmarks
run: make bench | tee output.raw run: make bench | tee output.raw
- name: Fix benchmark names - name: Fix benchmark names
@@ -61,7 +61,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version-file: 'go.mod' go-version-file: "go.mod"
- name: Publish coverage - name: Publish coverage
uses: paambaati/codeclimate-action@v9 uses: paambaati/codeclimate-action@v9
env: env:
@@ -109,7 +109,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version-file: 'go.mod' go-version-file: "go.mod"
- name: Run benchmarks - name: Run benchmarks
run: make bench | tee output.raw run: make bench | tee output.raw
- name: Fix benchmark names - name: Fix benchmark names

View File

@@ -53,6 +53,7 @@ s, err := rands.UnicodeString(16, []rune("九七二人入八力十下三千上
s, err := rands.DNSLabel(16) // => z0ij9o8qkbs0ru-h s, err := rands.DNSLabel(16) // => z0ij9o8qkbs0ru-h
s, err := rands.UUID() // => a62b8712-f238-43ba-a47e-333f5fffe785 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.Int(2147483647) // => 1334400235
n, err := rands.Int64(int64(9223372036854775807)) // => 8256935979116161233 n, err := rands.Int64(int64(9223372036854775807)) // => 8256935979116161233
@@ -95,6 +96,7 @@ s := randsmust.UnicodeString(16, []rune("九七二人入八力十下三千上口
s := randsmust.DNSLabel(16) // => pu31o0gqyk76x35f s := randsmust.DNSLabel(16) // => pu31o0gqyk76x35f
s := randsmust.UUID() // => d616c873-f3dd-4690-bcd6-ed307eec1105 s := randsmust.UUID() // => d616c873-f3dd-4690-bcd6-ed307eec1105
s := randsmust.UUIDv7() // => 01954a30-add2-7590-8238-6cf6b2790c1e
n := randsmust.Int(2147483647) // => 1293388115 n := randsmust.Int(2147483647) // => 1293388115
n := randsmust.Int64(int64(9223372036854775807)) // => 6168113630900161239 n := randsmust.Int64(int64(9223372036854775807)) // => 6168113630900161239

6
go.mod
View File

@@ -2,10 +2,10 @@ module github.com/jimeh/rands
go 1.17 go 1.17
require github.com/stretchr/testify v1.7.0 require github.com/stretchr/testify v1.10.0
require ( 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 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
) )

17
go.sum
View File

@@ -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.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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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-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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -180,7 +180,7 @@ func DNSLabel(n int) string {
return r 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. // section 4.4.
func UUID() string { func UUID() string {
r, err := rands.UUID() r, err := rands.UUID()
@@ -190,3 +190,24 @@ func UUID() string {
return r 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
}

View File

@@ -75,3 +75,8 @@ func ExampleUUID() {
s := randsmust.UUID() s := randsmust.UUID()
fmt.Println(s) // => 5baa35a6-9a46-49b4-91d0-9530173e118d fmt.Println(s) // => 5baa35a6-9a46-49b4-91d0-9530173e118d
} }
func ExampleUUIDv7() {
s := randsmust.UUIDv7()
fmt.Println(s) // => 01954a3a-a06f-7186-8774-51a770503eb2
}

View File

@@ -6,6 +6,7 @@ import (
"regexp" "regexp"
"strings" "strings"
"testing" "testing"
"time"
"github.com/jimeh/rands" "github.com/jimeh/rands"
"github.com/stretchr/testify/assert" "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}$`, `^[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++ { for i := 0; i < 10000; i++ {
got := UUID() got := UUID()
require.Regexp(t, m, got) require.Regexp(t, m, got)
if _, ok := seen[got]; ok {
require.FailNow(t, "duplicate UUID")
}
seen[got] = struct{}{}
raw := strings.ReplaceAll(got, "-", "") raw := strings.ReplaceAll(got, "-", "")
b := make([]byte, 16) b := make([]byte, 16)
_, err := hex.Decode(b, []byte(raw)) _, err := hex.Decode(b, []byte(raw))
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, 4, int(b[6]>>4), "version is not 4") require.Equal(t, 4, int(b[6]>>4), "version is not 4")
require.Equal(t, byte(0x80), b[8]&0xc0, require.Equal(t, byte(0x80), b[8]&0xc0, "variant is not RFC 4122")
"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
} }
} }

View File

@@ -7,6 +7,8 @@ import (
"fmt" "fmt"
"math/big" "math/big"
"unicode" "unicode"
"github.com/jimeh/rands/uuid"
) )
const ( const (
@@ -18,7 +20,6 @@ const (
alphabeticChars = upperChars + lowerChars alphabeticChars = upperChars + lowerChars
alphanumericChars = alphabeticChars + numericChars alphanumericChars = alphabeticChars + numericChars
dnsLabelChars = lowerNumericChars + "-" dnsLabelChars = lowerNumericChars + "-"
uuidHyphen = byte('-')
) )
var ( 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, // UUID returns a random UUID v4 in string format as defined by RFC 4122,
// section 4.4. // section 4.4.
func UUID() (string, error) { func UUID() (string, error) {
b, err := Bytes(16) uuid, err := uuid.NewRandom()
if err != nil { if err != nil {
return "", err return "", err
} }
b[6] = (b[6] & 0x0f) | 0x40 // Version: 4 (random) return uuid.String(), nil
b[8] = (b[8] & 0x3f) | 0x80 // Variant: RFC 4122 }
// Construct a UUID v4 string according to RFC 4122 specifications. // UUIDv7 returns a time-ordered UUID v7 in string format.
dst := make([]byte, 36) //
hex.Encode(dst[0:8], b[0:4]) // time-low // The UUID v7 format uses a timestamp with millisecond precision in the most
dst[8] = uuidHyphen // significant bits, followed by random data. This provides both uniqueness and
hex.Encode(dst[9:13], b[4:6]) // time-mid // chronological ordering, making it ideal for database primary keys and
dst[13] = uuidHyphen // situations where sorting by creation time is desired.
hex.Encode(dst[14:18], b[6:8]) // time-high-and-version //
dst[18] = uuidHyphen // References:
hex.Encode(dst[19:23], b[8:10]) // clock-seq-and-reserved, clock-seq-low // - https://uuid7.com/
dst[23] = uuidHyphen // - https://www.ietf.org/archive/id/draft-peabody-dispatch-new-uuid-format-04.html#name-uuid-version-7
hex.Encode(dst[24:], b[10:]) // node //
//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 { func isASCII(s string) bool {

View File

@@ -132,3 +132,12 @@ func ExampleUUID() {
fmt.Println(s) // => 6a1c4f65-d5d6-4a28-aa51-eaa94fa7ad4a 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
}

View File

@@ -6,6 +6,7 @@ import (
"regexp" "regexp"
"strings" "strings"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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}$`, `^[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++ { for i := 0; i < 10000; i++ {
got, err := UUID() got, err := UUID()
require.NoError(t, err) require.NoError(t, err)
require.Regexp(t, m, got) require.Regexp(t, m, got)
if _, ok := seen[got]; ok {
require.FailNow(t, "duplicate UUID")
}
seen[got] = struct{}{}
raw := strings.ReplaceAll(got, "-", "") raw := strings.ReplaceAll(got, "-", "")
b := make([]byte, 16) b := make([]byte, 16)
_, err = hex.Decode(b, []byte(raw)) _, err = hex.Decode(b, []byte(raw))
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, 4, int(b[6]>>4), "version is not 4") require.Equal(t, 4, int(b[6]>>4), "version is not 4")
require.Equal(t, byte(0x80), b[8]&0xc0, require.Equal(t, byte(0x80), b[8]&0xc0, "variant is not RFC 4122")
"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 // Helpers
// //

24
uuid/random.go Normal file
View File

@@ -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
}

39
uuid/random_test.go Normal file
View File

@@ -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()
}
}

101
uuid/uuid.go Normal file
View File

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

475
uuid/uuid_test.go Normal file
View File

@@ -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()
}
}

56
uuid/version_7.go Normal file
View File

@@ -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()
}

165
uuid/version_7_test.go Normal file
View File

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