13 Commits
v0.3.0 ... main

Author SHA1 Message Date
jimehbot[bot]
c98b79b4c5 chore(main): release 0.5.0 (#12)
Co-authored-by: jimehbot[bot] <132453784+jimehbot[bot]@users.noreply.github.com>
2025-03-11 00:06:02 +00:00
b28e506295 docs(readme): fix typo Shuffle code example 2025-03-10 23:52:37 +00:00
a141938394 feat(shuffle)!: add Shuffle and ShuffleSlice functions (#11)
Add equivalent to `Shuffle` function from `math/rand` and `math/rand/v2`
packages, but based on randomness from `crypto/rand` package. This
allows cryptographically secure shuffling of data.

Also add `ShuffleSlice` function that shuffles a slice of any type.

BREAKING CHANGE: Minimum required Go version is now 1.18 due the `ShuffleSlice` using generics.
2025-03-10 23:50:25 +00:00
jimehbot[bot]
312e856234 chore(main): release 0.4.0 (#6)
Co-authored-by: jimehbot[bot] <132453784+jimehbot[bot]@users.noreply.github.com>
2025-03-07 01:35:31 +00:00
f4dccc6e46 test(uuid): fix flakiness on slower machines 2025-03-07 01:16:39 +00:00
ec94efe49e ci(deps): upgrade release-action action 2025-03-07 01:15:50 +00:00
fe4308607c 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
2025-02-28 02:16:32 +00:00
e87d9c4726 docs(readme): fix badges (#9) 2025-02-28 01:31:43 +00:00
127ebbaa03 ci(benchmarks): set correct permissions for benchmarks job (#8) 2025-02-28 01:24:52 +00:00
dependabot[bot]
41227bd53d chore(deps): bump gopkg.in/yaml.v3 (#7)
Bumps gopkg.in/yaml.v3 from 3.0.0-20200313102051-9f266ea9e77c to 3.0.0.

---
updated-dependencies:
- dependency-name: gopkg.in/yaml.v3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-28 00:52:00 +00:00
16bd3ea3b9 feat(deps)!: upgrade to Go 1.17 and golanci-lint 1.64 (#5)
Upgrade to Go 1.17 and golangci-lint to 1.64, and fix the linting issues
that were found.

Also upgrade CI workflow actions to latest versions, and setup a test
matrix for relevant Go versions.

BREAKING CHANGE: Minimum Go version changed from 1.15 to 1.17.
2025-02-28 00:42:28 +00:00
8da5e1ef80 ci(release): setup release-please for managing releases and changelog (#4) 2025-02-28 00:24:59 +00:00
1b0bb32a3e docs(readme): fix links to randsmust package 2021-12-17 12:51:54 +00:00
29 changed files with 2264 additions and 175 deletions

3
.github/.release-please-manifest.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
".": "0.5.0"
}

15
.github/release-please-config.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
"bootstrap-sha": "660fc4d179bfe22826230988407fff810e3b8568",
"packages": {
".": {
"release-type": "go",
"changelog-path": "CHANGELOG.md",
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": true,
"always-update": true,
"draft": false,
"prerelease": false
}
},
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
}

View File

@@ -7,11 +7,14 @@ jobs:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: golangci-lint
uses: golangci/golangci-lint-action@v2
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
version: v1.43
go-version-file: "go.mod"
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: v1.64
env:
VERBOSE: "true"
@@ -19,34 +22,22 @@ jobs:
name: Tidy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 1.15
- uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
go-version-file: "go.mod"
- name: Check if mods are tidy
run: make check-tidy
benchmark:
name: Benchmarks
runs-on: ubuntu-latest
if: github.ref != 'refs/heads/master'
if: github.ref != 'refs/heads/main'
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 1.15
- uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
go-version-file: "go.mod"
- name: Run benchmarks
run: make bench | tee output.raw
- name: Fix benchmark names
@@ -67,18 +58,12 @@ jobs:
name: Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 1.15
- uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
go-version-file: "go.mod"
- name: Publish coverage
uses: paambaati/codeclimate-action@v2.7.4
uses: paambaati/codeclimate-action@v9
env:
VERBOSE: "true"
GOMAXPROCS: 4
@@ -92,17 +77,23 @@ jobs:
test:
name: Test
runs-on: ubuntu-latest
strategy:
matrix:
go-version:
- "1.18"
- "1.19"
- "1.20"
- "1.21"
- "1.22"
- "1.23"
- "1.24"
- "stable"
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 1.15
- uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
go-version: ${{ matrix.go-version }}
check-latest: true
- name: Run tests
run: make test
env:
@@ -111,18 +102,15 @@ jobs:
benchmark-store:
name: Store benchmarks
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/master'
if: github.ref == 'refs/heads/main'
permissions:
deployments: write
contents: write
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 1.15
- uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
go-version-file: "go.mod"
- name: Run benchmarks
run: make bench | tee output.raw
- name: Fix benchmark names
@@ -134,6 +122,19 @@ jobs:
with:
tool: "go"
output-file-path: output.txt
github-token: ${{ secrets.GH_PUSH_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
comment-on-alert: true
auto-push: true
release-please:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
outputs:
release_created: ${{ steps.release-please.outputs.release_created }}
version: ${{ steps.release-please.outputs.version }}
steps:
- uses: jimeh/release-please-manifest-action@v2
id: release-please
with:
app-id: ${{ secrets.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}

View File

@@ -4,18 +4,13 @@ linters-settings:
statements: 150
gocyclo:
min-complexity: 20
golint:
min-confidence: 0
govet:
check-shadowing: true
enable-all: true
disable:
- fieldalignment
lll:
line-length: 80
tab-width: 4
maligned:
suggest-new: true
misspell:
locale: US
@@ -24,13 +19,11 @@ linters:
enable:
- asciicheck
- bodyclose
- deadcode
- depguard
- copyloopvar
- durationcheck
- errcheck
- errorlint
- exhaustive
- exportloopref
- funlen
- gochecknoinits
- goconst
@@ -58,12 +51,10 @@ linters:
- rowserrcheck
- sqlclosecheck
- staticcheck
- structcheck
- typecheck
- unconvert
- unparam
- unused
- varcheck
- wastedassign
- whitespace

View File

@@ -2,6 +2,29 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
## [0.5.0](https://github.com/jimeh/rands/compare/v0.4.0...v0.5.0) (2025-03-10)
### ⚠ BREAKING CHANGES
* **shuffle:** Minimum required Go version is now 1.18 due the `ShuffleSlice` using generics.
### Features
* **shuffle:** add Shuffle and ShuffleSlice functions ([#11](https://github.com/jimeh/rands/issues/11)) ([a141938](https://github.com/jimeh/rands/commit/a14193839475562268ea10e356580b33b27ebedc))
## [0.4.0](https://github.com/jimeh/rands/compare/v0.3.0...v0.4.0) (2025-03-07)
### ⚠ BREAKING CHANGES
* **deps:** Minimum Go version changed from 1.15 to 1.17.
### Features
* **deps:** upgrade to Go 1.17 and golanci-lint 1.64 ([#5](https://github.com/jimeh/rands/issues/5)) ([16bd3ea](https://github.com/jimeh/rands/commit/16bd3ea3b9483f5510c0e0fa35e832e881840b3a))
* **strings/uuidv7:** add UUIDv7 generation ([#10](https://github.com/jimeh/rands/issues/10)) ([fe43086](https://github.com/jimeh/rands/commit/fe4308607cc8d454255908dc44e64462759e303d))
## [0.3.0](https://github.com/jimeh/rands/compare/v0.2.0...v0.3.0) (2021-12-17)

View File

@@ -34,23 +34,19 @@ SHELL := env \
# Tools
#
TOOLS += $(TOOLDIR)/gobin
$(TOOLDIR)/gobin:
GO111MODULE=off go get -u github.com/myitcv/gobin
# external tool
define tool # 1: binary-name, 2: go-import-path
TOOLS += $(TOOLDIR)/$(1)
$(TOOLDIR)/$(1): $(TOOLDIR)/gobin Makefile
gobin $(V) "$(2)"
$(TOOLDIR)/$(1): Makefile
GOBIN="$(CURDIR)/$(TOOLDIR)" go install "$(2)"
endef
$(eval $(call tool,godoc,golang.org/x/tools/cmd/godoc))
$(eval $(call tool,gofumpt,mvdan.cc/gofumpt))
$(eval $(call tool,goimports,golang.org/x/tools/cmd/goimports))
$(eval $(call tool,golangci-lint,github.com/golangci/golangci-lint/cmd/golangci-lint@v1.43))
$(eval $(call tool,gomod,github.com/Helcaraxan/gomod))
$(eval $(call tool,godoc,golang.org/x/tools/cmd/godoc@latest))
$(eval $(call tool,gofumpt,mvdan.cc/gofumpt@latest))
$(eval $(call tool,goimports,golang.org/x/tools/cmd/goimports@latest))
$(eval $(call tool,golangci-lint,github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64))
$(eval $(call tool,gomod,github.com/Helcaraxan/gomod@latest))
.PHONY: tools
tools: $(TOOLS)
@@ -158,20 +154,3 @@ check-tidy:
docs: $(TOOLDIR)/godoc
$(info serviing docs on http://127.0.0.1:6060/pkg/$(GOMODNAME)/)
@godoc -http=127.0.0.1:6060
#
# Release
#
.PHONY: new-version
new-version: check-npx
npx standard-version
.PHONY: next-version
next-version: check-npx
npx standard-version --dry-run
.PHONY: check-npx
check-npx:
$(if $(shell which npx),,\
$(error No npx found in PATH, please install NodeJS))

View File

@@ -5,35 +5,19 @@
<p align="center">
<strong>
Go package providing a suite of functions that use <code>crypto/rand</code>
to generate cryptographically secure random strings in various formats, as
well as ints and bytes.
to generate cryptographically secure random data in various forms and
formats.
</strong>
</p>
<p align="center">
<a href="https://pkg.go.dev/github.com/jimeh/rands">
<img src="https://img.shields.io/badge/%E2%80%8B-reference-387b97.svg?logo=go&logoColor=white"
alt="Go Reference">
</a>
<a href="https://github.com/jimeh/rands/releases">
<img src="https://img.shields.io/github/v/tag/jimeh/rands?label=release" alt="GitHub tag (latest SemVer)">
</a>
<a href="https://github.com/jimeh/rands/actions">
<img src="https://img.shields.io/github/workflow/status/jimeh/rands/CI.svg?logo=github" alt="Actions Status">
</a>
<a href="https://codeclimate.com/github/jimeh/rands">
<img src="https://img.shields.io/codeclimate/coverage/jimeh/rands.svg?logo=code%20climate" alt="Coverage">
</a>
<a href="https://github.com/jimeh/rands/issues">
<img src="https://img.shields.io/github/issues-raw/jimeh/rands.svg?style=flat&logo=github&logoColor=white"
alt="GitHub issues">
</a>
<a href="https://github.com/jimeh/rands/pulls">
<img src="https://img.shields.io/github/issues-pr-raw/jimeh/rands.svg?style=flat&logo=github&logoColor=white" alt="GitHub pull requests">
</a>
<a href="https://github.com/jimeh/rands/blob/master/LICENSE">
<img src="https://img.shields.io/github/license/jimeh/rands.svg?style=flat" alt="License Status">
</a>
<a href="https://pkg.go.dev/github.com/jimeh/rands"><img src="https://img.shields.io/badge/%E2%80%8B-reference-387b97.svg?logo=go&logoColor=white" alt="Go Reference"></a>
<a href="https://github.com/jimeh/rands/releases"><img src="https://img.shields.io/github/v/tag/jimeh/rands?label=release" alt="GitHub tag (latest SemVer)"></a>
<a href="https://github.com/jimeh/rands/actions"><img src="https://img.shields.io/github/actions/workflow/status/jimeh/rands/ci.yml?branch=main&logo=github" alt="Actions Status"></a>
<a href="https://codeclimate.com/github/jimeh/rands"><img src="https://img.shields.io/codeclimate/coverage/jimeh/rands.svg?logo=code%20climate" alt="Coverage"></a>
<a href="https://github.com/jimeh/rands/issues"><img src="https://img.shields.io/github/issues-raw/jimeh/rands.svg?style=flat&logo=github&logoColor=white" alt="GitHub issues"></a>
<a href="https://github.com/jimeh/rands/pulls"><img src="https://img.shields.io/github/issues-pr-raw/jimeh/rands.svg?style=flat&logo=github&logoColor=white" alt="GitHub pull requests"></a>
<a href="https://github.com/jimeh/rands/blob/master/LICENSE"><img src="https://img.shields.io/github/license/jimeh/rands.svg?style=flat" alt="License Status"></a>
</p>
## [`rands`](https://pkg.go.dev/github.com/jimeh/rands) package
@@ -69,14 +53,18 @@ 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
b, err := rands.Bytes(8) // => [0 220 137 243 135 204 34 63]
err := rands.Shuffle(len(arr), func(i, j) { arr[i], arr[j] = arr[j], arr[i] })
err := rands.ShuffleSlice(arr)
```
## [`randsmust`](https://pkg.go.dev/github.com/jimeh/rands/must) package
## [`randsmust`](https://pkg.go.dev/github.com/jimeh/rands/randsmust) package
`randsmust` is specifically intended as an alternative to `rands` for use in
tests. All functions return a single value, and panic in the event of an error.
@@ -111,11 +99,15 @@ 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
b := randsmust.Bytes(8) // => [205 128 54 95 0 95 53 51]
randsmust.Shuffle(len(arr), func(i, j) { arr[i], arr[j] = arr[j], arr[i] })
randsmust.ShuffleSlice(arr)
```
## Documentation
@@ -123,7 +115,7 @@ b := randsmust.Bytes(8) // => [205 128 54 95 0 95 53 51]
Please see the Go Reference for documentation and examples:
- [`rands`](https://pkg.go.dev/github.com/jimeh/rands)
- [`randsmust`](https://pkg.go.dev/github.com/jimeh/rands/must)
- [`randsmust`](https://pkg.go.dev/github.com/jimeh/rands/randsmust)
## Benchmarks

10
go.mod
View File

@@ -1,5 +1,11 @@
module github.com/jimeh/rands
go 1.15
go 1.18
require github.com/stretchr/testify v1.7.0
require github.com/stretchr/testify v1.10.0
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

13
go.sum
View File

@@ -1,11 +1,10 @@
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/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 h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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=

16
ints.go
View File

@@ -8,13 +8,13 @@ import (
var ErrInvalidMaxInt = fmt.Errorf("%w: max cannot be less than 1", Err)
// Int generates a random int ranging between 0 and max.
func Int(max int) (int, error) {
if max < 1 {
// Int generates a random int ranging between 0 and nMax.
func Int(nMax int) (int, error) {
if nMax < 1 {
return 0, ErrInvalidMaxInt
}
r, err := rand.Int(rand.Reader, big.NewInt(int64(max)))
r, err := rand.Int(rand.Reader, big.NewInt(int64(nMax)))
if err != nil {
return 0, err
}
@@ -22,13 +22,13 @@ func Int(max int) (int, error) {
return int(r.Int64()), nil
}
// Int64 generates a random int64 ranging between 0 and max.
func Int64(max int64) (int64, error) {
if max < 1 {
// Int64 generates a random int64 ranging between 0 and nMax.
func Int64(nMax int64) (int64, error) {
if nMax < 1 {
return 0, ErrInvalidMaxInt
}
r, err := rand.Int(rand.Reader, big.NewInt(max))
r, err := rand.Int(rand.Reader, big.NewInt(nMax))
if err != nil {
return 0, err
}

View File

@@ -2,9 +2,9 @@ package randsmust
import "github.com/jimeh/rands"
// Int generates a random int ranging between 0 and max.
func Int(max int) int {
r, err := rands.Int(max)
// Int generates a random int ranging between 0 and nMax.
func Int(nMax int) int {
r, err := rands.Int(nMax)
if err != nil {
panic(err)
}
@@ -12,9 +12,9 @@ func Int(max int) int {
return r
}
// Int64 generates a random int64 ranging between 0 and max.
func Int64(max int64) int64 {
r, err := rands.Int64(max)
// Int64 generates a random int64 ranging between 0 and nMax.
func Int64(nMax int64) int64 {
r, err := rands.Int64(nMax)
if err != nil {
panic(err)
}

32
randsmust/shuffle.go Normal file
View File

@@ -0,0 +1,32 @@
package randsmust
import "github.com/jimeh/rands"
// Shuffle randomizes the order of a collection of n elements using
// cryptographically secure random values from crypto/rand. It implements the
// Fisher-Yates shuffle algorithm.
//
// The swap function is called to exchange values at indices i and j. This
// signature is compatible with Shuffle from math/rand and math/rand/v2 for easy
// migration.
//
// If an error occurs during shuffling, this function will panic.
func Shuffle(n int, swap func(i, j int)) {
err := rands.Shuffle(n, swap)
if err != nil {
panic(err)
}
}
// ShuffleSlice randomizes the order of elements in a slice in-place using
// cryptographically secure random values from crypto/rand.
//
// It implements the Fisher-Yates shuffle algorithm.
//
// If an error occurs during shuffling, this function will panic.
func ShuffleSlice[T any](slice []T) {
err := rands.ShuffleSlice(slice)
if err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,25 @@
package randsmust_test
import (
"fmt"
"github.com/jimeh/rands/randsmust"
)
func ExampleShuffle() {
numbers := []int{1, 2, 3, 4, 5}
randsmust.Shuffle(len(numbers), func(i, j int) {
numbers[i], numbers[j] = numbers[j], numbers[i]
})
fmt.Println(numbers) // => [4 2 5 3 1]
}
func ExampleShuffleSlice() {
mixed := []any{1, "two", 3.14, true, nil}
randsmust.ShuffleSlice(mixed)
fmt.Println(mixed) // => [two <nil> true 3.14 1]
}

456
randsmust/shuffle_test.go Normal file
View File

@@ -0,0 +1,456 @@
package randsmust
import (
"fmt"
"testing"
"github.com/jimeh/rands"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func factorial(n int) int {
factorial := 1
for i := 1; i <= n; i++ {
factorial *= i
}
return factorial
}
func TestShuffle(t *testing.T) {
t.Parallel()
t.Run("n < 0", func(t *testing.T) {
t.Parallel()
p := recoverPanic(func() {
Shuffle(-1, func(_, _ int) {})
})
require.NotNil(t, p, "Expected a panic")
assert.ErrorIs(t, p.(error), rands.ErrInvalidShuffleNegativeN)
assert.ErrorIs(t, p.(error), rands.ErrShuffle)
assert.ErrorIs(t, p.(error), rands.Err)
})
t.Run("n == 0", func(t *testing.T) {
t.Parallel()
swapCount := 0
p := recoverPanic(func() {
Shuffle(0, func(_, _ int) {
swapCount++
})
})
require.Nil(t, p, "Did not expect a panic")
assert.Equal(t, 0, swapCount)
})
t.Run("n == 1", func(t *testing.T) {
t.Parallel()
swapCount := 0
p := recoverPanic(func() {
Shuffle(1, func(_, _ int) {
swapCount++
})
})
require.Nil(t, p, "Did not expect a panic")
assert.Equal(t, 0, swapCount)
})
t.Run("n == 2", func(t *testing.T) {
t.Parallel()
swapCount := 0
p := recoverPanic(func() {
Shuffle(2, func(_, _ int) {
swapCount++
})
})
require.Nil(t, p, "Did not expect a panic")
assert.Equal(t, 1, swapCount)
})
t.Run("basic", func(t *testing.T) {
t.Parallel()
arr := make([]int, 100)
for i := range arr {
arr[i] = i
}
arrCopy := make([]int, len(arr))
copy(arrCopy, arr)
p := recoverPanic(func() {
Shuffle(len(arr), func(i, j int) {
arr[i], arr[j] = arr[j], arr[i]
})
})
require.Nil(t, p, "Did not expect a panic")
assert.NotEqual(t, arrCopy, arr, "Shuffle did not change the array")
assert.ElementsMatch(t, arrCopy, arr, "Shuffle changed elements")
})
t.Run("swaps", func(t *testing.T) {
t.Parallel()
swapSame := 0
swapDifferent := 0
arr := make([]int, 100)
for j := range arr {
arr[j] = j
}
p := recoverPanic(func() {
Shuffle(len(arr), func(i, j int) {
if i == j {
swapSame++
} else {
swapDifferent++
}
arr[i], arr[j] = arr[j], arr[i]
})
})
require.Nil(t, p, "Did not expect a panic")
// Fisher-Yates with n elements should make exactly n-1 swaps
assert.Equal(t, len(arr)-1, swapSame+swapDifferent,
"Unexpected swaps count",
)
// Ensure we have more different-element swaps than self-swaps. The
// lower the input shuffle n value, the more likely this assertion will
// fail. For a n=100 shuffle, this is exceptionally unlikely to fail.
assert.Greater(t, swapDifferent, swapSame,
"Expected more different-element swaps than self-swaps",
)
})
t.Run("swap ranges", func(t *testing.T) {
t.Parallel()
n := 32
runs := 1000
for run := 0; run < runs; run++ {
called := 0
p := recoverPanic(func() {
Shuffle(n, func(i, j int) {
called++
// Verify indices are in bounds.
assert.True(t,
i >= 0 && i < n, "Out of bounds index i = %d", i,
)
assert.True(t,
j >= 0 && j < n, "Out of bounds index j = %d", j,
)
// For Fisher-Yates, i should be > 0 and j should be in
// range [0,i].
assert.Greater(t, i, 0, "Expected i > 0, got i=%d", i)
assert.True(t,
j >= 0 && j <= i,
"Expected j in range [0,%d], got j=%d", i, j,
)
})
})
require.Nil(t, p, "Did not expect a panic")
// Fisher-Yates with n elements should make exactly n-1 swaps
expected := n - 1
assert.Equal(t, expected, called,
"Expected %d swap calls, got %d", expected, called,
)
}
})
t.Run("all permutations", func(t *testing.T) {
t.Parallel()
// Use a small array of 5 elements to make it feasible to track all
// permutations.
n := 5
fact := factorial(n) // 120
runs := fact * 3000 // 360000
permCounts := make(map[string]int)
for i := 0; i < runs; i++ {
arr := make([]int, n)
for i := range arr {
arr[i] = i
}
p := recoverPanic(func() {
Shuffle(len(arr), func(i, j int) {
arr[i], arr[j] = arr[j], arr[i]
})
})
require.Nil(t, p, "Did not expect a panic")
// Convert the permutation to a string key and count it.
key := fmt.Sprintf("%v", arr)
permCounts[key]++
}
assert.Equal(t, fact, len(permCounts),
"Expected %d different permutations", fact,
)
wantCount := float64(runs) / float64(fact)
margin := 0.15
minAcceptable := int(wantCount * (1 - margin))
maxAcceptable := int(wantCount * (1 + margin))
for perm, count := range permCounts {
assert.True(t,
count >= minAcceptable && count <= maxAcceptable,
"Non-uniform distribution for %s: count=%d, expected=%v±%v",
perm, count, wantCount, wantCount*margin,
)
}
})
t.Run("distribution", func(t *testing.T) {
t.Parallel()
// Track which positions received which random indices
n := 100
posCounts := make([]map[int]int, n)
for i := range posCounts {
posCounts[i] = make(map[int]int)
}
runs := 3000
for run := 0; run < runs; run++ {
p := recoverPanic(func() {
Shuffle(n, func(i, j int) {
posCounts[i][j]++
})
})
require.Nil(t, p, "Did not expect a panic")
}
// For each position, check that it received a reasonable distribution.
for i := n - 1; i >= n-len(posCounts); i-- {
// Calculate how many unique positions we should expect.
// Position i should receive random positions from 0 to i, and
// allow for some statistical variation.
want := int(float64(i+1) * 0.9)
assert.GreaterOrEqual(t,
len(posCounts[i]), want,
"Position %d: expected ~%d unique indices, got %d",
i, want, len(posCounts[i]),
)
}
})
}
func TestShuffleSlice(t *testing.T) {
t.Parallel()
t.Run("empty slice", func(t *testing.T) {
t.Parallel()
slice := []int{}
p := recoverPanic(func() {
ShuffleSlice(slice)
})
require.Nil(t, p, "Did not expect a panic")
assert.Empty(t, slice)
})
t.Run("single element", func(t *testing.T) {
t.Parallel()
slice := []int{42}
origSlice := make([]int, len(slice))
copy(origSlice, slice)
p := recoverPanic(func() {
ShuffleSlice(slice)
})
require.Nil(t, p, "Did not expect a panic")
assert.Equal(t,
origSlice, slice, "Single element slice should remain unchanged",
)
})
t.Run("two elements", func(t *testing.T) {
t.Parallel()
slice := []int{1, 2}
origSlice := make([]int, len(slice))
copy(origSlice, slice)
p := recoverPanic(func() {
ShuffleSlice(slice)
})
require.Nil(t, p, "Did not expect a panic")
// With two elements, the slice might remain the same or be swapped
assert.Len(t, slice, len(origSlice))
assert.ElementsMatch(t, origSlice, slice)
})
t.Run("basic", func(t *testing.T) {
t.Parallel()
slice := make([]int, 100)
for i := range slice {
slice[i] = i
}
sliceCopy := make([]int, len(slice))
copy(sliceCopy, slice)
p := recoverPanic(func() {
ShuffleSlice(slice)
})
require.Nil(t, p, "Did not expect a panic")
assert.NotEqual(t,
sliceCopy, slice, "ShuffleSlice did not change the slice",
)
assert.ElementsMatch(t,
sliceCopy, slice, "ShuffleSlice changed elements",
)
})
t.Run("string slice", func(t *testing.T) {
t.Parallel()
strSlice := []string{"a", "b", "c", "d", "e"}
strCopy := make([]string, len(strSlice))
copy(strCopy, strSlice)
p := recoverPanic(func() {
ShuffleSlice(strSlice)
})
require.Nil(t, p, "Did not expect a panic")
assert.ElementsMatch(t, strCopy, strSlice)
})
t.Run("struct slice", func(t *testing.T) {
t.Parallel()
type testStruct struct {
id int
name string
}
structSlice := []testStruct{
{1, "one"},
{2, "two"},
{3, "three"},
{4, "four"},
}
structCopy := make([]testStruct, len(structSlice))
copy(structCopy, structSlice)
p := recoverPanic(func() {
ShuffleSlice(structSlice)
})
require.Nil(t, p, "Did not expect a panic")
assert.ElementsMatch(t, structCopy, structSlice)
})
t.Run("all permutations", func(t *testing.T) {
t.Parallel()
// Use a small slice of 5 elements to make it feasible to track all
// permutations.
n := 5
fact := factorial(n) // 120
runs := fact * 3000 // 360000
permCounts := make(map[string]int)
for i := 0; i < runs; i++ {
slice := make([]int, n)
for j := range slice {
slice[j] = j
}
p := recoverPanic(func() {
ShuffleSlice(slice)
})
require.Nil(t, p, "Did not expect a panic")
// Convert the permutation to a string key and count it.
key := fmt.Sprintf("%v", slice)
permCounts[key]++
}
assert.Equal(t, fact, len(permCounts),
"Expected %d different permutations", fact,
)
wantCount := float64(runs) / float64(fact)
margin := 0.15
minAcceptable := int(wantCount * (1 - margin))
maxAcceptable := int(wantCount * (1 + margin))
for perm, count := range permCounts {
assert.True(t,
count >= minAcceptable && count <= maxAcceptable,
"Non-uniform distribution for %s: count=%d, expected=%v±%v",
perm, count, wantCount, wantCount*margin,
)
}
})
t.Run("distribution", func(t *testing.T) {
t.Parallel()
// Track where each original index ends up after shuffling
n := 100
// posCounts[originalPos][newPos] tracks how many times
// the element originally at position i ended up at position j
posCounts := make([]map[int]int, n)
for i := range posCounts {
posCounts[i] = make(map[int]int)
}
runs := 3000
for run := 0; run < runs; run++ {
// Create a slice where the value is its original position
slice := make([]int, n)
for i := range slice {
slice[i] = i
}
p := recoverPanic(func() {
ShuffleSlice(slice)
})
require.Nil(t, p, "Did not expect a panic")
// Track where each original position ended up
for newPos, origPos := range slice {
posCounts[origPos][newPos]++
}
}
// For each original position, check that it was distributed
// reasonably across all possible new positions
for i := n - 1; i >= n-len(posCounts); i-- {
// Calculate how many unique positions we should expect.
// Position i should receive random positions from 0 to i, and
// allow for some statistical variation.
want := int(float64(i+1) * 0.9)
assert.GreaterOrEqual(t,
len(posCounts[i]), want,
"Original position %d: expected ~%d unique positions, got %d",
i, want, len(posCounts[i]),
)
}
})
}

View File

@@ -165,12 +165,12 @@ func UnicodeString(n int, alphabet []rune) string {
//
// In summary, the generated random string will:
//
// - be between 1 and 63 characters in length, other n values returns a error
// - first character will be one of a-z
// - last character will be one of a-z or 0-9
// - in-between first and last characters consist of a-z, 0-9, or "-"
// - potentially contain two or more consecutive "-", except the 3rd and 4th
// characters, as that would violate RFC 5891, section 4.2.3.1.
// - be between 1 and 63 characters in length, other n values returns a error
// - first character will be one of a-z
// - last character will be one of a-z or 0-9
// - in-between first and last characters consist of a-z, 0-9, or "-"
// - potentially contain two or more consecutive "-", except the 3rd and 4th
// characters, as that would violate RFC 5891, section 4.2.3.1.
func DNSLabel(n int) string {
r, err := rands.DNSLabel(n)
if err != nil {
@@ -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
}

View File

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

View File

@@ -6,6 +6,7 @@ import (
"regexp"
"strings"
"testing"
"time"
"github.com/jimeh/rands"
"github.com/stretchr/testify/assert"
@@ -487,19 +488,82 @@ 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 10 seconds of current time. This is a
// sanity check to ensure the UUID is not too far off from the current
// time, while allowing tests to pass on super slow machines.
tsTime := time.UnixMilli(timestampBytes)
require.WithinDuration(t, time.Now(), tsTime, 10*time.Second,
"timestamp is not within 10 seconds 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
}
}

51
shuffle.go Normal file
View File

@@ -0,0 +1,51 @@
package rands
import (
"fmt"
)
var (
ErrShuffle = fmt.Errorf("%w: shuffle", Err)
ErrInvalidShuffleNegativeN = fmt.Errorf(
"%w: n must not be negative", ErrShuffle,
)
)
// Shuffle randomizes the order of a collection of n elements using
// cryptographically secure random values from crypto/rand. It implements the
// Fisher-Yates shuffle algorithm.
//
// The swap function is called to exchange values at indices i and j. This
// signature is compatible with Shuffle from math/rand and math/rand/v2 for easy
// migration.
func Shuffle(n int, swap func(i, j int)) error {
if n < 0 {
return ErrInvalidShuffleNegativeN
}
for i := n - 1; i > 0; i-- {
j, err := Int(i + 1)
if err != nil {
return err
}
swap(i, j)
}
return nil
}
// ShuffleSlice randomizes the order of elements in a slice in-place using
// cryptographically secure random values from crypto/rand.
//
// It implements the Fisher-Yates shuffle algorithm.
func ShuffleSlice[T any](slice []T) error {
// If the slice has one or no elements, there's nothing to shuffle.
if len(slice) < 2 {
return nil
}
return Shuffle(len(slice), func(i, j int) {
slice[i], slice[j] = slice[j], slice[i]
})
}

32
shuffle_example_test.go Normal file
View File

@@ -0,0 +1,32 @@
package rands_test
import (
"fmt"
"log"
"github.com/jimeh/rands"
)
func ExampleShuffle() {
numbers := []int{1, 2, 3, 4, 5}
err := rands.Shuffle(len(numbers), func(i, j int) {
numbers[i], numbers[j] = numbers[j], numbers[i]
})
if err != nil {
log.Fatal(err)
}
fmt.Println(numbers) // => [2 4 5 1 3]
}
func ExampleShuffleSlice() {
mixed := []any{1, "two", 3.14, true, nil}
err := rands.ShuffleSlice(mixed)
if err != nil {
log.Fatal(err)
}
fmt.Println(mixed) // => [3.14 true 1 two <nil>]
}

444
shuffle_test.go Normal file
View File

@@ -0,0 +1,444 @@
package rands
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func factorial(n int) int {
factorial := 1
for i := 1; i <= n; i++ {
factorial *= i
}
return factorial
}
func TestShuffle(t *testing.T) {
t.Parallel()
t.Run("n < 0", func(t *testing.T) {
t.Parallel()
err := Shuffle(-1, func(_, _ int) {})
require.ErrorIs(t, err, ErrInvalidShuffleNegativeN)
require.ErrorIs(t, err, ErrShuffle)
require.ErrorIs(t, err, Err)
})
t.Run("n == 0", func(t *testing.T) {
t.Parallel()
swapCount := 0
err := Shuffle(0, func(_, _ int) {
swapCount++
})
require.NoError(t, err)
assert.Equal(t, 0, swapCount)
})
t.Run("n == 1", func(t *testing.T) {
t.Parallel()
swapCount := 0
err := Shuffle(1, func(_, _ int) {
swapCount++
})
require.NoError(t, err)
assert.Equal(t, 0, swapCount)
})
t.Run("n == 2", func(t *testing.T) {
t.Parallel()
swapCount := 0
err := Shuffle(2, func(_, _ int) {
swapCount++
})
require.NoError(t, err)
assert.Equal(t, 1, swapCount)
})
t.Run("basic", func(t *testing.T) {
t.Parallel()
arr := make([]int, 100)
for i := range arr {
arr[i] = i
}
arrCopy := make([]int, len(arr))
copy(arrCopy, arr)
err := Shuffle(len(arr), func(i, j int) {
arr[i], arr[j] = arr[j], arr[i]
})
require.NoError(t, err, "Shuffle returned an error")
assert.NotEqual(t, arrCopy, arr, "Shuffle did not change the array")
assert.ElementsMatch(t, arrCopy, arr, "Shuffle changed elements")
})
t.Run("swaps", func(t *testing.T) {
t.Parallel()
swapSame := 0
swapDifferent := 0
arr := make([]int, 100)
for j := range arr {
arr[j] = j
}
err := Shuffle(len(arr), func(i, j int) {
if i == j {
swapSame++
} else {
swapDifferent++
}
arr[i], arr[j] = arr[j], arr[i]
})
require.NoError(t, err, "Shuffle returned an error")
// Fisher-Yates with n elements should make exactly n-1 swaps
assert.Equal(t, len(arr)-1, swapSame+swapDifferent,
"Unexpected swaps count",
)
// Ensure we have more different-element swaps than self-swaps. The
// lower the input shuffle n value, the more likely this assertion will
// fail. For a n=100 shuffle, this is exceptionally unlikely to fail.
assert.Greater(t, swapDifferent, swapSame,
"Expected more different-element swaps than self-swaps",
)
})
t.Run("swap ranges", func(t *testing.T) {
t.Parallel()
n := 32
runs := 1000
for run := 0; run < runs; run++ {
called := 0
err := Shuffle(n, func(i, j int) {
called++
// Verify indices are in bounds.
assert.True(t, i >= 0 && i < n, "Out of bounds index i = %d", i)
assert.True(t, j >= 0 && j < n, "Out of bounds index j = %d", j)
// For Fisher-Yates, i should be > 0 and j should be in range
// [0,i].
assert.Greater(t, i, 0, "Expected i > 0, got i=%d", i)
assert.True(t,
j >= 0 && j <= i,
"Expected j in range [0,%d], got j=%d", i, j,
)
})
require.NoError(t, err, "Shuffle returned an error")
// Fisher-Yates with n elements should make exactly n-1 swaps
expected := n - 1
assert.Equal(t, expected, called,
"Expected %d swap calls, got %d", expected, called,
)
}
})
t.Run("all permutations", func(t *testing.T) {
t.Parallel()
// Use a small array of 5 elements to make it feasible to track all
// permutations.
n := 5
fact := factorial(n) // 120
runs := fact * 3000 // 360000
permCounts := make(map[string]int)
for i := 0; i < runs; i++ {
arr := make([]int, n)
for i := range arr {
arr[i] = i
}
err := Shuffle(len(arr), func(i, j int) {
arr[i], arr[j] = arr[j], arr[i]
})
require.NoError(t, err, "Shuffle returned an error")
// Convert the permutation to a string key and count it.
key := fmt.Sprintf("%v", arr)
permCounts[key]++
}
assert.Equal(t, fact, len(permCounts),
"Expected %d different permutations", fact,
)
wantCount := float64(runs) / float64(fact)
margin := 0.15
minAcceptable := int(wantCount * (1 - margin))
maxAcceptable := int(wantCount * (1 + margin))
for perm, count := range permCounts {
assert.True(t,
count >= minAcceptable && count <= maxAcceptable,
"Non-uniform distribution for %s: count=%d, expected=%v±%v",
perm, count, wantCount, wantCount*margin,
)
}
})
t.Run("distribution", func(t *testing.T) {
t.Parallel()
// Track which positions received which random indices
n := 100
posCounts := make([]map[int]int, n)
for i := range posCounts {
posCounts[i] = make(map[int]int)
}
runs := 3000
for run := 0; run < runs; run++ {
err := Shuffle(n, func(i, j int) {
posCounts[i][j]++
})
require.NoError(t, err, "Shuffle returned an error")
}
// For each position, check that it received a reasonable distribution.
for i := n - 1; i >= n-len(posCounts); i-- {
// Calculate how many unique positions we should expect.
// Position i should receive random positions from 0 to i, and
// allow for some statistical variation.
want := int(float64(i+1) * 0.9)
assert.GreaterOrEqual(t,
len(posCounts[i]), want,
"Position %d: expected ~%d unique indices, got %d",
i, want, len(posCounts[i]),
)
}
})
}
func BenchmarkShuffle(b *testing.B) {
ranges := []int{32, 64, 128, 1024, 4096}
for _, n := range ranges {
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = Shuffle(n, func(_, _ int) {})
}
})
}
}
func TestShuffleSlice(t *testing.T) {
t.Parallel()
t.Run("empty slice", func(t *testing.T) {
t.Parallel()
slice := []int{}
err := ShuffleSlice(slice)
require.NoError(t, err)
assert.Empty(t, slice)
})
t.Run("single element", func(t *testing.T) {
t.Parallel()
slice := []int{42}
origSlice := make([]int, len(slice))
copy(origSlice, slice)
err := ShuffleSlice(slice)
require.NoError(t, err)
assert.Equal(t,
origSlice, slice, "Single element slice should remain unchanged",
)
})
t.Run("two elements", func(t *testing.T) {
t.Parallel()
slice := []int{1, 2}
origSlice := make([]int, len(slice))
copy(origSlice, slice)
err := ShuffleSlice(slice)
require.NoError(t, err)
// With two elements, the slice might remain the same or be swapped
assert.Len(t, slice, len(origSlice))
assert.ElementsMatch(t, origSlice, slice)
})
t.Run("basic", func(t *testing.T) {
t.Parallel()
slice := make([]int, 100)
for i := range slice {
slice[i] = i
}
sliceCopy := make([]int, len(slice))
copy(sliceCopy, slice)
err := ShuffleSlice(slice)
require.NoError(t, err, "ShuffleSlice returned an error")
assert.NotEqual(t,
sliceCopy, slice, "ShuffleSlice did not change the slice",
)
assert.ElementsMatch(t,
sliceCopy, slice, "ShuffleSlice changed elements",
)
})
t.Run("string slice", func(t *testing.T) {
t.Parallel()
strSlice := []string{"a", "b", "c", "d", "e"}
strCopy := make([]string, len(strSlice))
copy(strCopy, strSlice)
err := ShuffleSlice(strSlice)
require.NoError(t, err)
assert.ElementsMatch(t, strCopy, strSlice)
})
t.Run("struct slice", func(t *testing.T) {
t.Parallel()
type testStruct struct {
id int
name string
}
structSlice := []testStruct{
{1, "one"},
{2, "two"},
{3, "three"},
{4, "four"},
}
structCopy := make([]testStruct, len(structSlice))
copy(structCopy, structSlice)
err := ShuffleSlice(structSlice)
require.NoError(t, err)
assert.ElementsMatch(t, structCopy, structSlice)
})
t.Run("all permutations", func(t *testing.T) {
t.Parallel()
// Use a small slice of 5 elements to make it feasible to track all
// permutations.
n := 5
fact := factorial(n) // 120
runs := fact * 3000 // 360000
permCounts := make(map[string]int)
for i := 0; i < runs; i++ {
slice := make([]int, n)
for j := range slice {
slice[j] = j
}
err := ShuffleSlice(slice)
require.NoError(t, err, "ShuffleSlice returned an error")
// Convert the permutation to a string key and count it.
key := fmt.Sprintf("%v", slice)
permCounts[key]++
}
assert.Equal(t, fact, len(permCounts),
"Expected %d different permutations", fact,
)
wantCount := float64(runs) / float64(fact)
margin := 0.15
minAcceptable := int(wantCount * (1 - margin))
maxAcceptable := int(wantCount * (1 + margin))
for perm, count := range permCounts {
assert.True(t,
count >= minAcceptable && count <= maxAcceptable,
"Non-uniform distribution for %s: count=%d, expected=%v±%v",
perm, count, wantCount, wantCount*margin,
)
}
})
t.Run("distribution", func(t *testing.T) {
t.Parallel()
// Track where each original index ends up after shuffling
n := 100
// posCounts[originalPos][newPos] tracks how many times
// the element originally at position i ended up at position j
posCounts := make([]map[int]int, n)
for i := range posCounts {
posCounts[i] = make(map[int]int)
}
runs := 3000
for run := 0; run < runs; run++ {
// Create a slice where the value is its original position
slice := make([]int, n)
for i := range slice {
slice[i] = i
}
err := ShuffleSlice(slice)
require.NoError(t, err, "ShuffleSlice returned an error")
// Track where each original position ended up
for newPos, origPos := range slice {
posCounts[origPos][newPos]++
}
}
// For each original position, check that it was distributed
// reasonably across all possible new positions
for i := n - 1; i >= n-len(posCounts); i-- {
// Calculate how many unique positions we should expect.
// Position i should receive random positions from 0 to i, and
// allow for some statistical variation.
want := int(float64(i+1) * 0.9)
assert.GreaterOrEqual(t,
len(posCounts[i]), want,
"Original position %d: expected ~%d unique positions, got %d",
i, want, len(posCounts[i]),
)
}
})
}
func BenchmarkShuffleSlice(b *testing.B) {
ranges := []int{32, 64, 128, 1024, 4096}
for _, n := range ranges {
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
b.StopTimer()
slice := make([]int, n)
for i := range slice {
slice[i] = i
}
b.StartTimer()
for i := 0; i < b.N; i++ {
_ = ShuffleSlice(slice)
}
})
}
}

View File

@@ -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 (
@@ -171,12 +172,12 @@ func UnicodeString(n int, alphabet []rune) (string, error) {
//
// In summary, the generated random string will:
//
// - be between 1 and 63 characters in length, other n values returns a error
// - first character will be one of a-z
// - last character will be one of a-z or 0-9
// - in-between first and last characters consist of a-z, 0-9, or "-"
// - potentially contain two or more consecutive "-", except the 3rd and 4th
// characters, as that would violate RFC 5891, section 4.2.3.1.
// - be between 1 and 63 characters in length, other n values returns a error
// - first character will be one of a-z
// - last character will be one of a-z or 0-9
// - in-between first and last characters consist of a-z, 0-9, or "-"
// - potentially contain two or more consecutive "-", except the 3rd and 4th
// characters, as that would violate RFC 5891, section 4.2.3.1.
func DNSLabel(n int) (string, error) {
switch {
case n < 1 || n > 63:
@@ -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 {

View File

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

View File

@@ -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,70 @@ 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 10 seconds of current time. This is a
// sanity check to ensure the UUID is not too far off from the current
// time, while allowing tests to pass on super slow machines.
tsTime := time.UnixMilli(timestampBytes)
require.WithinDuration(t, time.Now(), tsTime, 10*time.Second,
"timestamp is not within 10 seconds 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
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()
}

168
uuid/version_7_test.go Normal file
View File

@@ -0,0 +1,168 @@
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 10 seconds of current time. This is a
// sanity check to ensure the UUID is not too far off from the current
// time, while allowing tests to pass on super slow machines.
tsTime := time.UnixMilli(timestampBytes)
require.WithinDuration(t, time.Now(), tsTime, 10*time.Second,
"timestamp is not within 10 seconds 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)
}
}