27 Commits

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
660fc4d179 chore(release): 0.3.0 2021-12-17 11:21:25 +00:00
4642a149bb Merge pull request #3 from jimeh/add-must-subpackage 2021-12-17 11:17:26 +00:00
22fe517baa feat(randsmust): add 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. This makes them easy to use when building structs in test cases
that need random data.

Internally the package simply calls the equivalent function from the
rands package, and panics if a error is returned.
2021-12-17 11:03:32 +00:00
74dd8fb7e9 docs(examples): add error handling to examples 2021-12-17 01:48:41 +00:00
a86282e34d chore(makefile): various minor updates and tweaks 2021-12-17 00:17:40 +00:00
0a42d1e112 chore(deps): update golangci-lint to 1.43 2021-12-17 00:10:50 +00:00
b59d421322 chore(test): enable parallel test execution
Seems to cut overall total test time down to one third the time.
2021-12-16 20:16:03 +00:00
164ccc497a feat(error): export error variables 2021-12-16 20:15:17 +00:00
83bad54113 chore(release): 0.2.0 2021-03-16 02:27:39 +00:00
09883ed0d2 docs(readme): add UUID() example to readme 2021-03-16 02:25:50 +00:00
d7f439e1d9 Merge pull request #2 from jimeh/add-uuid
feat(strings): add UUID function to generate random RFC 4122 UUID v4 strings
2021-03-16 02:23:18 +00:00
825a3c18fb feat(strings): add UUID function to generate random RFC 4122 UUID v4 strings 2021-03-16 02:14:56 +00:00
a755fe957a fix(strings): add missing benchmark for DNSLabel 2021-03-16 02:13:20 +00:00
ef9cb3a01d chore(docs): remove redundant RFC URLs
pkg.go.dev parses "RFC 1035, section 2.3.1" style text and links it to
the relevant place. So there's no need to also include a full URL for
documentation purposes.
2021-03-16 02:10:59 +00:00
41 changed files with 3752 additions and 288 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.35
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,59 +4,61 @@ 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
linters:
disable-all: true
enable:
- asciicheck
- bodyclose
- deadcode
- depguard
- dupl
- copyloopvar
- durationcheck
- errcheck
- errorlint
- exhaustive
- funlen
- gochecknoinits
- goconst
- gocritic
- gocyclo
- goerr113
- godot
- gofumpt
- goimports
- golint
- goprintffuncname
- gosec
- gosimple
- govet
- importas
- ineffassign
- lll
- misspell
- nakedret
- nilerr
- nlreturn
- noctx
- nolintlint
- scopelint
- prealloc
- predeclared
- revive
- rowserrcheck
- sqlclosecheck
- staticcheck
- structcheck
- typecheck
- unconvert
- unparam
- unused
- varcheck
- wastedassign
- whitespace
issues:
include:
# - EXC0002 # disable excluding of issues about comments from golint
exclude:
- Using the variable on range scope `tt` in function literal
- Using the variable on range scope `tc` in function literal
@@ -71,6 +73,12 @@ issues:
- source: "`json:"
linters:
- lll
- source: "`xml:"
linters:
- lll
- source: "`yaml:"
linters:
- lll
run:
timeout: 2m

View File

