diff options
| author | Shulhan <ms@kilabit.info> | 2026-03-10 13:18:11 +0700 |
|---|---|---|
| committer | Shulhan <ms@kilabit.info> | 2026-03-10 13:23:54 +0700 |
| commit | fcb3423fc68a8500b1ddd87e12c75ca1f607ad8b (patch) | |
| tree | 41f3ef02ea60a80cf2ba649cc1b7b616c3dba948 /lib | |
| parent | 3f780768650cf8a55d2f7310f09c7a936cf0ae16 (diff) | |
| download | pakakeh.go-fcb3423fc68a8500b1ddd87e12c75ca1f607ad8b.tar.xz | |
lib/uuidv7: new package that implements UUID version 7
The uuidv7 package provides type UUIDv7 as the container for UUID
version 7 that satisfy the [database/sql], [encoding/gob], and
[encoding/json].
The implementation follow RFC 9562.
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/uuidv7/uuidv7.go | 198 | ||||
| -rw-r--r-- | lib/uuidv7/uuidv7_example_test.go | 130 | ||||
| -rw-r--r-- | lib/uuidv7/uuidv7_test.go | 88 |
3 files changed, 416 insertions, 0 deletions
diff --git a/lib/uuidv7/uuidv7.go b/lib/uuidv7/uuidv7.go new file mode 100644 index 00000000..5a6b6e03 --- /dev/null +++ b/lib/uuidv7/uuidv7.go @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: BSD-3-Clause +// SPDX-FileCopyrightText: 2026 M. Shulhan <ms@kilabit.info> + +// Package uuidv7 implements UUID version 7 as defined in RFC 9562. +package uuidv7 + +import ( + "crypto/rand" + "encoding/binary" + "encoding/hex" + "fmt" + "time" +) + +const hexTable = `0123456789ABCDEF` +const variantMask uint64 = 0x8000_0000_0000_0000 + +// Now defines the function variable that return the current time in UTC. +// This identifier is exported to simplify testing. +var Now = func() time.Time { + return time.Now().UTC() +} + +// Rand defines the function variable that return a random uint64. +// This identifier is exported to simplify testing. +var Rand = func() (v uint64) { + b := make([]byte, 8) + rand.Read(b) + v, _ = binary.Uvarint(b) + return v +} + +// UUIDv7 defines the container for UUID version 7 that satisfy the +// [database/sql], [encoding/gob], and [encoding/json]. +type UUIDv7 struct { + // The high 8 octets store the unix timestamp in milliseconds (6 + // octets), the version (4 bits), and clock precision (12 bits). + high uint64 + + // The low 8 octets store the variant (2 bits) and random number (62 + // bits). + low uint64 +} + +// Generate generates new UUID version 7. +func Generate() (id UUIDv7) { + now := Now() + generate(&now, &id) + return id +} + +func generate(t *time.Time, id *UUIDv7) { + unixns := uint64(t.UnixNano()) // 1773066002123456700 + millis := uint64(unixns / 1e6) // 1773066002123 + id.high = millis << 16 // 0x019c_d2f8_1ecb_0000 + id.high |= 0x7000 // 0x019c_d2f8_1ecb_7000 + prec := unixns - (millis * 1e6) // 456700 + clockPrec := uint64((prec * 4096) / 1e6) // 1870 + id.high |= clockPrec // 0x019c_d2f8_1ecb_774e + rand := Rand() // 0xffff_ffff_ffff_ffff + rand >>= 2 // 0x3fff_ffff_ffff_ffff + id.low = variantMask | rand // 0xbfff_ffff_ffff_ffff +} + +// Parse parses the UUIDv7 formated string and return it as non-zero id. +// If the string s is invalid UUIDv7 it will return zero id. +func Parse(s string) (id UUIDv7) { + id.UnmarshalText([]byte(s)) + return id +} + +// Bytes returns id as a stream of binary. +func (id UUIDv7) Bytes() (data []byte) { + data = make([]byte, 0, binary.MaxVarintLen64*2) + data = binary.BigEndian.AppendUint64(data, id.high) + data = binary.BigEndian.AppendUint64(data, id.low) + return data +} + +// IsZero returns true if all bits is zero. +func (id UUIDv7) IsZero() bool { + return id.high == 0 && id.low == 0 +} + +// MarshalBinary encodes the id to binary for [encoding/gob]. +func (id UUIDv7) MarshalBinary() (data []byte, err error) { + data = id.Bytes() + return data, nil +} + +// MarshalText encodes the id to JSON for [encoding/json]. +func (id UUIDv7) MarshalText() (data []byte, err error) { + v := id.String() + return []byte(v), nil +} + +// UnmarshalBinary decodes the data into id for [encoding/gob]. +func (id *UUIDv7) UnmarshalBinary(data []byte) (err error) { + if len(data) != 16 { + return fmt.Errorf(`uuidv7: invalid length %d`, len(data)) + } + ver := data[6] >> 4 + if ver != 7 { + return fmt.Errorf(`uuidv7: invalid version %d`, ver) + } + id.high = binary.BigEndian.Uint64(data[:8]) + id.low = binary.BigEndian.Uint64(data[8:]) + return nil +} + +// UnmarshalText decodes the JSON data into id for [encoding/json]. +func (id *UUIDv7) UnmarshalText(data []byte) (err error) { + src := make([]byte, 0, 32) + for _, c := range data { + if c == '-' { + continue + } + src = append(src, c) + } + l := len(src) + if l != 32 { + return fmt.Errorf(`uuidv7: invalid length %d`, l) + } + if src[12] != '7' { + return fmt.Errorf(`uuidv7: invalid version %c`, data[12]) + } + + l = hex.DecodedLen(len(src[:16])) + dst := make([]byte, l) + _, err = hex.Decode(dst, src[:16]) + if err != nil { + return fmt.Errorf(`uuidv7: %w`, err) + } + id.high = binary.BigEndian.Uint64(dst) + + _, err = hex.Decode(dst, src[16:]) + if err != nil { + return fmt.Errorf(`uuidv7: %w`, err) + } + id.low = binary.BigEndian.Uint64(dst) + return nil +} + +// Scan scans the raw database value into id. +// This method implement [database/sql.Scanner] interface. +func (id *UUIDv7) Scan(src any) (err error) { + switch v := src.(type) { + case []byte: + err = id.UnmarshalText(v) + if err != nil { + return err + } + default: + return fmt.Errorf(`uuidv7: Scan: invalid type %T`, src) + } + return nil +} + +// String returns the human readable format of UUIDv7 in the form of +// "0xxxxxxx-xxxx-7xxx-8xxx-xxxxxxxxxxxx". +func (id UUIDv7) String() string { + buf := make([]byte, 36) + sw := 56 + x := 0 + for sw >= 0 { + b := byte(id.high >> sw) + sw -= 8 + buf[x] = hexTable[b>>4] + buf[x+1] = hexTable[b&0x0F] + x += 2 + if x == 8 || x == 13 { + buf[x] = '-' + x++ + } + } + buf[x] = '-' + x++ + sw = 56 + for sw >= 0 { + b := byte(id.low >> sw) + sw -= 8 + buf[x] = hexTable[b>>4] + buf[x+1] = hexTable[b&0x0F] + x += 2 + if x == 23 { + buf[x] = '-' + x++ + } + } + return string(buf) +} + +// Time returns the Unix epoch timestamp in milliseconds stored in the UUIDv7. +func (id *UUIDv7) Time() time.Time { + millis := id.high >> 16 + t := time.UnixMilli(int64(millis)) + return t +} diff --git a/lib/uuidv7/uuidv7_example_test.go b/lib/uuidv7/uuidv7_example_test.go new file mode 100644 index 00000000..c0472dd5 --- /dev/null +++ b/lib/uuidv7/uuidv7_example_test.go @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: BSD-3-Clause +// SPDX-FileCopyrightText: 2026 M. Shulhan <ms@kilabit.info> + +package uuidv7_test + +import ( + "bytes" + "encoding/gob" + "encoding/json" + "fmt" + "log" + "time" + + "git.sr.ht/~shulhan/pakakeh.go/lib/uuidv7" +) + +func ExampleGenerate() { + now := time.Date(2026, 3, 9, 14, 20, 0, 123456700, time.UTC) + uuidv7.Now = func() time.Time { + now = now.Add(time.Second) + return now + } + uuidv7.Rand = func() uint64 { + return 0xFFFF_FFFF_FFFF_FFFF + } + + id := uuidv7.Generate() + fmt.Printf("%s %s\n", id, id.Time().UTC()) + id = uuidv7.Generate() + fmt.Printf("%s %s\n", id, id.Time().UTC()) + + // Output: + // 019CD2F8-1AE3-774E-BFFF-FFFFFFFFFFFF 2026-03-09 14:20:01.123 +0000 UTC + // 019CD2F8-1ECB-774E-BFFF-FFFFFFFFFFFF 2026-03-09 14:20:02.123 +0000 UTC +} + +func ExampleParse() { + id := uuidv7.Parse(``) + fmt.Printf("%t %s %s\n", id.IsZero(), id, id.Time().UTC()) + + id = uuidv7.Parse(`019CD2F8-1AE3-774E-BFFF-FFFFFFFFFFFF`) + fmt.Printf("%t %s %s\n", id.IsZero(), id, id.Time().UTC()) + + // Output: + // true 00000000-0000-0000-0000-000000000000 1970-01-01 00:00:00 +0000 UTC + // false 019CD2F8-1AE3-774E-BFFF-FFFFFFFFFFFF 2026-03-09 14:20:01.123 +0000 UTC +} + +func ExampleUUIDv7_MarshalBinary() { + now := time.Date(2026, 3, 9, 14, 20, 0, 123456700, time.UTC) + uuidv7.Now = func() time.Time { + now = now.Add(time.Second) + return now + } + uuidv7.Rand = func() uint64 { + return 0xFFFF_FFFF_FFFF_FFFF + } + + var buf bytes.Buffer + enc := gob.NewEncoder(&buf) + id := uuidv7.Generate() + err := enc.Encode(id) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Sent: %s\n", id) + + dec := gob.NewDecoder(&buf) + var gotID uuidv7.UUIDv7 + err = dec.Decode(&gotID) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Receive: %s\n", gotID) + + // Output: + // Sent: 019CD2F8-1AE3-774E-BFFF-FFFFFFFFFFFF + // Receive: 019CD2F8-1AE3-774E-BFFF-FFFFFFFFFFFF +} + +func ExampleUUIDv7_MarshalText() { + now := time.Date(2026, 3, 9, 14, 20, 0, 123456700, time.UTC) + uuidv7.Now = func() time.Time { + now = now.Add(time.Second) + return now + } + uuidv7.Rand = func() uint64 { + return 0xFFFF_FFFF_FFFF_FFFF + } + + type T struct { + ID uuidv7.UUIDv7 + } + + t := T{ + ID: uuidv7.Generate(), + } + jsonb, err := json.Marshal(t) + if err != nil { + log.Fatal(err) + } + fmt.Printf("%s\n", jsonb) + + var got T + err = json.Unmarshal(jsonb, &got) + if err != nil { + log.Fatal(err) + } + fmt.Printf("%+v\n", got) + + // Output: + // {"ID":"019CD2F8-1AE3-774E-BFFF-FFFFFFFFFFFF"} + // {ID:019CD2F8-1AE3-774E-BFFF-FFFFFFFFFFFF} +} + +func ExampleUUIDv7_Scan() { + var id uuidv7.UUIDv7 + + // Scan the value from the database: + // + // dbc.QueryRow(`SELECT id ...`, &id) + + err := id.Scan([]byte(`017F22E2-79B0-7CC3-98C4-DC0C0C07398F`)) + if err != nil { + log.Fatal(err) + } + fmt.Printf("%s\n", id) + // Output: + // 017F22E2-79B0-7CC3-98C4-DC0C0C07398F +} diff --git a/lib/uuidv7/uuidv7_test.go b/lib/uuidv7/uuidv7_test.go new file mode 100644 index 00000000..70ca315c --- /dev/null +++ b/lib/uuidv7/uuidv7_test.go @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: BSD-3-Clause +// SPDX-FileCopyrightText: 2026 M. Shulhan <ms@kilabit.info> + +package uuidv7 + +import ( + "testing" + + "git.sr.ht/~shulhan/pakakeh.go/lib/test" +) + +func TestUUIDv7_Scan(t *testing.T) { + listCase := []struct { + desc string + data any + expError string + }{{ + desc: `With empty data`, + data: `017F22E2-79B0-7CC3-98C4-DC0C0C07398F`, + expError: `uuidv7: Scan: invalid type string`, + }, { + desc: `With invalid version`, + data: []byte(`5c146b14-3c52-4afd-938a-375d0df1fbf6`), + expError: `uuidv7: invalid version 2`, + }} + for _, tc := range listCase { + var id UUIDv7 + err := id.Scan(tc.data) + if err != nil { + test.Assert(t, tc.desc, tc.expError, err.Error()) + continue + } + } +} + +func TestUUIDv7_UnmarshalBinary(t *testing.T) { + listCase := []struct { + desc string + expError string + data []byte + }{{ + desc: `With empty data`, + expError: `uuidv7: invalid length 0`, + }, { + desc: `With non-version 7`, + data: []byte{1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 1, 2, 3, 4}, + expError: `uuidv7: invalid version 0`, + }} + for _, tc := range listCase { + var id UUIDv7 + err := id.UnmarshalBinary(tc.data) + if err != nil { + test.Assert(t, tc.desc, tc.expError, err.Error()) + continue + } + } +} + +func TestUUIDv7_UnmarshalText(t *testing.T) { + listCase := []struct { + desc string + expError string + data []byte + }{{ + desc: `With empty data`, + expError: `uuidv7: invalid length 0`, + }, { + desc: `With non-version 7`, + data: []byte(`5c146b14-3c52-4afd-938a-375d0df1fbf6`), + expError: `uuidv7: invalid version 2`, + }, { + desc: `With invalid hex (high)`, + data: []byte(`X17F22E2-79B0-7CC3-98C4-DC0C0C07398F`), + expError: `uuidv7: encoding/hex: invalid byte: U+0058 'X'`, + }, { + desc: `With invalid hex (low)`, + data: []byte(`017F22E2-79B0-7CC3-98C4-DC0C0C07398X`), + expError: `uuidv7: encoding/hex: invalid byte: U+0058 'X'`, + }} + for _, tc := range listCase { + var id UUIDv7 + err := id.UnmarshalText(tc.data) + if err != nil { + test.Assert(t, tc.desc, tc.expError, err.Error()) + continue + } + } +} |
