mirror of
https://github.com/jimeh/rands.git
synced 2026-02-18 19:16:38 +00:00
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:
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
6
go.mod
6
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
|
||||
)
|
||||
|
||||
17
go.sum
17
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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
39
strings.go
39
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
//
|
||||
|
||||
24
uuid/random.go
Normal file
24
uuid/random.go
Normal 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
39
uuid/random_test.go
Normal 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
101
uuid/uuid.go
Normal 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
475
uuid/uuid_test.go
Normal 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
56
uuid/version_7.go
Normal 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
165
uuid/version_7_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user