@@ -2,6 +2,49 @@
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)
### Features
* **error:** export error variables ([164ccc4](https://github.com/jimeh/rands/commit/164ccc497ad9880d43b22b74d6a83c5e68d79334))
* **randsmust:** add randsmust package ([22fe517](https://github.com/jimeh/rands/commit/22fe517baa8b6939503c0c804dd71628f7d473a3))
## [0.2.0](https://github.com/jimeh/rands/compare/v0.1.1...v0.2.0) (2021-03-16)
### Features
* **strings:** add UUID function to generate random RFC 4122 UUID v4 strings ([825a3c1](https://github.com/jimeh/rands/commit/825a3c18fbc8e0497eafea9254baadb2951f23c6))
### Bug Fixes
* **strings:** add missing benchmark for DNSLabel ([a755fe9](https://github.com/jimeh/rands/commit/a755fe957a485e4c29b8ade474878b265785bc66))
### [0.1.1](https://github.com/jimeh/rands/compare/v0.1.0...v0.1.1) (2021-01-20)
## 0.1.0 (2021-01-20)

View File

@@ -34,26 +34,19 @@ SHELL := env \
# Tools
#
TOOLS += $(TOOLDIR)/gobin
gobin: $(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)
.PHONY: $(1)
$(1): $(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,gofumports,mvdan.cc/gofumpt/gofumports))
$(eval $(call tool,golangci-lint,github.com/golangci/golangci-lint/cmd/golangci-lint@v1.35))
$(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)
@@ -62,8 +55,8 @@ tools: $(TOOLS)
# Development
#
TEST ?= $$(go list ./... | grep -v 'vendor')
BENCH ?= .
TESTARGS ?=
.PHONY: clean
clean:
@@ -72,24 +65,24 @@ clean:
.PHONY: test
test:
go test $(V) -count=1 $(TESTARGS) $(TEST)
go test $(V) -count=1 -race $(TESTARGS) ./...
.PHONY: test-deps
test-deps:
go test all
.PHONY: lint
lint: golangci-lint
GOGC=off golangci-lint $(V) run
lint: $(TOOLDIR)/golangci-lint
golangci-lint $(V) run
.PHONY: format
format: gofumports
gofumports -w .
format: $(TOOLDIR)/goimports $(TOOLDIR)/gofumpt
goimports -w . && gofumpt -w .
.SILENT: bench
.PHONY: bench
bench:
go test $(V) -count=1 -bench=$(BENCH) $(TESTARGS) $(TEST)
go test $(V) -count=1 -bench=$(BENCH) $(TESTARGS) ./...
#
# Coverage
@@ -115,16 +108,14 @@ coverage.out: $(SOURCES)
.PHONY: deps
deps:
$(info Downloading dependencies)
go mod download
.PHONY: deps-update
deps-update:
$(info Downloading dependencies)
go get -u ./...
go get -u -t ./...
.PHONY: deps-analyze
deps-analyze: gomod
deps-analyze: $(TOOLDIR)/gomod
gomod analyze
.PHONY: tidy
@@ -160,14 +151,6 @@ check-tidy:
# Serve docs
.PHONY: docs
docs: godoc
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:
npx standard-version

146
README.md
View File

@@ -5,71 +5,117 @@
<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>
```go
s, _ := rands.Base64(16) // => CYxqEdUB1Rzno3SyZu2g/g==
s, _ := rands.Base64URL(16) // => zlqw9aFqcFggbk2asn3_aQ
s, _ := rands.Hex(16) // => 956e2ec9e7f19ddd58bb935826926531
s, _ := rands.Alphanumeric(16) // => Fvk1PkrmG5crgOjT
s, _ := rands.Alphabetic(16) // => XEJIzcZufHkuUmRM
s, _ := rands.Upper(16) // => UMAGAFPPNDRGLUPZ
s, _ := rands.UpperNumeric(16) // => DF0CQS0TK9CPUO3E
s, _ := rands.Lower(16) // => ocsmggykzrxzfwgt
s, _ := rands.LowerNumeric(16) // => rwlv7a1p7klqffs5
s, _ := rands.Numeric(16) // => 9403373143598295
## [`rands`](https://pkg.go.dev/github.com/jimeh/rands) package
s, _ := rands.String(16, "abcdefABCDEF") // => adCDCaDEdeffeDeb
s, _ := rands.UnicodeString(16, []rune("九七二人入八力十下三千上口土夕大")) // => 下下口九力下土夕下土八上二夕大三
`rands` is intended for use in production code where random data generation is
required. All functions have a error return value which should be checked.
s, _ := rands.DNSLabel(16) // => z0ij9o8qkbs0ru-h
For tests there is the `randsmust` package, which has all the same functions but
with single return values, and they panic in the event of an error.
n, _ := rands.Int(2147483647) // => 1334400235
n, _ := rands.Int64(int64(9223372036854775807)) // => 8256935979116161233
b, _ := rands.Bytes(8) // => [0 220 137 243 135 204 34 63]
```
## Import
### Import
```
import "github.com/jimeh/rands"
```
### Usage
```go
s, err := rands.Base64(16) // => CYxqEdUB1Rzno3SyZu2g/g==
s, err := rands.Base64URL(16) // => zlqw9aFqcFggbk2asn3_aQ
s, err := rands.Hex(16) // => 956e2ec9e7f19ddd58bb935826926531
s, err := rands.Alphanumeric(16) // => Fvk1PkrmG5crgOjT
s, err := rands.Alphabetic(16) // => XEJIzcZufHkuUmRM
s, err := rands.Upper(16) // => UMAGAFPPNDRGLUPZ
s, err := rands.UpperNumeric(16) // => DF0CQS0TK9CPUO3E
s, err := rands.Lower(16) // => ocsmggykzrxzfwgt
s, err := rands.LowerNumeric(16) // => rwlv7a1p7klqffs5
s, err := rands.Numeric(16) // => 9403373143598295
s, err := rands.String(16, "abcdefABCDEF") // => adCDCaDEdeffeDeb
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/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.
This makes them easy to use when building structs in test cases that need random
data.
For production code, make sure to use the `rands` package and check returned
errors.
### Import
```
import "github.com/jimeh/rands/randsmust"
```
### Usage
```go
s := randsmust.Base64(16) // => d1wm/wS6AQGduO3uaey1Cg==
s := randsmust.Base64URL(16) // => 4pHWVcddXsL_45vhOfCdng
s := randsmust.Hex(16) // => b5552558bc009264d129c422a666fe56
s := randsmust.Alphanumeric(16) // => j5WkpNKmW8K701XF
s := randsmust.Alphabetic(16) // => OXxsqfFjNLvmZqDb
s := randsmust.Upper(16) // => AOTLYQRCVNMEPRCX
s := randsmust.UpperNumeric(16) // => 1NTY6KATDVAXBTY2
s := randsmust.Lower(16) // => xmftrwvurrritqfu
s := randsmust.LowerNumeric(16) // => yszg56fzeql7pjpl
s := randsmust.Numeric(16) // => 0761782105447226
s := randsmust.String(16, "abcdefABCDEF") // => dfAbBfaDDdDFDaEa
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
Please see the
[Go Reference](https://pkg.go.dev/github.com/jimeh/rands#section-documentation)
for documentation and examples.
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/randsmust)
## Benchmarks
@@ -78,4 +124,4 @@ https://jimeh.me/rands/dev/bench/
## License
[MIT](https://github.com/jimeh/rands/blob/master/LICENSE)
[MIT](https://github.com/jimeh/rands/blob/main/LICENSE)

View File

@@ -2,11 +2,16 @@ package rands_test
import (
"fmt"
"log"
"github.com/jimeh/rands"
)
func ExampleBytes() {
b, _ := rands.Bytes(8)
fmt.Printf("%+v\n", b) // => [0 220 137 243 135 204 34 63]
b, err := rands.Bytes(8)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%+v\n", b) // => [181 153 143 235 241 20 208 173]
}

View File

@@ -7,6 +7,8 @@ import (
)
func TestBytes(t *testing.T) {
t.Parallel()
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got, _ := Bytes(tt.n)

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=

22
ints.go
View File

@@ -6,15 +6,15 @@ import (
"math/big"
)
var errInvalidMaxInt = fmt.Errorf("%w: max cannot be less than 1", errBase)
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 {
return 0, errInvalidMaxInt
// 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 {
return 0, errInvalidMaxInt
// 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,16 +2,25 @@ package rands_test
import (
"fmt"
"log"
"github.com/jimeh/rands"
)
func ExampleInt() {
n, _ := rands.Int(2147483647)
fmt.Printf("%d\n", n) // => 1334400235
n, err := rands.Int(2147483647)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%d\n", n) // => 1908357440
}
func ExampleInt64() {
n, _ := rands.Int64(int64(9223372036854775807))
fmt.Printf("%d\n", n) // => 8256935979116161233
n, err := rands.Int64(int64(9223372036854775807))
if err != nil {
log.Fatal(err)
}
fmt.Printf("%d\n", n) // => 6530460062499341591
}

View File

@@ -1,7 +1,6 @@
package rands
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
@@ -16,67 +15,67 @@ var testIntCases = []struct {
{
name: "n=-2394345",
max: -2394345,
errIs: errInvalidMaxInt,
errIs: ErrInvalidMaxInt,
errStr: "rands: max cannot be less than 1",
},
{
name: "n=-409600",
max: -409600,
errIs: errInvalidMaxInt,
errIs: ErrInvalidMaxInt,
errStr: "rands: max cannot be less than 1",
},
{
name: "n=-1024",
max: -1024,
errIs: errInvalidMaxInt,
errIs: ErrInvalidMaxInt,
errStr: "rands: max cannot be less than 1",
},
{
name: "n=-128",
max: -128,
errIs: errInvalidMaxInt,
errIs: ErrInvalidMaxInt,
errStr: "rands: max cannot be less than 1",
},
{
name: "n=-32",
max: -32,
errIs: errInvalidMaxInt,
errIs: ErrInvalidMaxInt,
errStr: "rands: max cannot be less than 1",
},
{
name: "n=-16",
max: -16,
errIs: errInvalidMaxInt,
errIs: ErrInvalidMaxInt,
errStr: "rands: max cannot be less than 1",
},
{
name: "n=-8",
max: -8,
errIs: errInvalidMaxInt,
errIs: ErrInvalidMaxInt,
errStr: "rands: max cannot be less than 1",
},
{
name: "n=-7",
max: -7,
errIs: errInvalidMaxInt,
errIs: ErrInvalidMaxInt,
errStr: "rands: max cannot be less than 1",
},
{
name: "n=-2",
max: -2,
errIs: errInvalidMaxInt,
errIs: ErrInvalidMaxInt,
errStr: "rands: max cannot be less than 1",
},
{
name: "n=-1",
max: -1,
errIs: errInvalidMaxInt,
errIs: ErrInvalidMaxInt,
errStr: "rands: max cannot be less than 1",
},
{
name: "n=0",
max: 0,
errIs: errInvalidMaxInt,
errIs: ErrInvalidMaxInt,
errStr: "rands: max cannot be less than 1",
},
{name: "n=1", max: 1},
@@ -92,6 +91,8 @@ var testIntCases = []struct {
}
func TestInt(t *testing.T) {
t.Parallel()
for _, tt := range testIntCases {
t.Run(tt.name, func(t *testing.T) {
got, err := Int(tt.max)
@@ -102,7 +103,7 @@ func TestInt(t *testing.T) {
}
if tt.errIs != nil {
assert.True(t, errors.Is(err, errInvalidMaxInt))
assert.ErrorIs(t, err, tt.errIs)
}
if tt.errStr != "" {
@@ -123,6 +124,8 @@ func BenchmarkInt(b *testing.B) {
}
func TestInt64(t *testing.T) {
t.Parallel()
for _, tt := range testIntCases {
t.Run(tt.name, func(t *testing.T) {
got, err := Int64(int64(tt.max))
@@ -133,7 +136,7 @@ func TestInt64(t *testing.T) {
}
if tt.errIs != nil {
assert.True(t, errors.Is(err, errInvalidMaxInt))
assert.ErrorIs(t, err, tt.errIs)
}
if tt.errStr != "" {

View File

@@ -4,8 +4,15 @@
//
// All functions which produce strings from a alphabet of characters uses
// rand.Int() to ensure a uniform distribution of all possible values.
//
// rands is intended for use in production code where random data generation is
// required. All functions have a error return value, which should be
// checked.
//
// For tests there is the randsmust package, which has all the same functions
// but with single return values, and they panic in the event of an error.
package rands
import "errors"
var errBase = errors.New("rands")
var Err = errors.New("rands")

13
randsmust/bytes.go Normal file
View File

@@ -0,0 +1,13 @@
package randsmust
import "github.com/jimeh/rands"
// Bytes generates a byte slice of n number of random bytes.
func Bytes(n int) []byte {
r, err := rands.Bytes(n)
if err != nil {
panic(err)
}
return r
}

View File

@@ -0,0 +1,12 @@
package randsmust_test
import (
"fmt"
"github.com/jimeh/rands/randsmust"
)
func ExampleBytes() {
b := randsmust.Bytes(8)
fmt.Printf("%+v\n", b) // => [6 99 106 54 163 188 28 152]
}

19
randsmust/bytes_test.go Normal file
View File

@@ -0,0 +1,19 @@
package randsmust
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestBytes(t *testing.T) {
t.Parallel()
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := Bytes(tt.n)
assert.Len(t, got, tt.n)
})
}
}

23
randsmust/ints.go Normal file
View File

@@ -0,0 +1,23 @@
package randsmust
import "github.com/jimeh/rands"
// 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)
}
return r
}
// 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)
}
return r
}

View File

@@ -0,0 +1,17 @@
package randsmust_test
import (
"fmt"
"github.com/jimeh/rands/randsmust"
)
func ExampleInt() {
n := randsmust.Int(2147483647)
fmt.Printf("%d\n", n) // => 1616989970
}
func ExampleInt64() {
n := randsmust.Int64(int64(9223372036854775807))
fmt.Printf("%d\n", n) // => 1599573251306894157
}

144
randsmust/ints_test.go Normal file
View File

@@ -0,0 +1,144 @@
package randsmust
import (
"testing"
"github.com/jimeh/rands"
"github.com/stretchr/testify/assert"
)
var testIntCases = []struct {
name string
max int
panicErrIs error
panicStr string
}{
{
name: "n=-2394345",
max: -2394345,
panicErrIs: rands.ErrInvalidMaxInt,
panicStr: "rands: max cannot be less than 1",
},
{
name: "n=-409600",
max: -409600,
panicErrIs: rands.ErrInvalidMaxInt,
panicStr: "rands: max cannot be less than 1",
},
{
name: "n=-1024",
max: -1024,
panicErrIs: rands.ErrInvalidMaxInt,
panicStr: "rands: max cannot be less than 1",
},
{
name: "n=-128",
max: -128,
panicErrIs: rands.ErrInvalidMaxInt,
panicStr: "rands: max cannot be less than 1",
},
{
name: "n=-32",
max: -32,
panicErrIs: rands.ErrInvalidMaxInt,
panicStr: "rands: max cannot be less than 1",
},
{
name: "n=-16",
max: -16,
panicErrIs: rands.ErrInvalidMaxInt,
panicStr: "rands: max cannot be less than 1",
},
{
name: "n=-8",
max: -8,
panicErrIs: rands.ErrInvalidMaxInt,
panicStr: "rands: max cannot be less than 1",
},
{
name: "n=-7",
max: -7,
panicErrIs: rands.ErrInvalidMaxInt,
panicStr: "rands: max cannot be less than 1",
},
{
name: "n=-2",
max: -2,
panicErrIs: rands.ErrInvalidMaxInt,
panicStr: "rands: max cannot be less than 1",
},
{
name: "n=-1",
max: -1,
panicErrIs: rands.ErrInvalidMaxInt,
panicStr: "rands: max cannot be less than 1",
},
{
name: "n=0",
max: 0,
panicErrIs: rands.ErrInvalidMaxInt,
panicStr: "rands: max cannot be less than 1",
},
{name: "n=1", max: 1},
{name: "n=2", max: 2},
{name: "n=7", max: 7},
{name: "n=8", max: 8},
{name: "n=16", max: 16},
{name: "n=32", max: 32},
{name: "n=128", max: 128},
{name: "n=1024", max: 1024},
{name: "n=409600", max: 409600},
{name: "n=2394345", max: 2394345},
}
func TestInt(t *testing.T) {
t.Parallel()
for _, tt := range testIntCases {
t.Run(tt.name, func(t *testing.T) {
var got int
p := recoverPanic(func() {
got = Int(tt.max)
})
if tt.panicErrIs == nil || tt.panicStr == "" {
assert.GreaterOrEqual(t, got, 0)
assert.LessOrEqual(t, got, tt.max)
}
if tt.panicErrIs != nil {
assert.ErrorIs(t, p.(error), tt.panicErrIs)
}
if tt.panicStr != "" {
assert.EqualError(t, p.(error), tt.panicStr)
}
})
}
}
func TestInt64(t *testing.T) {
t.Parallel()
for _, tt := range testIntCases {
t.Run(tt.name, func(t *testing.T) {
var got int64
p := recoverPanic(func() {
got = Int64(int64(tt.max))
})
if tt.panicErrIs == nil || tt.panicStr == "" {
assert.GreaterOrEqual(t, got, int64(0))
assert.LessOrEqual(t, got, int64(tt.max))
}
if tt.panicErrIs != nil {
assert.ErrorIs(t, p.(error), tt.panicErrIs)
}
if tt.panicStr != "" {
assert.EqualError(t, p.(error), tt.panicStr)
}
})
}
}

15
randsmust/randsmust.go Normal file
View File

@@ -0,0 +1,15 @@
// Package randsmust provides a suite of functions that use crypto/rand to
// generate cryptographically secure random strings in various formats, as well
// as ints and bytes.
//
// All functions which produce strings from a alphabet of characters uses
// rand.Int() to ensure a uniform distribution of all possible values.
//
// 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. This makes them easy to use when building structs in test cases that
// need random data.
//
// For production code, make sure to use the rands package and check returned
// errors.
package randsmust

View File

@@ -0,0 +1,30 @@
package randsmust
var testCases = []struct {
name string
n int
}{
{name: "n=0", n: 0},
{name: "n=1", n: 1},
{name: "n=2", n: 2},
{name: "n=7", n: 7},
{name: "n=8", n: 8},
{name: "n=16", n: 16},
{name: "n=32", n: 32},
{name: "n=128", n: 128},
{name: "n=1024", n: 1024},
{name: "n=409600", n: 409600},
{name: "n=2394345", n: 2394345},
}
func recoverPanic(f func()) (p interface{}) {
defer func() {
if r := recover(); r != nil {
p = r
}
}()
f()
return
}

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]),
)
}
})
}

213
randsmust/strings.go Normal file
View File

@@ -0,0 +1,213 @@
package randsmust
import (
"github.com/jimeh/rands"
)
// Base64 generates a random base64 encoded string of n number of bytes.
//
// Length of the returned string is about one third greater than the value of n,
// and it may contain characters A-Z, a-z, 0-9, "+", "/", and "=".
func Base64(n int) string {
r, err := rands.Base64(n)
if err != nil {
panic(err)
}
return r
}
// Base64URL generates a URL-safe un-padded random base64 encoded string of n
// number of bytes.
//
// Length of the returned string is about one third greater than the value of n,
// and it may contain characters A-Z, a-z, 0-9, "-", and "_".
func Base64URL(n int) string {
r, err := rands.Base64URL(n)
if err != nil {
panic(err)
}
return r
}
// Hex generates a random hexadecimal encoded string of n number of bytes.
//
// Length of the returned string is twice the value of n, and it may contain
// characters 0-9 and a-f.
func Hex(n int) string {
r, err := rands.Hex(n)
if err != nil {
panic(err)
}
return r
}
// Alphanumeric generates a random alphanumeric string of n length.
//
// The returned string may contain A-Z, a-z, and 0-9.
func Alphanumeric(n int) string {
r, err := rands.Alphanumeric(n)
if err != nil {
panic(err)
}
return r
}
// Alphabetic generates a random alphabetic string of n length.
//
// The returned string may contain A-Z, and a-z.
func Alphabetic(n int) string {
r, err := rands.Alphabetic(n)
if err != nil {
panic(err)
}
return r
}
// Numeric generates a random numeric string of n length.
//
// The returned string may contain 0-9.
func Numeric(n int) string {
r, err := rands.Numeric(n)
if err != nil {
panic(err)
}
return r
}
// Upper generates a random uppercase alphabetic string of n length.
//
// The returned string may contain A-Z.
func Upper(n int) string {
r, err := rands.Upper(n)
if err != nil {
panic(err)
}
return r
}
// UpperNumeric generates a random uppercase alphanumeric string of n length.
//
// The returned string may contain A-Z and 0-9.
func UpperNumeric(n int) string {
r, err := rands.UpperNumeric(n)
if err != nil {
panic(err)
}
return r
}
// Lower generates a random lowercase alphabetic string of n length.
//
// The returned string may contain a-z.
func Lower(n int) string {
r, err := rands.Lower(n)
if err != nil {
panic(err)
}
return r
}
// LowerNumeric generates a random lowercase alphanumeric string of n length.
//
// The returned string may contain A-Z and 0-9.
func LowerNumeric(n int) string {
r, err := rands.LowerNumeric(n)
if err != nil {
panic(err)
}
return r
}
// String generates a random string of n length using the given ASCII alphabet.
//
// The specified alphabet determines what characters are used in the returned
// random string. The alphabet can only contain ASCII characters, use
// UnicodeString() if you need a alphabet with Unicode characters.
func String(n int, alphabet string) string {
r, err := rands.String(n, alphabet)
if err != nil {
panic(err)
}
return r
}
// UnicodeString generates a random string of n length using the given Unicode
// alphabet.
//
// The specified alphabet determines what characters are used in the returned
// random string. The length of the returned string will be n or greater
// depending on the byte-length of characters which were randomly selected from
// the alphabet.
func UnicodeString(n int, alphabet []rune) string {
r, err := rands.UnicodeString(n, alphabet)
if err != nil {
panic(err)
}
return r
}
// DNSLabel returns a random string of n length in a DNS label compliant format
// as defined in RFC 1035, section 2.3.1.
//
// It also adheres to RFC 5891, section 4.2.3.1.
//
// 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.
func DNSLabel(n int) string {
r, err := rands.DNSLabel(n)
if err != nil {
panic(err)
}
return r
}
// UUIDv4 returns a random UUID v4 in string format as defined by RFC 4122,
// section 4.4.
func UUID() string {
r, err := rands.UUID()
if err != nil {
panic(err)
}
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

@@ -0,0 +1,82 @@
package randsmust_test
import (
"fmt"
"github.com/jimeh/rands/randsmust"
)
func ExampleBase64() {
s := randsmust.Base64(16)
fmt.Println(s) // => rGnZOxJunCd5h+piBpOfDA==
}
func ExampleBase64URL() {
s := randsmust.Base64URL(16)
fmt.Println(s) // => NlXKmutou2knLU8q7Hlp5Q
}
func ExampleHex() {
s := randsmust.Hex(16)
fmt.Println(s) // => 1013ec67a802be177d3e37f46951e97f
}
func ExampleAlphanumeric() {
s := randsmust.Alphanumeric(16)
fmt.Println(s) // => mjT119HdPslVfvUE
}
func ExampleAlphabetic() {
s := randsmust.Alphabetic(16)
fmt.Println(s) // => RLaRaTVqcrxvNkiz
}
func ExampleUpper() {
s := randsmust.Upper(16)
fmt.Println(s) // => CANJDLMHANPQNXUE
}
func ExampleUpperNumeric() {
s := randsmust.UpperNumeric(16)
fmt.Println(s) // => EERZHC96KOIRU9DM
}
func ExampleLower() {
s := randsmust.Lower(16)
fmt.Println(s) // => aoybqdwigyezucjy
}
func ExampleLowerNumeric() {
s := randsmust.LowerNumeric(16)
fmt.Println(s) // => hs8l2l0750med3g2
}
func ExampleNumeric() {
s := randsmust.Numeric(16)
fmt.Println(s) // => 3126402104379869
}
func ExampleString() {
s := randsmust.String(16, "abcdefABCDEF")
fmt.Println(s) // => cbdCAbABECaADcaB
}
func ExampleUnicodeString() {
s := randsmust.UnicodeString(16, []rune("九七二人入八力十下三千上口土夕大"))
fmt.Println(s) // => 下夕七下千千力入八三力夕千三土七
}
func ExampleDNSLabel() {
s := randsmust.DNSLabel(16)
fmt.Println(s) // => urqkt-remuwz5083
}
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
}

622
randsmust/strings_test.go Normal file
View File

@@ -0,0 +1,622 @@
package randsmust
import (
"encoding/base64"
"encoding/hex"
"regexp"
"strings"
"testing"
"time"
"github.com/jimeh/rands"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHex(t *testing.T) {
t.Parallel()
allowed := "0123456789abcdef"
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := Hex(tt.n)
assert.Len(t, got, tt.n*2)
assertAllowedChars(t, allowed, got)
})
}
}
func TestBase64(t *testing.T) {
t.Parallel()
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +
"0123456789+/="
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := Base64(tt.n)
b, err := base64.StdEncoding.DecodeString(got)
require.NoError(t, err)
assert.Len(t, b, tt.n)
assertAllowedChars(t, allowed, got)
})
}
}
func TestBase64URL(t *testing.T) {
t.Parallel()
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +
"0123456789-_"
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := Base64URL(tt.n)
b, err := base64.RawURLEncoding.DecodeString(got)
require.NoError(t, err)
assert.Len(t, b, tt.n)
assertAllowedChars(t, allowed, got)
})
}
}
func TestAlphanumeric(t *testing.T) {
t.Parallel()
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := Alphanumeric(tt.n)
assert.Len(t, got, tt.n)
assertAllowedChars(t, allowed, got)
})
}
}
func TestAlphabetic(t *testing.T) {
t.Parallel()
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := Alphabetic(tt.n)
assert.Len(t, got, tt.n)
assertAllowedChars(t, allowed, got)
})
}
}
func TestNumeric(t *testing.T) {
t.Parallel()
allowed := "0123456789"
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := Numeric(tt.n)
assert.Len(t, got, tt.n)
assertAllowedChars(t, allowed, got)
})
}
}
func TestUpper(t *testing.T) {
t.Parallel()
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := Upper(tt.n)
assert.Len(t, got, tt.n)
assertAllowedChars(t, allowed, got)
})
}
}
func TestUpperNumeric(t *testing.T) {
t.Parallel()
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := UpperNumeric(tt.n)
assert.Len(t, got, tt.n)
assertAllowedChars(t, allowed, got)
})
}
}
func TestLower(t *testing.T) {
t.Parallel()
allowed := "abcdefghijklmnopqrstuvwxyz"
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := Lower(tt.n)
assert.Len(t, got, tt.n)
assertAllowedChars(t, allowed, got)
})
}
}
func TestLowerNumeric(t *testing.T) {
t.Parallel()
allowed := "abcdefghijklmnopqrstuvwxyz0123456789"
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := LowerNumeric(tt.n)
assert.Len(t, got, tt.n)
assertAllowedChars(t, allowed, got)
})
}
}
var stringTestCases = []struct {
name string
n int
alphabet string
panicErrIs error
panicStr string
}{
{
name: "greek",
n: 32,
alphabet: "αβγδεζηθικλμνξοπρστυφχψωςΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΩ" +
"άίόύώέϊϋΐΰΆΈΌΏΎΊ",
panicErrIs: rands.ErrNonASCIIAlphabet,
panicStr: "rands: alphabet contains non-ASCII characters",
},
{
name: "chinese",
n: 32,
alphabet: "的一是不了人我在有他这为之大来以个中上们到说国和地也子" +
"时道出而要于就下得可你年生",
panicErrIs: rands.ErrNonASCIIAlphabet,
panicStr: "rands: alphabet contains non-ASCII characters",
},
{
name: "japanese",
n: 32,
alphabet: "一九七二人入八力十下三千上口土夕大女子小山川五天中六円" +
"手文日月木水火犬王正出本右四",
panicErrIs: rands.ErrNonASCIIAlphabet,
panicStr: "rands: alphabet contains non-ASCII characters",
},
{
name: "n=0",
n: 0,
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
},
{
name: "n=1",
n: 1,
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
},
{
name: "n=2",
n: 2,
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
},
{
name: "n=7",
n: 7,
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
},
{
name: "n=8",
n: 8,
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
},
{
name: "n=16",
n: 16,
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
},
{
name: "n=32",
n: 32,
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
},
{
name: "n=128",
n: 128,
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
},
{
name: "n=1024",
n: 1024,
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
},
{
name: "n=409600",
n: 409600,
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
},
{
name: "n=2394345",
n: 2394345,
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
},
{
name: "uppercase",
n: 16,
alphabet: "ABCDEFGHJKMNPRSTUVWXYZ",
},
{
name: "lowercase",
n: 16,
alphabet: "abcdefghjkmnprstuvwxyz",
},
}
func TestString(t *testing.T) {
t.Parallel()
for _, tt := range stringTestCases {
t.Run(tt.name, func(t *testing.T) {
var got string
p := recoverPanic(func() {
got = String(tt.n, tt.alphabet)
})
if tt.panicErrIs == nil || tt.panicStr == "" {
assert.Len(t, []rune(got), tt.n)
assertAllowedChars(t, tt.alphabet, got)
}
if tt.panicErrIs != nil {
assert.ErrorIs(t, p.(error), tt.panicErrIs)
}
if tt.panicStr != "" {
assert.EqualError(t, p.(error), tt.panicStr)
}
})
}
}
var unicodeStringTestCases = []struct {
name string
n int
alphabet string
}{
{
name: "n=0",
n: 0,
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
"手文日月木水火犬王正出本右",
},
{
name: "n=1",
n: 1,
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
"手文日月木水火犬王正出本右",
},
{
name: "n=2",
n: 2,
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
"手文日月木水火犬王正出本右",
},
{
name: "n=7",
n: 7,
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
"手文日月木水火犬王正出本右",
},
{
name: "n=8",
n: 8,
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
"手文日月木水火犬王正出本右",
},
{
name: "n=16",
n: 16,
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
"手文日月木水火犬王正出本右",
},
{
name: "n=32",
n: 32,
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
"手文日月木水火犬王正出本右",
},
{
name: "n=128",
n: 128,
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
"手文日月木水火犬王正出本右",
},
{
name: "n=1024",
n: 1024,
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
"手文日月木水火犬王正出本右",
},
{
name: "n=409600",
n: 409600,
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
"手文日月木水火犬王正出本右",
},
{
name: "n=2394345",
n: 2394345,
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
"手文日月木水火犬王正出本右",
},
{
name: "latin",
n: 32,
alphabet: "ABCDEFGHJKMNPRSTUVWXYZabcdefghjkmnprstuvwxyz",
},
{
name: "greek",
n: 32,
alphabet: "αβγδεζηθικλμνξοπρστυφχψωςΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΩ" +
"άίόύώέϊϋΐΰΆΈΌΏΎΊ",
},
{
name: "chinese",
n: 32,
alphabet: "的一是不了人我在有他这为之大来以个中上们到说国和地也子" +
"时道出而要于就下得可你年生",
},
{
name: "japanese",
n: 32,
alphabet: "一九七二人入八力十下三千上口土夕大女子小山川五天中六円" +
"手文日月木水火犬王正出本右四",
},
}
func TestUnicodeString(t *testing.T) {
t.Parallel()
for _, tt := range unicodeStringTestCases {
t.Run(tt.name, func(t *testing.T) {
got := UnicodeString(tt.n, []rune(tt.alphabet))
assert.Len(t, []rune(got), tt.n)
assertAllowedChars(t, tt.alphabet, got)
})
}
}
var dnsLabelTestCases = []struct {
name string
n int
panicErrIs error
panicStr string
}{
{
name: "n=-128",
n: -128,
panicErrIs: rands.ErrDNSLabelLength,
panicStr: "rands: DNS labels must be between 1 and 63 characters " +
"in length",
},
{
name: "n=0",
n: 0,
panicErrIs: rands.ErrDNSLabelLength,
panicStr: "rands: DNS labels must be between 1 and 63 characters " +
"in length",
},
{name: "n=1", n: 1},
{name: "n=2", n: 2},
{name: "n=3", n: 3},
{name: "n=4", n: 4},
{name: "n=5", n: 5},
{name: "n=6", n: 6},
{name: "n=7", n: 7},
{name: "n=8", n: 8},
{name: "n=16", n: 16},
{name: "n=32", n: 32},
{name: "n=63", n: 63},
{
name: "n=64",
n: 64,
panicErrIs: rands.ErrDNSLabelLength,
panicStr: "rands: DNS labels must be between 1 and 63 characters " +
"in length",
},
{
name: "n=128",
n: 128,
panicErrIs: rands.ErrDNSLabelLength,
panicStr: "rands: DNS labels must be between 1 and 63 characters " +
"in length",
},
}
func TestDNSLabel(t *testing.T) {
t.Parallel()
for _, tt := range dnsLabelTestCases {
t.Run(tt.name, func(t *testing.T) {
// generate lots of labels to increase the chances of catching any
// obscure bugs
for i := 0; i < 10000; i++ {
var got string
p := recoverPanic(func() {
got = DNSLabel(tt.n)
})
if tt.panicErrIs == nil || tt.panicStr == "" {
require.Len(t, got, tt.n)
asserDNSLabel(t, got)
}
if tt.panicErrIs != nil {
require.ErrorIs(t, p.(error), tt.panicErrIs)
}
if tt.panicStr != "" {
require.EqualError(t, p.(error), tt.panicStr)
}
}
})
}
}
func TestUUID(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 := 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")
}
}
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
}
}
//
// Helpers
//
var (
dnsLabelHeadRx = regexp.MustCompile(`^[a-z]$`)
dnsLabelBodyRx = regexp.MustCompile(`^[a-z0-9-]+$`)
dnsLabelTailRx = regexp.MustCompile(`^[a-z0-9]$`)
)
func asserDNSLabel(t *testing.T, label string) {
require.LessOrEqualf(t, len(label), 63,
`DNS label "%s" is longer than 63 characters`, label,
)
require.GreaterOrEqualf(t, len(label), 1,
`DNS label "%s" is shorter than 1 character`, label,
)
if len(label) >= 1 {
require.Regexpf(t, dnsLabelHeadRx, string(label[0]),
`DNS label "%s" must start with a-z`, label,
)
}
if len(label) >= 2 {
require.Regexpf(t, dnsLabelTailRx, string(label[len(label)-1]),
`DNS label "%s" must end with a-z0-9`, label,
)
}
if len(label) >= 3 {
require.Regexpf(t, dnsLabelBodyRx, label[1:len(label)-1],
`DNS label "%s" body must only contain a-z0-9-`, label)
}
if len(label) >= 4 {
require.NotEqualf(t, "--", label[2:4],
`DNS label "%s" cannot contain "--" as 3rd and 4th char`, label,
)
}
}
func assertAllowedChars(t *testing.T, allowed string, s string) {
invalid := ""
for _, c := range s {
if !strings.Contains(allowed, string(c)) &&
!strings.Contains(invalid, string(c)) {
invalid += string(c)
}
}
assert.Truef(
t, len(invalid) == 0, "string contains invalid chars: %s", invalid,
)
}

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 (
@@ -21,12 +23,12 @@ const (
)
var (
errNonASCIIAlphabet = fmt.Errorf(
"%w: alphabet contains non-ASCII characters", errBase,
ErrNonASCIIAlphabet = fmt.Errorf(
"%w: alphabet contains non-ASCII characters", Err,
)
errDNSLabelLength = fmt.Errorf(
"%w: DNS labels must be between 1 and 63 characters in length", errBase,
ErrDNSLabelLength = fmt.Errorf(
"%w: DNS labels must be between 1 and 63 characters in length", Err,
)
)
@@ -126,7 +128,7 @@ func LowerNumeric(n int) (string, error) {
// UnicodeString() if you need a alphabet with Unicode characters.
func String(n int, alphabet string) (string, error) {
if !isASCII(alphabet) {
return "", errNonASCIIAlphabet
return "", ErrNonASCIIAlphabet
}
l := big.NewInt(int64(len(alphabet)))
@@ -164,24 +166,22 @@ func UnicodeString(n int, alphabet []rune) (string, error) {
}
// DNSLabel returns a random string of n length in a DNS label compliant format
// as defined in RFC 1035, section 2.3.1:
// https://www.rfc-editor.org/rfc/rfc1035.html#section-2.3.1
// as defined in RFC 1035, section 2.3.1.
//
// It also adheres to RFC 5891, section 4.2.3.1:
// https://www.rfc-editor.org/rfc/rfc5891.html#section-4.2.3.1
// It also adheres to RFC 5891, section 4.2.3.1.
//
// 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:
return "", errDNSLabelLength
return "", ErrDNSLabelLength
case n == 1:
return String(1, lowerChars)
default:
@@ -235,6 +235,38 @@ 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) {
uuid, err := uuid.NewRandom()
if err != nil {
return "", err
}
return uuid.String(), nil
}
// 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 uuid.String(), nil
}
func isASCII(s string) bool {
for _, c := range s {
if c > unicode.MaxASCII {

View File

@@ -2,71 +2,142 @@ package rands_test
import (
"fmt"
"log"
"github.com/jimeh/rands"
)
func ExampleBase64() {
s, _ := rands.Base64(16)
fmt.Println(s) // => CYxqEdUB1Rzno3SyZu2g/g==
s, err := rands.Base64(16)
if err != nil {
log.Fatal(err)
}
fmt.Println(s) // => nYQLhIYTqh8oH/W4hZuXMQ==
}
func ExampleBase64URL() {
s, _ := rands.Base64URL(16)
fmt.Println(s) // => zlqw9aFqcFggbk2asn3_aQ
s, err := rands.Base64URL(16)
if err != nil {
log.Fatal(err)
}
fmt.Println(s) // => zI_zrc1l0uPT4MxncR6e5w
}
func ExampleHex() {
s, _ := rands.Hex(16)
fmt.Println(s) // => 956e2ec9e7f19ddd58bb935826926531
s, err := rands.Hex(16)
if err != nil {
log.Fatal(err)
}
fmt.Println(s) // => b59e8977a13f3c030bd2ea1002ec8081
}
func ExampleAlphanumeric() {
s, _ := rands.Alphanumeric(16)
fmt.Println(s) // => Fvk1PkrmG5crgOjT
s, err := rands.Alphanumeric(16)
if err != nil {
log.Fatal(err)
}
fmt.Println(s) // => EgPieCBO7MuWhHtj
}
func ExampleAlphabetic() {
s, _ := rands.Alphabetic(16)
fmt.Println(s) // => XEJIzcZufHkuUmRM
s, err := rands.Alphabetic(16)
if err != nil {
log.Fatal(err)
}
fmt.Println(s) // => VzcovEqvMRBWUtQC
}
func ExampleUpper() {
s, _ := rands.Upper(16)
fmt.Println(s) // => UMAGAFPPNDRGLUPZ
s, err := rands.Upper(16)
if err != nil {
log.Fatal(err)
}
fmt.Println(s) // => MCZEGPWGYKNUEDCK
}
func ExampleUpperNumeric() {
s, _ := rands.UpperNumeric(16)
fmt.Println(s) // => DF0CQS0TK9CPUO3E
s, err := rands.UpperNumeric(16)
if err != nil {
log.Fatal(err)
}
fmt.Println(s) // => 6LLPBBUW77B26X2X
}
func ExampleLower() {
s, _ := rands.Lower(16)
fmt.Println(s) // => ocsmggykzrxzfwgt
s, err := rands.Lower(16)
if err != nil {
log.Fatal(err)
}
fmt.Println(s) // => dhoqhrqljadsztaa
}
func ExampleLowerNumeric() {
s, _ := rands.LowerNumeric(16)
fmt.Println(s) // => rwlv7a1p7klqffs5
s, err := rands.LowerNumeric(16)
if err != nil {
log.Fatal(err)
}
fmt.Println(s) // => th1z1b1d24l5h8pu
}
func ExampleNumeric() {
s, _ := rands.Numeric(16)
fmt.Println(s) // => 9403373143598295
s, err := rands.Numeric(16)
if err != nil {
log.Fatal(err)
}
fmt.Println(s) // => 3378802228987741
}
func ExampleString() {
s, _ := rands.String(16, "abcdefABCDEF")
fmt.Println(s) // => adCDCaDEdeffeDeb
s, err := rands.String(16, "abcdefABCDEF")
if err != nil {
log.Fatal(err)
}
fmt.Println(s) // => BAFffADaadeeacfa
}
func ExampleUnicodeString() {
s, _ := rands.UnicodeString(16, []rune("九七二人入八力十下三千上口土夕大"))
fmt.Println(s) // => 下下口九力下土夕下土八上二夕大三
s, err := rands.UnicodeString(16, []rune("九七二人入八力十下三千上口土夕大"))
if err != nil {
log.Fatal(err)
}
fmt.Println(s) // => 八三口上土土七入力夕人力下三上力
}
func ExampleDNSLabel() {
s, _ := rands.DNSLabel(16)
fmt.Println(s) // => z0ij9o8qkbs0ru-h
s, err := rands.DNSLabel(16)
if err != nil {
log.Fatal(err)
}
fmt.Println(s) // => ab-sbh5q0gfb6sqo
}
func ExampleUUID() {
s, err := rands.UUID()
if err != nil {
log.Fatal(err)
}
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

@@ -2,16 +2,19 @@ package rands
import (
"encoding/base64"
"errors"
"encoding/hex"
"regexp"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHex(t *testing.T) {
t.Parallel()
allowed := "0123456789abcdef"
for _, tt := range testCases {
@@ -35,6 +38,8 @@ func BenchmarkHex(b *testing.B) {
}
func TestBase64(t *testing.T) {
t.Parallel()
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +
"0123456789+/="
@@ -62,6 +67,8 @@ func BenchmarkBase64(b *testing.B) {
}
func TestBase64URL(t *testing.T) {
t.Parallel()
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +
"0123456789-_"
@@ -89,6 +96,8 @@ func BenchmarkBase64URL(b *testing.B) {
}
func TestAlphanumeric(t *testing.T) {
t.Parallel()
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
for _, tt := range testCases {
@@ -112,6 +121,8 @@ func BenchmarkAlphanumeric(b *testing.B) {
}
func TestAlphabetic(t *testing.T) {
t.Parallel()
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
for _, tt := range testCases {
@@ -135,6 +146,8 @@ func BenchmarkAlphabetic(b *testing.B) {
}
func TestNumeric(t *testing.T) {
t.Parallel()
allowed := "0123456789"
for _, tt := range testCases {
@@ -158,6 +171,8 @@ func BenchmarkNumeric(b *testing.B) {
}
func TestUpper(t *testing.T) {
t.Parallel()
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
for _, tt := range testCases {
@@ -181,6 +196,8 @@ func BenchmarkUpper(b *testing.B) {
}
func TestUpperNumeric(t *testing.T) {
t.Parallel()
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
for _, tt := range testCases {
@@ -204,6 +221,8 @@ func BenchmarkUpperNumeric(b *testing.B) {
}
func TestLower(t *testing.T) {
t.Parallel()
allowed := "abcdefghijklmnopqrstuvwxyz"
for _, tt := range testCases {
@@ -227,6 +246,8 @@ func BenchmarkLower(b *testing.B) {
}
func TestLowerNumeric(t *testing.T) {
t.Parallel()
allowed := "abcdefghijklmnopqrstuvwxyz0123456789"
for _, tt := range testCases {
@@ -261,7 +282,7 @@ var stringTestCases = []struct {
n: 32,
alphabet: "αβγδεζηθικλμνξοπρστυφχψωςΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΩ" +
"άίόύώέϊϋΐΰΆΈΌΏΎΊ",
errIs: errNonASCIIAlphabet,
errIs: ErrNonASCIIAlphabet,
errStr: "rands: alphabet contains non-ASCII characters",
},
{
@@ -269,7 +290,7 @@ var stringTestCases = []struct {
n: 32,
alphabet: "的一是不了人我在有他这为之大来以个中上们到说国和地也子" +
"时道出而要于就下得可你年生",
errIs: errNonASCIIAlphabet,
errIs: ErrNonASCIIAlphabet,
errStr: "rands: alphabet contains non-ASCII characters",
},
{
@@ -277,7 +298,7 @@ var stringTestCases = []struct {
n: 32,
alphabet: "一九七二人入八力十下三千上口土夕大女子小山川五天中六円" +
"手文日月木水火犬王正出本右四",
errIs: errNonASCIIAlphabet,
errIs: ErrNonASCIIAlphabet,
errStr: "rands: alphabet contains non-ASCII characters",
},
{
@@ -348,6 +369,8 @@ var stringTestCases = []struct {
}
func TestString(t *testing.T) {
t.Parallel()
for _, tt := range stringTestCases {
t.Run(tt.name, func(t *testing.T) {
got, err := String(tt.n, tt.alphabet)
@@ -358,7 +381,7 @@ func TestString(t *testing.T) {
}
if tt.errIs != nil {
assert.True(t, errors.Is(err, errNonASCIIAlphabet))
assert.ErrorIs(t, err, tt.errIs)
}
if tt.errStr != "" {
@@ -475,6 +498,8 @@ var unicodeStringTestCases = []struct {
}
func TestUnicodeString(t *testing.T) {
t.Parallel()
for _, tt := range unicodeStringTestCases {
t.Run(tt.name, func(t *testing.T) {
got, _ := UnicodeString(tt.n, []rune(tt.alphabet))
@@ -497,54 +522,57 @@ func BenchmarkUnicodeString(b *testing.B) {
}
}
var dnsLabelTestCases = []struct {
name string
n int
errIs error
errStr string
}{
{
name: "n=-128",
n: -128,
errIs: ErrDNSLabelLength,
errStr: "rands: DNS labels must be between 1 and 63 characters " +
"in length",
},
{
name: "n=0",
n: 0,
errIs: ErrDNSLabelLength,
errStr: "rands: DNS labels must be between 1 and 63 characters " +
"in length",
},
{name: "n=1", n: 1},
{name: "n=2", n: 2},
{name: "n=3", n: 3},
{name: "n=4", n: 4},
{name: "n=5", n: 5},
{name: "n=6", n: 6},
{name: "n=7", n: 7},
{name: "n=8", n: 8},
{name: "n=16", n: 16},
{name: "n=32", n: 32},
{name: "n=63", n: 63},
{
name: "n=64",
n: 64,
errIs: ErrDNSLabelLength,
errStr: "rands: DNS labels must be between 1 and 63 characters " +
"in length",
},
{
name: "n=128",
n: 128,
errIs: ErrDNSLabelLength,
errStr: "rands: DNS labels must be between 1 and 63 characters " +
"in length",
},
}
func TestDNSLabel(t *testing.T) {
tests := []struct {
name string
n int
errIs error
errStr string
}{
{
name: "n=-128",
n: -128,
errIs: errDNSLabelLength,
errStr: "rands: DNS labels must be between 1 and 63 characters " +
"in length",
},
{
name: "n=0",
n: 0,
errIs: errDNSLabelLength,
errStr: "rands: DNS labels must be between 1 and 63 characters " +
"in length",
},
{name: "n=1", n: 1},
{name: "n=2", n: 2},
{name: "n=3", n: 3},
{name: "n=4", n: 4},
{name: "n=5", n: 5},
{name: "n=6", n: 6},
{name: "n=7", n: 7},
{name: "n=8", n: 8},
{name: "n=16", n: 16},
{name: "n=32", n: 32},
{name: "n=63", n: 63},
{
name: "n=64",
n: 64,
errIs: errDNSLabelLength,
errStr: "rands: DNS labels must be between 1 and 63 characters " +
"in length",
},
{
name: "n=128",
n: 128,
errIs: errDNSLabelLength,
errStr: "rands: DNS labels must be between 1 and 63 characters " +
"in length",
},
}
for _, tt := range tests {
t.Parallel()
for _, tt := range dnsLabelTestCases {
t.Run(tt.name, func(t *testing.T) {
// generate lots of labels to increase the chances of catching any
// obscure bugs
@@ -557,7 +585,7 @@ func TestDNSLabel(t *testing.T) {
}
if tt.errIs != nil {
require.True(t, errors.Is(err, errDNSLabelLength))
require.ErrorIs(t, err, tt.errIs)
}
if tt.errStr != "" {
@@ -568,6 +596,116 @@ func TestDNSLabel(t *testing.T) {
}
}
func BenchmarkDNSLabel(b *testing.B) {
for _, tt := range dnsLabelTestCases {
b.Run(tt.name, func(b *testing.B) {
for n := 0; n < b.N; n++ {
_, _ = DNSLabel(tt.n)
}
})
}
}
func TestUUID(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 := 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")
}
}
func BenchmarkUUID(b *testing.B) {
for n := 0; n < b.N; n++ {
_, _ = UUID()
}
}
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)
}
}