From cd71d5f423b76646738d259662603e7791bea593 Mon Sep 17 00:00:00 2001 From: Vojtech Vitek Date: Mon, 30 Mar 2026 16:31:31 +0200 Subject: [PATCH 1/9] Explore Any prefix --- typeid.go | 20 ++++++++++++++++ uuid.go | 66 +++++++++++++++++++++++++++++++++++++++++++++------- uuid_test.go | 54 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 8 deletions(-) diff --git a/typeid.go b/typeid.go index 88526fb..d2304f7 100644 --- a/typeid.go +++ b/typeid.go @@ -10,6 +10,13 @@ type Prefixer interface { Prefix() string } +// AnyPrefix enables [UUID[AnyPrefix]] to parse any prefix (including none) while +// preserving the prefix for [UUID.Prefix], [UUID.SetPrefix], and text marshaling. +// The [AnyPrefix.Prefix] method always returns "" and is not used for validation. +type AnyPrefix struct{} + +func (AnyPrefix) Prefix() string { return "" } + var ( ErrOnlyV7 = errors.New("typeid: only UUIDv7 is supported") ErrZeroUUID = errors.New("typeid: zero UUID") @@ -39,6 +46,19 @@ func splitTypeid[P Prefixer](s string, suffixLen int) (suffix string, err error) return s[sep+1:], nil } +// splitTypeidAny splits s into prefix and suffix. If s is exactly suffixLen +// characters, the prefix is empty and the whole string is the suffix. +func splitTypeidAny(s string, suffixLen int) (prefix string, suffix string, err error) { + if len(s) == suffixLen { + return "", s, nil + } + sep := len(s) - suffixLen - 1 + if sep < 0 || s[sep] != '_' { + return "", "", fmt.Errorf("typeid: invalid format: %q", s) + } + return s[:sep], s[sep+1:], nil +} + func growSlice(dst []byte, n int) []byte { if cap(dst)-len(dst) >= n { return dst diff --git a/uuid.go b/uuid.go index 55caaf5..b44a988 100644 --- a/uuid.go +++ b/uuid.go @@ -10,7 +10,8 @@ import ( // UUID is a type-safe UUIDv7 identifier with a compile-time prefix. // Maps to Postgres uuid. type UUID[P Prefixer] struct { - val uuid.UUID + val uuid.UUID + prefix string // only used when P is [AnyPrefix]; holds runtime prefix for parse/marshal } func NewUUID[P Prefixer]() (UUID[P], error) { @@ -29,9 +30,22 @@ func UUIDFrom[P Prefixer](u uuid.UUID) (UUID[P], error) { } func ParseUUID[P Prefixer](s string) (UUID[P], error) { - suffix, err := splitTypeid[P](s, uuidSuffixLen) - if err != nil { - return UUID[P]{}, err + var p P + var suffix string + var dynPref string + switch any(p).(type) { + case AnyPrefix: + var err error + dynPref, suffix, err = splitTypeidAny(s, uuidSuffixLen) + if err != nil { + return UUID[P]{}, err + } + default: + var err error + suffix, err = splitTypeid[P](s, uuidSuffixLen) + if err != nil { + return UUID[P]{}, err + } } b, err := decodeBase32UUID(suffix) if err != nil { @@ -41,13 +55,45 @@ func ParseUUID[P Prefixer](s string) (UUID[P], error) { if u.Version() != 7 { return UUID[P]{}, ErrOnlyV7 } - return UUID[P]{val: u}, nil + switch any(p).(type) { + case AnyPrefix: + return UUID[P]{val: u, prefix: dynPref}, nil + default: + return UUID[P]{val: u}, nil + } } -func (id UUID[P]) appendText(dst []byte) []byte { +// Prefix returns the type's fixed prefix, or the runtime prefix for [UUID[AnyPrefix]]. +func (id UUID[P]) Prefix() string { var p P - dst = growSlice(dst, len(p.Prefix())+1+uuidSuffixLen) - return appendBase32UUID(appendID[P](dst), id.val) + switch any(p).(type) { + case AnyPrefix: + return id.prefix + default: + return p.Prefix() + } +} + +// SetPrefix updates the stored prefix for [UUID[AnyPrefix]] only; it is a no-op for other P. +func (id *UUID[P]) SetPrefix(s string) { + var p P + if _, ok := any(p).(AnyPrefix); ok { + id.prefix = s + } +} + +func (id UUID[P]) appendText(dst []byte) []byte { + pref := id.Prefix() + n := uuidSuffixLen + if pref != "" { + n += len(pref) + 1 + } + dst = growSlice(dst, n) + if pref != "" { + dst = append(dst, pref...) + dst = append(dst, '_') + } + return appendBase32UUID(dst, id.val) } func (id UUID[P]) String() string { return string(id.appendText(nil)) } func (id UUID[P]) UUID() uuid.UUID { return id.val } @@ -100,5 +146,9 @@ func (id *UUID[P]) Scan(src any) (err error) { return ErrOnlyV7 } id.val = u + var p P + if _, ok := any(p).(AnyPrefix); ok { + id.prefix = "" + } return nil } diff --git a/uuid_test.go b/uuid_test.go index 0894e1d..13177b8 100644 --- a/uuid_test.go +++ b/uuid_test.go @@ -258,6 +258,60 @@ func BenchmarkUUID_Parse(b *testing.B) { } } +func TestUUID_AnyPrefix_json(t *testing.T) { + type Request struct { + ID typeid.UUID[typeid.AnyPrefix] `json:"id"` + } + + suffix := "01jcp1ss00edg828t5cy4tqkff" + inputs := []string{ + `{"id":"whatever_prefix_` + suffix + `"}`, + `{"id":"other_prefix_` + suffix + `"}`, + `{"id":"` + suffix + `"}`, + } + for _, raw := range inputs { + var req Request + if err := json.Unmarshal([]byte(raw), &req); err != nil { + t.Fatalf("Unmarshal %s: %v", raw, err) + } + if req.ID.UUID().String() == "" || req.ID.UUID().Version() != 7 { + t.Fatalf("expected v7 UUID, got %v", req.ID.UUID()) + } + } +} + +func TestUUID_AnyPrefix_prefixAndSetPrefix(t *testing.T) { + suffix := "01jcp1ss00edg828t5cy4tqkff" + id, err := typeid.ParseUUID[typeid.AnyPrefix]("foo_" + suffix) + if err != nil { + t.Fatal(err) + } + if got := id.Prefix(); got != "foo" { + t.Fatalf("Prefix() = %q, want foo", got) + } + + id.SetPrefix("bar") + if got := id.Prefix(); got != "bar" { + t.Fatalf("after SetPrefix, Prefix() = %q, want bar", got) + } + wantText := "bar_" + suffix + if got, _ := id.MarshalText(); string(got) != wantText { + t.Fatalf("MarshalText = %q, want %q", got, wantText) + } +} + +func TestUUID_AnyPrefix_SetPrefixNoOpOnFixed(t *testing.T) { + id, err := typeid.NewUUID[userPrefix]() + if err != nil { + t.Fatal(err) + } + before := id.String() + id.SetPrefix("nope") + if id.String() != before { + t.Fatalf("SetPrefix changed fixed-prefix ID: %s -> %s", before, id.String()) + } +} + func TestUUID_Sortable(t *testing.T) { a, err := typeid.NewUUID[userPrefix]() if err != nil { From 66d798a8dfd4a46b051a7d72b6d0dbf2f232044c Mon Sep 17 00:00:00 2001 From: Vojtech Vitek Date: Mon, 30 Mar 2026 23:09:13 +0200 Subject: [PATCH 2/9] Implement typeid.AnyUUID and typeid.AnyInt64 to support multiple prefixes Fixes https://github.com/go-chi/typeid/issues/4 --- any_int64.go | 107 ++++++++++++++++++++++++++++++++++++++++++++++ any_uuid.go | 114 +++++++++++++++++++++++++++++++++++++++++++++++++ crockford.go | 17 ++++++-- int64.go | 8 ++-- int64_test.go | 96 +++++++++++++++++++++++++++++++++++++++++ typeid.go | 50 ++++------------------ typeid_test.go | 10 +++++ uuid.go | 65 +++------------------------- uuid_test.go | 75 +++++++++++++++++++++++++------- 9 files changed, 418 insertions(+), 124 deletions(-) create mode 100644 any_int64.go create mode 100644 any_uuid.go diff --git a/any_int64.go b/any_int64.go new file mode 100644 index 0000000..3db2fbf --- /dev/null +++ b/any_int64.go @@ -0,0 +1,107 @@ +package typeid + +import ( + "crypto/rand" + "database/sql/driver" + "encoding/binary" + "fmt" + "strings" + "time" +) + +// AnyInt64 is a compact typeid string that accepts any prefix (or none) when parsing +// and keeps that prefix for [AnyInt64.Prefix], [AnyInt64.SetPrefix], and text marshaling. +type AnyInt64 struct { + val int64 + prefix string +} + +func NewAnyInt64() (AnyInt64, error) { + ms := time.Now().UnixMilli() + + var rb [2]byte + if _, err := rand.Read(rb[:]); err != nil { + return AnyInt64{}, fmt.Errorf("typeid: crypto/rand: %w", err) + } + r := int64(binary.BigEndian.Uint16(rb[:]) & 0x7FFF) + + return AnyInt64{val: (ms << randomBits) | r}, nil +} + +func AnyInt64From(v int64) (AnyInt64, error) { + if v <= 0 { + return AnyInt64{}, ErrNonPositiveInt + } + return AnyInt64{val: v}, nil +} + +func ParseAnyInt64(s string) (AnyInt64, error) { + j := strings.LastIndex(s, "_") + 1 + pref, suffix := s[:max(0, j-1)], s[j:] + if len(suffix) != int64SuffixLen { + return AnyInt64{}, fmt.Errorf("typeid: invalid format: %q", s) + } + v, err := decodeBase32Int64(suffix) + if err != nil { + return AnyInt64{}, err + } + if v <= 0 { + return AnyInt64{}, ErrNonPositiveInt + } + return AnyInt64{val: v, prefix: pref}, nil +} + +func (id AnyInt64) Int64() int64 { return id.val } +func (id AnyInt64) Prefix() string { return id.prefix } +func (id *AnyInt64) SetPrefix(s string) { + id.prefix = s +} + +func (id AnyInt64) appendText(dst []byte) []byte { + return appendBase32Int64(dst, id.prefix, id.val) +} + +func (id AnyInt64) String() string { return string(id.appendText(nil)) } + +func (id AnyInt64) IsZero() bool { return id.val == 0 } + +func (id AnyInt64) MarshalText() ([]byte, error) { + if id.val <= 0 { + return nil, ErrNonPositiveInt + } + return id.appendText(nil), nil +} + +func (id *AnyInt64) UnmarshalText(data []byte) error { + parsed, err := ParseAnyInt64(string(data)) + if err != nil { + return err + } + *id = parsed + return nil +} + +func (id AnyInt64) Value() (driver.Value, error) { + if id.val <= 0 { + return nil, ErrNonPositiveInt + } + return id.val, nil +} + +func (id *AnyInt64) Scan(src any) error { + var v int64 + switch sv := src.(type) { + case int64: + v = sv + case int: + v = int64(sv) + default: + return fmt.Errorf("typeid: cannot scan %T into AnyInt64", src) + } + if v <= 0 { + return ErrNonPositiveInt + } + id.val = v + id.prefix = "" + return nil +} diff --git a/any_uuid.go b/any_uuid.go new file mode 100644 index 0000000..b28184c --- /dev/null +++ b/any_uuid.go @@ -0,0 +1,114 @@ +package typeid + +import ( + "database/sql/driver" + "fmt" + "strings" + + "github.com/google/uuid" +) + +// AnyUUID is a UUIDv7 typeid string that accepts any prefix (or none) when parsing +// and keeps that prefix for [AnyUUID.Prefix], [AnyUUID.SetPrefix], and text marshaling. +type AnyUUID struct { + val uuid.UUID + prefix string +} + +func NewAnyUUID() (AnyUUID, error) { + u, err := uuid.NewV7() + if err != nil { + return AnyUUID{}, err + } + return AnyUUID{val: u}, nil +} + +func AnyUUIDFrom(u uuid.UUID) (AnyUUID, error) { + if u.Version() != 7 { + return AnyUUID{}, ErrOnlyV7 + } + return AnyUUID{val: u}, nil +} + +func ParseAnyUUID(s string) (AnyUUID, error) { + j := strings.LastIndex(s, "_") + 1 + pref, suffix := s[:max(0, j-1)], s[j:] + if len(suffix) != uuidSuffixLen { + return AnyUUID{}, fmt.Errorf("typeid: invalid format: %q", s) + } + b, err := decodeBase32UUID(suffix) + if err != nil { + return AnyUUID{}, err + } + u := uuid.UUID(b) + if u.Version() != 7 { + return AnyUUID{}, ErrOnlyV7 + } + return AnyUUID{val: u, prefix: pref}, nil +} + +func (id AnyUUID) UUID() uuid.UUID { return id.val } +func (id AnyUUID) Prefix() string { return id.prefix } +func (id *AnyUUID) SetPrefix(s string) { + id.prefix = s +} + +func (id AnyUUID) appendText(dst []byte) []byte { + return appendBase32UUID(dst, id.prefix, id.val) +} + +func (id AnyUUID) String() string { return string(id.appendText(nil)) } + +func (id AnyUUID) IsZero() bool { return id.val == uuid.UUID{} } + +func (id AnyUUID) MarshalText() ([]byte, error) { + if id.IsZero() { + return nil, ErrZeroUUID + } + return id.appendText(nil), nil +} + +func (id *AnyUUID) UnmarshalText(data []byte) error { + parsed, err := ParseAnyUUID(string(data)) + if err != nil { + return err + } + *id = parsed + return nil +} + +func (id AnyUUID) Value() (driver.Value, error) { + if id.IsZero() { + return nil, ErrZeroUUID + } + return id.val.String(), nil +} + +func (id *AnyUUID) Scan(src any) (err error) { + var u uuid.UUID + switch v := src.(type) { + case string: + if u, err = uuid.Parse(v); err != nil { + return err + } + case []byte: + switch { + case len(v) == 16: + copy(u[:], v) + default: + if u, err = uuid.ParseBytes(v); err != nil { + return err + } + } + case [16]byte: + u = uuid.UUID(v) + default: + return fmt.Errorf("typeid: cannot scan %T into AnyUUID", src) + } + if u.Version() != 7 { + return ErrOnlyV7 + } + id.val = u + id.prefix = "" + return nil +} diff --git a/crockford.go b/crockford.go index 66f0b70..6b96099 100644 --- a/crockford.go +++ b/crockford.go @@ -3,6 +3,7 @@ package typeid import ( "encoding/binary" "fmt" + "slices" "github.com/google/uuid" ) @@ -32,8 +33,12 @@ func decodeChar(c byte) (byte, error) { return v, nil } -// UUID encoding (128 bits -> 26 chars, appended to dst) -func appendBase32UUID(dst []byte, u uuid.UUID) []byte { +// appendBase32UUID appends an optional prefix and underscore, then the 26-char typeid suffix for u. +func appendBase32UUID(dst []byte, prefix string, u uuid.UUID) []byte { + dst = slices.Grow(dst, uuidSuffixLen+len(prefix)+min(1, len(prefix))) + if len(prefix) > 0 { + dst = append(append(dst, prefix...), '_') + } hi := binary.BigEndian.Uint64(u[:8]) lo := binary.BigEndian.Uint64(u[8:]) @@ -101,8 +106,12 @@ func decodeBase32UUID(s string) ([16]byte, error) { return out, nil } -// Int64 encoding (63 bits -> 13 chars, appended to dst) -func appendBase32Int64(dst []byte, n int64) []byte { +// appendBase32Int64 appends an optional prefix and underscore, then the 13-char typeid suffix for n. +func appendBase32Int64(dst []byte, prefix string, n int64) []byte { + dst = slices.Grow(dst, int64SuffixLen+len(prefix)+min(1, len(prefix))) + if len(prefix) > 0 { + dst = append(append(dst, prefix...), '_') + } u := uint64(n) var buf [int64SuffixLen]byte diff --git a/int64.go b/int64.go index 9acd44a..dc54f98 100644 --- a/int64.go +++ b/int64.go @@ -79,12 +79,12 @@ func ParseInt64[P Prefixer](s string) (Int64[P], error) { func (id Int64[P]) appendText(dst []byte) []byte { var p P - dst = growSlice(dst, len(p.Prefix())+1+int64SuffixLen) - return appendBase32Int64(appendID[P](dst), id.val) + return appendBase32Int64(dst, p.Prefix(), id.val) } + func (id Int64[P]) String() string { return string(id.appendText(nil)) } -func (id Int64[P]) Int64() int64 { return id.val } -func (id Int64[P]) IsZero() bool { return id.val == 0 } +func (id Int64[P]) Int64() int64 { return id.val } +func (id Int64[P]) IsZero() bool { return id.val == 0 } func (id Int64[P]) MarshalText() ([]byte, error) { if id.val <= 0 { return nil, ErrNonPositiveInt diff --git a/int64_test.go b/int64_test.go index 74b44bc..16ba40f 100644 --- a/int64_test.go +++ b/int64_test.go @@ -238,6 +238,102 @@ func BenchmarkInt64_Parse(b *testing.B) { } } +// ExampleAnyInt64_switchToTypedInt64 narrows [AnyInt64] to [Int64] after a prefix switch. +func ExampleAnyInt64_switchToTypedInt64() { + const payload = `{"id":"org_01hf7yat00c1s"}` + type Request struct { + ID typeid.AnyInt64 `json:"id"` + } + var req Request + if err := json.Unmarshal([]byte(payload), &req); err != nil { + fmt.Println("unmarshal:", err) + return + } + + var orgID OrgID + var err error + switch req.ID.Prefix() { + case "org": + orgID, err = typeid.Int64From[orgPrefix](req.ID.Int64()) + default: + fmt.Println("unknown prefix") + return + } + if err != nil { + fmt.Println("narrow:", err) + return + } + fmt.Println(orgID.String()) + // Output: + // org_01hf7yat00c1s +} + +func TestAnyInt64_json(t *testing.T) { + type Request struct { + ID typeid.AnyInt64 `json:"id"` + } + + suffix := "01hf7yat00c1s" + inputs := []string{ + `{"id":"whatever_` + suffix + `"}`, + `{"id":"other_prefix_` + suffix + `"}`, + `{"id":"` + suffix + `"}`, + } + for _, raw := range inputs { + var req Request + if err := json.Unmarshal([]byte(raw), &req); err != nil { + t.Fatalf("Unmarshal %s: %v", raw, err) + } + if req.ID.Int64() <= 0 { + t.Fatalf("expected positive Int64, got %d", req.ID.Int64()) + } + } +} + +func TestAnyInt64_prefixAndSetPrefix(t *testing.T) { + suffix := "01hf7yat00c1s" + id, err := typeid.ParseAnyInt64("foo_" + suffix) + if err != nil { + t.Fatal(err) + } + if got := id.Prefix(); got != "foo" { + t.Fatalf("Prefix() = %q, want foo", got) + } + + id.SetPrefix("bar") + if got := id.Prefix(); got != "bar" { + t.Fatalf("after SetPrefix, Prefix() = %q, want bar", got) + } + wantText := "bar_" + suffix + if got, _ := id.MarshalText(); string(got) != wantText { + t.Fatalf("MarshalText = %q, want %q", got, wantText) + } +} + +func TestAnyInt64_narrowToOrgPrefix(t *testing.T) { + suffix := "01hf7yat00c1s" + anyID, err := typeid.ParseAnyInt64("org_" + suffix) + if err != nil { + t.Fatal(err) + } + var orgID OrgID + switch anyID.Prefix() { + case "org": + orgID, err = typeid.Int64From[orgPrefix](anyID.Int64()) + default: + t.Fatalf("unexpected prefix %q", anyID.Prefix()) + } + if err != nil { + t.Fatal(err) + } + if orgID.Int64() != anyID.Int64() { + t.Errorf("Int64 mismatch") + } + if got := orgID.String(); got != "org_"+suffix { + t.Errorf("String() = %q", got) + } +} + func TestInt64_Sortable(t *testing.T) { a, err := typeid.NewInt64[orgPrefix]() if err != nil { diff --git a/typeid.go b/typeid.go index d2304f7..468de56 100644 --- a/typeid.go +++ b/typeid.go @@ -3,6 +3,7 @@ package typeid import ( "errors" "fmt" + "strings" ) // Prefixer is the constraint for type-safe ID prefixes. @@ -10,13 +11,6 @@ type Prefixer interface { Prefix() string } -// AnyPrefix enables [UUID[AnyPrefix]] to parse any prefix (including none) while -// preserving the prefix for [UUID.Prefix], [UUID.SetPrefix], and text marshaling. -// The [AnyPrefix.Prefix] method always returns "" and is not used for validation. -type AnyPrefix struct{} - -func (AnyPrefix) Prefix() string { return "" } - var ( ErrOnlyV7 = errors.New("typeid: only UUIDv7 is supported") ErrZeroUUID = errors.New("typeid: zero UUID") @@ -32,44 +26,16 @@ const ( // splitTypeid splits "prefix_" from the right using known suffix length. // Supports underscores in the prefix (e.g. "project_env_"). -func splitTypeid[P Prefixer](s string, suffixLen int) (suffix string, err error) { +func splitTypeid[P Prefixer](s string, suffixLen int) (string, error) { var p P want := p.Prefix() - - sep := len(s) - suffixLen - 1 - if sep < 0 || s[sep] != '_' { + j := strings.LastIndex(s, "_") + 1 // 0 = bare suffix; else first byte after last '_' + prefix, suffix := s[:max(0, j-1)], s[j:] + if len(suffix) != suffixLen { return "", fmt.Errorf("typeid: invalid format: %q", s) } - if s[:sep] != want { - return "", fmt.Errorf("typeid: prefix mismatch: expected %q, got %q", want, s[:sep]) - } - return s[sep+1:], nil -} - -// splitTypeidAny splits s into prefix and suffix. If s is exactly suffixLen -// characters, the prefix is empty and the whole string is the suffix. -func splitTypeidAny(s string, suffixLen int) (prefix string, suffix string, err error) { - if len(s) == suffixLen { - return "", s, nil - } - sep := len(s) - suffixLen - 1 - if sep < 0 || s[sep] != '_' { - return "", "", fmt.Errorf("typeid: invalid format: %q", s) + if prefix != want { + return "", fmt.Errorf("typeid: prefix mismatch: expected %q, got %q", want, prefix) } - return s[:sep], s[sep+1:], nil -} - -func growSlice(dst []byte, n int) []byte { - if cap(dst)-len(dst) >= n { - return dst - } - buf := make([]byte, len(dst), len(dst)+n) - copy(buf, dst) - return buf -} - -func appendID[P Prefixer](dst []byte) []byte { - var p P - dst = append(dst, p.Prefix()...) - return append(dst, '_') + return suffix, nil } diff --git a/typeid_test.go b/typeid_test.go index f2be655..3d488df 100644 --- a/typeid_test.go +++ b/typeid_test.go @@ -30,14 +30,24 @@ type ( var ( _ fmt.Stringer = UserID{} _ fmt.Stringer = OrgID{} + _ fmt.Stringer = typeid.AnyUUID{} + _ fmt.Stringer = typeid.AnyInt64{} _ encoding.TextMarshaler = UserID{} _ encoding.TextMarshaler = OrgID{} + _ encoding.TextMarshaler = typeid.AnyUUID{} + _ encoding.TextMarshaler = typeid.AnyInt64{} _ encoding.TextUnmarshaler = (*UserID)(nil) _ encoding.TextUnmarshaler = (*OrgID)(nil) + _ encoding.TextUnmarshaler = (*typeid.AnyUUID)(nil) + _ encoding.TextUnmarshaler = (*typeid.AnyInt64)(nil) _ driver.Valuer = UserID{} _ driver.Valuer = OrgID{} + _ driver.Valuer = typeid.AnyUUID{} + _ driver.Valuer = typeid.AnyInt64{} _ sql.Scanner = (*UserID)(nil) _ sql.Scanner = (*OrgID)(nil) + _ sql.Scanner = (*typeid.AnyUUID)(nil) + _ sql.Scanner = (*typeid.AnyInt64)(nil) ) func Example() { diff --git a/uuid.go b/uuid.go index b44a988..715a736 100644 --- a/uuid.go +++ b/uuid.go @@ -10,8 +10,7 @@ import ( // UUID is a type-safe UUIDv7 identifier with a compile-time prefix. // Maps to Postgres uuid. type UUID[P Prefixer] struct { - val uuid.UUID - prefix string // only used when P is [AnyPrefix]; holds runtime prefix for parse/marshal + val uuid.UUID } func NewUUID[P Prefixer]() (UUID[P], error) { @@ -30,22 +29,9 @@ func UUIDFrom[P Prefixer](u uuid.UUID) (UUID[P], error) { } func ParseUUID[P Prefixer](s string) (UUID[P], error) { - var p P - var suffix string - var dynPref string - switch any(p).(type) { - case AnyPrefix: - var err error - dynPref, suffix, err = splitTypeidAny(s, uuidSuffixLen) - if err != nil { - return UUID[P]{}, err - } - default: - var err error - suffix, err = splitTypeid[P](s, uuidSuffixLen) - if err != nil { - return UUID[P]{}, err - } + suffix, err := splitTypeid[P](s, uuidSuffixLen) + if err != nil { + return UUID[P]{}, err } b, err := decodeBase32UUID(suffix) if err != nil { @@ -55,45 +41,12 @@ func ParseUUID[P Prefixer](s string) (UUID[P], error) { if u.Version() != 7 { return UUID[P]{}, ErrOnlyV7 } - switch any(p).(type) { - case AnyPrefix: - return UUID[P]{val: u, prefix: dynPref}, nil - default: - return UUID[P]{val: u}, nil - } -} - -// Prefix returns the type's fixed prefix, or the runtime prefix for [UUID[AnyPrefix]]. -func (id UUID[P]) Prefix() string { - var p P - switch any(p).(type) { - case AnyPrefix: - return id.prefix - default: - return p.Prefix() - } -} - -// SetPrefix updates the stored prefix for [UUID[AnyPrefix]] only; it is a no-op for other P. -func (id *UUID[P]) SetPrefix(s string) { - var p P - if _, ok := any(p).(AnyPrefix); ok { - id.prefix = s - } + return UUID[P]{val: u}, nil } func (id UUID[P]) appendText(dst []byte) []byte { - pref := id.Prefix() - n := uuidSuffixLen - if pref != "" { - n += len(pref) + 1 - } - dst = growSlice(dst, n) - if pref != "" { - dst = append(dst, pref...) - dst = append(dst, '_') - } - return appendBase32UUID(dst, id.val) + var p P + return appendBase32UUID(dst, p.Prefix(), id.val) } func (id UUID[P]) String() string { return string(id.appendText(nil)) } func (id UUID[P]) UUID() uuid.UUID { return id.val } @@ -146,9 +99,5 @@ func (id *UUID[P]) Scan(src any) (err error) { return ErrOnlyV7 } id.val = u - var p P - if _, ok := any(p).(AnyPrefix); ok { - id.prefix = "" - } return nil } diff --git a/uuid_test.go b/uuid_test.go index 13177b8..cecc4b9 100644 --- a/uuid_test.go +++ b/uuid_test.go @@ -258,9 +258,9 @@ func BenchmarkUUID_Parse(b *testing.B) { } } -func TestUUID_AnyPrefix_json(t *testing.T) { +func TestAnyUUID_json(t *testing.T) { type Request struct { - ID typeid.UUID[typeid.AnyPrefix] `json:"id"` + ID typeid.AnyUUID `json:"id"` } suffix := "01jcp1ss00edg828t5cy4tqkff" @@ -280,9 +280,64 @@ func TestUUID_AnyPrefix_json(t *testing.T) { } } -func TestUUID_AnyPrefix_prefixAndSetPrefix(t *testing.T) { +// ExampleAnyUUID_switchToTypedUUID shows narrowing [AnyUUID] to [UUID] after inspecting [AnyUUID.Prefix]. +// Use [UUIDFrom] when the prefix matches; it keeps the same UUID bytes under the typed wrapper. +func ExampleAnyUUID_switchToTypedUUID() { + const payload = `{"id":"user_01jcp1ss00edg828t5cy4tqkff"}` + type Request struct { + ID typeid.AnyUUID `json:"id"` + } + var req Request + if err := json.Unmarshal([]byte(payload), &req); err != nil { + fmt.Println("unmarshal:", err) + return + } + + var userID UserID + var err error + switch req.ID.Prefix() { + case "user": + userID, err = typeid.UUIDFrom[userPrefix](req.ID.UUID()) + default: + fmt.Println("unknown prefix") + return + } + if err != nil { + fmt.Println("narrow:", err) + return + } + fmt.Println(userID.String()) + // Output: + // user_01jcp1ss00edg828t5cy4tqkff +} + +func TestAnyUUID_narrowToUserPrefix(t *testing.T) { + suffix := "01jcp1ss00edg828t5cy4tqkff" + anyID, err := typeid.ParseAnyUUID("user_" + suffix) + if err != nil { + t.Fatal(err) + } + var userID UserID + switch anyID.Prefix() { + case "user": + userID, err = typeid.UUIDFrom[userPrefix](anyID.UUID()) + default: + t.Fatalf("unexpected prefix %q", anyID.Prefix()) + } + if err != nil { + t.Fatal(err) + } + if userID.UUID() != anyID.UUID() { + t.Errorf("UUID mismatch") + } + if got := userID.String(); got != "user_"+suffix { + t.Errorf("String() = %q", got) + } +} + +func TestAnyUUID_prefixAndSetPrefix(t *testing.T) { suffix := "01jcp1ss00edg828t5cy4tqkff" - id, err := typeid.ParseUUID[typeid.AnyPrefix]("foo_" + suffix) + id, err := typeid.ParseAnyUUID("foo_" + suffix) if err != nil { t.Fatal(err) } @@ -300,18 +355,6 @@ func TestUUID_AnyPrefix_prefixAndSetPrefix(t *testing.T) { } } -func TestUUID_AnyPrefix_SetPrefixNoOpOnFixed(t *testing.T) { - id, err := typeid.NewUUID[userPrefix]() - if err != nil { - t.Fatal(err) - } - before := id.String() - id.SetPrefix("nope") - if id.String() != before { - t.Fatalf("SetPrefix changed fixed-prefix ID: %s -> %s", before, id.String()) - } -} - func TestUUID_Sortable(t *testing.T) { a, err := typeid.NewUUID[userPrefix]() if err != nil { From a34e2756a4c4293c63bef3278c88f635ed2fc05d Mon Sep 17 00:00:00 2001 From: Vojtech Vitek Date: Thu, 9 Apr 2026 17:03:08 +0200 Subject: [PATCH 3/9] Rename to typeid.AnyPrefix* --- any_int64.go | 107 ----------------------------------------- any_prefix_int64.go | 107 +++++++++++++++++++++++++++++++++++++++++ any_prefix_uuid.go | 114 ++++++++++++++++++++++++++++++++++++++++++++ any_uuid.go | 114 -------------------------------------------- int64_test.go | 18 +++---- typeid_test.go | 20 ++++---- uuid_test.go | 18 +++---- 7 files changed, 249 insertions(+), 249 deletions(-) delete mode 100644 any_int64.go create mode 100644 any_prefix_int64.go create mode 100644 any_prefix_uuid.go delete mode 100644 any_uuid.go diff --git a/any_int64.go b/any_int64.go deleted file mode 100644 index 3db2fbf..0000000 --- a/any_int64.go +++ /dev/null @@ -1,107 +0,0 @@ -package typeid - -import ( - "crypto/rand" - "database/sql/driver" - "encoding/binary" - "fmt" - "strings" - "time" -) - -// AnyInt64 is a compact typeid string that accepts any prefix (or none) when parsing -// and keeps that prefix for [AnyInt64.Prefix], [AnyInt64.SetPrefix], and text marshaling. -type AnyInt64 struct { - val int64 - prefix string -} - -func NewAnyInt64() (AnyInt64, error) { - ms := time.Now().UnixMilli() - - var rb [2]byte - if _, err := rand.Read(rb[:]); err != nil { - return AnyInt64{}, fmt.Errorf("typeid: crypto/rand: %w", err) - } - r := int64(binary.BigEndian.Uint16(rb[:]) & 0x7FFF) - - return AnyInt64{val: (ms << randomBits) | r}, nil -} - -func AnyInt64From(v int64) (AnyInt64, error) { - if v <= 0 { - return AnyInt64{}, ErrNonPositiveInt - } - return AnyInt64{val: v}, nil -} - -func ParseAnyInt64(s string) (AnyInt64, error) { - j := strings.LastIndex(s, "_") + 1 - pref, suffix := s[:max(0, j-1)], s[j:] - if len(suffix) != int64SuffixLen { - return AnyInt64{}, fmt.Errorf("typeid: invalid format: %q", s) - } - v, err := decodeBase32Int64(suffix) - if err != nil { - return AnyInt64{}, err - } - if v <= 0 { - return AnyInt64{}, ErrNonPositiveInt - } - return AnyInt64{val: v, prefix: pref}, nil -} - -func (id AnyInt64) Int64() int64 { return id.val } -func (id AnyInt64) Prefix() string { return id.prefix } -func (id *AnyInt64) SetPrefix(s string) { - id.prefix = s -} - -func (id AnyInt64) appendText(dst []byte) []byte { - return appendBase32Int64(dst, id.prefix, id.val) -} - -func (id AnyInt64) String() string { return string(id.appendText(nil)) } - -func (id AnyInt64) IsZero() bool { return id.val == 0 } - -func (id AnyInt64) MarshalText() ([]byte, error) { - if id.val <= 0 { - return nil, ErrNonPositiveInt - } - return id.appendText(nil), nil -} - -func (id *AnyInt64) UnmarshalText(data []byte) error { - parsed, err := ParseAnyInt64(string(data)) - if err != nil { - return err - } - *id = parsed - return nil -} - -func (id AnyInt64) Value() (driver.Value, error) { - if id.val <= 0 { - return nil, ErrNonPositiveInt - } - return id.val, nil -} - -func (id *AnyInt64) Scan(src any) error { - var v int64 - switch sv := src.(type) { - case int64: - v = sv - case int: - v = int64(sv) - default: - return fmt.Errorf("typeid: cannot scan %T into AnyInt64", src) - } - if v <= 0 { - return ErrNonPositiveInt - } - id.val = v - id.prefix = "" - return nil -} diff --git a/any_prefix_int64.go b/any_prefix_int64.go new file mode 100644 index 0000000..e1fb876 --- /dev/null +++ b/any_prefix_int64.go @@ -0,0 +1,107 @@ +package typeid + +import ( + "crypto/rand" + "database/sql/driver" + "encoding/binary" + "fmt" + "strings" + "time" +) + +// AnyPrefixInt64 is a compact typeid string that accepts any prefix (or none) when parsing +// and keeps that prefix for [AnyPrefixInt64.Prefix], [AnyPrefixInt64.SetPrefix], and text marshaling. +type AnyPrefixInt64 struct { + val int64 + prefix string +} + +func NewAnyPrefixInt64() (AnyPrefixInt64, error) { + ms := time.Now().UnixMilli() + + var rb [2]byte + if _, err := rand.Read(rb[:]); err != nil { + return AnyPrefixInt64{}, fmt.Errorf("typeid: crypto/rand: %w", err) + } + r := int64(binary.BigEndian.Uint16(rb[:]) & 0x7FFF) + + return AnyPrefixInt64{val: (ms << randomBits) | r}, nil +} + +func AnyPrefixInt64From(v int64) (AnyPrefixInt64, error) { + if v <= 0 { + return AnyPrefixInt64{}, ErrNonPositiveInt + } + return AnyPrefixInt64{val: v}, nil +} + +func ParseAnyPrefixInt64(s string) (AnyPrefixInt64, error) { + j := strings.LastIndex(s, "_") + 1 + pref, suffix := s[:max(0, j-1)], s[j:] + if len(suffix) != int64SuffixLen { + return AnyPrefixInt64{}, fmt.Errorf("typeid: invalid format: %q", s) + } + v, err := decodeBase32Int64(suffix) + if err != nil { + return AnyPrefixInt64{}, err + } + if v <= 0 { + return AnyPrefixInt64{}, ErrNonPositiveInt + } + return AnyPrefixInt64{val: v, prefix: pref}, nil +} + +func (id AnyPrefixInt64) Int64() int64 { return id.val } +func (id AnyPrefixInt64) Prefix() string { return id.prefix } +func (id *AnyPrefixInt64) SetPrefix(s string) { + id.prefix = s +} + +func (id AnyPrefixInt64) appendText(dst []byte) []byte { + return appendBase32Int64(dst, id.prefix, id.val) +} + +func (id AnyPrefixInt64) String() string { return string(id.appendText(nil)) } + +func (id AnyPrefixInt64) IsZero() bool { return id.val == 0 } + +func (id AnyPrefixInt64) MarshalText() ([]byte, error) { + if id.val <= 0 { + return nil, ErrNonPositiveInt + } + return id.appendText(nil), nil +} + +func (id *AnyPrefixInt64) UnmarshalText(data []byte) error { + parsed, err := ParseAnyPrefixInt64(string(data)) + if err != nil { + return err + } + *id = parsed + return nil +} + +func (id AnyPrefixInt64) Value() (driver.Value, error) { + if id.val <= 0 { + return nil, ErrNonPositiveInt + } + return id.val, nil +} + +func (id *AnyPrefixInt64) Scan(src any) error { + var v int64 + switch sv := src.(type) { + case int64: + v = sv + case int: + v = int64(sv) + default: + return fmt.Errorf("typeid: cannot scan %T into AnyPrefixInt64", src) + } + if v <= 0 { + return ErrNonPositiveInt + } + id.val = v + id.prefix = "" + return nil +} diff --git a/any_prefix_uuid.go b/any_prefix_uuid.go new file mode 100644 index 0000000..a08ad31 --- /dev/null +++ b/any_prefix_uuid.go @@ -0,0 +1,114 @@ +package typeid + +import ( + "database/sql/driver" + "fmt" + "strings" + + "github.com/google/uuid" +) + +// AnyPrefixUUID is a UUIDv7 typeid string that accepts any prefix (or none) when parsing +// and keeps that prefix for [AnyPrefixUUID.Prefix], [AnyPrefixUUID.SetPrefix], and text marshaling. +type AnyPrefixUUID struct { + val uuid.UUID + prefix string +} + +func NewAnyPrefixUUID() (AnyPrefixUUID, error) { + u, err := uuid.NewV7() + if err != nil { + return AnyPrefixUUID{}, err + } + return AnyPrefixUUID{val: u}, nil +} + +func AnyPrefixUUIDFrom(u uuid.UUID) (AnyPrefixUUID, error) { + if u.Version() != 7 { + return AnyPrefixUUID{}, ErrOnlyV7 + } + return AnyPrefixUUID{val: u}, nil +} + +func ParseAnyPrefixUUID(s string) (AnyPrefixUUID, error) { + j := strings.LastIndex(s, "_") + 1 + pref, suffix := s[:max(0, j-1)], s[j:] + if len(suffix) != uuidSuffixLen { + return AnyPrefixUUID{}, fmt.Errorf("typeid: invalid format: %q", s) + } + b, err := decodeBase32UUID(suffix) + if err != nil { + return AnyPrefixUUID{}, err + } + u := uuid.UUID(b) + if u.Version() != 7 { + return AnyPrefixUUID{}, ErrOnlyV7 + } + return AnyPrefixUUID{val: u, prefix: pref}, nil +} + +func (id AnyPrefixUUID) UUID() uuid.UUID { return id.val } +func (id AnyPrefixUUID) Prefix() string { return id.prefix } +func (id *AnyPrefixUUID) SetPrefix(s string) { + id.prefix = s +} + +func (id AnyPrefixUUID) appendText(dst []byte) []byte { + return appendBase32UUID(dst, id.prefix, id.val) +} + +func (id AnyPrefixUUID) String() string { return string(id.appendText(nil)) } + +func (id AnyPrefixUUID) IsZero() bool { return id.val == uuid.UUID{} } + +func (id AnyPrefixUUID) MarshalText() ([]byte, error) { + if id.IsZero() { + return nil, ErrZeroUUID + } + return id.appendText(nil), nil +} + +func (id *AnyPrefixUUID) UnmarshalText(data []byte) error { + parsed, err := ParseAnyPrefixUUID(string(data)) + if err != nil { + return err + } + *id = parsed + return nil +} + +func (id AnyPrefixUUID) Value() (driver.Value, error) { + if id.IsZero() { + return nil, ErrZeroUUID + } + return id.val.String(), nil +} + +func (id *AnyPrefixUUID) Scan(src any) (err error) { + var u uuid.UUID + switch v := src.(type) { + case string: + if u, err = uuid.Parse(v); err != nil { + return err + } + case []byte: + switch { + case len(v) == 16: + copy(u[:], v) + default: + if u, err = uuid.ParseBytes(v); err != nil { + return err + } + } + case [16]byte: + u = uuid.UUID(v) + default: + return fmt.Errorf("typeid: cannot scan %T into AnyPrefixUUID", src) + } + if u.Version() != 7 { + return ErrOnlyV7 + } + id.val = u + id.prefix = "" + return nil +} diff --git a/any_uuid.go b/any_uuid.go deleted file mode 100644 index b28184c..0000000 --- a/any_uuid.go +++ /dev/null @@ -1,114 +0,0 @@ -package typeid - -import ( - "database/sql/driver" - "fmt" - "strings" - - "github.com/google/uuid" -) - -// AnyUUID is a UUIDv7 typeid string that accepts any prefix (or none) when parsing -// and keeps that prefix for [AnyUUID.Prefix], [AnyUUID.SetPrefix], and text marshaling. -type AnyUUID struct { - val uuid.UUID - prefix string -} - -func NewAnyUUID() (AnyUUID, error) { - u, err := uuid.NewV7() - if err != nil { - return AnyUUID{}, err - } - return AnyUUID{val: u}, nil -} - -func AnyUUIDFrom(u uuid.UUID) (AnyUUID, error) { - if u.Version() != 7 { - return AnyUUID{}, ErrOnlyV7 - } - return AnyUUID{val: u}, nil -} - -func ParseAnyUUID(s string) (AnyUUID, error) { - j := strings.LastIndex(s, "_") + 1 - pref, suffix := s[:max(0, j-1)], s[j:] - if len(suffix) != uuidSuffixLen { - return AnyUUID{}, fmt.Errorf("typeid: invalid format: %q", s) - } - b, err := decodeBase32UUID(suffix) - if err != nil { - return AnyUUID{}, err - } - u := uuid.UUID(b) - if u.Version() != 7 { - return AnyUUID{}, ErrOnlyV7 - } - return AnyUUID{val: u, prefix: pref}, nil -} - -func (id AnyUUID) UUID() uuid.UUID { return id.val } -func (id AnyUUID) Prefix() string { return id.prefix } -func (id *AnyUUID) SetPrefix(s string) { - id.prefix = s -} - -func (id AnyUUID) appendText(dst []byte) []byte { - return appendBase32UUID(dst, id.prefix, id.val) -} - -func (id AnyUUID) String() string { return string(id.appendText(nil)) } - -func (id AnyUUID) IsZero() bool { return id.val == uuid.UUID{} } - -func (id AnyUUID) MarshalText() ([]byte, error) { - if id.IsZero() { - return nil, ErrZeroUUID - } - return id.appendText(nil), nil -} - -func (id *AnyUUID) UnmarshalText(data []byte) error { - parsed, err := ParseAnyUUID(string(data)) - if err != nil { - return err - } - *id = parsed - return nil -} - -func (id AnyUUID) Value() (driver.Value, error) { - if id.IsZero() { - return nil, ErrZeroUUID - } - return id.val.String(), nil -} - -func (id *AnyUUID) Scan(src any) (err error) { - var u uuid.UUID - switch v := src.(type) { - case string: - if u, err = uuid.Parse(v); err != nil { - return err - } - case []byte: - switch { - case len(v) == 16: - copy(u[:], v) - default: - if u, err = uuid.ParseBytes(v); err != nil { - return err - } - } - case [16]byte: - u = uuid.UUID(v) - default: - return fmt.Errorf("typeid: cannot scan %T into AnyUUID", src) - } - if u.Version() != 7 { - return ErrOnlyV7 - } - id.val = u - id.prefix = "" - return nil -} diff --git a/int64_test.go b/int64_test.go index 16ba40f..faf0380 100644 --- a/int64_test.go +++ b/int64_test.go @@ -238,11 +238,11 @@ func BenchmarkInt64_Parse(b *testing.B) { } } -// ExampleAnyInt64_switchToTypedInt64 narrows [AnyInt64] to [Int64] after a prefix switch. -func ExampleAnyInt64_switchToTypedInt64() { +// ExampleAnyPrefixInt64_switchToTypedInt64 narrows [AnyPrefixInt64] to [Int64] after a prefix switch. +func ExampleAnyPrefixInt64_switchToTypedInt64() { const payload = `{"id":"org_01hf7yat00c1s"}` type Request struct { - ID typeid.AnyInt64 `json:"id"` + ID typeid.AnyPrefixInt64 `json:"id"` } var req Request if err := json.Unmarshal([]byte(payload), &req); err != nil { @@ -268,9 +268,9 @@ func ExampleAnyInt64_switchToTypedInt64() { // org_01hf7yat00c1s } -func TestAnyInt64_json(t *testing.T) { +func TestAnyPrefixInt64_json(t *testing.T) { type Request struct { - ID typeid.AnyInt64 `json:"id"` + ID typeid.AnyPrefixInt64 `json:"id"` } suffix := "01hf7yat00c1s" @@ -290,9 +290,9 @@ func TestAnyInt64_json(t *testing.T) { } } -func TestAnyInt64_prefixAndSetPrefix(t *testing.T) { +func TestAnyPrefixInt64_prefixAndSetPrefix(t *testing.T) { suffix := "01hf7yat00c1s" - id, err := typeid.ParseAnyInt64("foo_" + suffix) + id, err := typeid.ParseAnyPrefixInt64("foo_" + suffix) if err != nil { t.Fatal(err) } @@ -310,9 +310,9 @@ func TestAnyInt64_prefixAndSetPrefix(t *testing.T) { } } -func TestAnyInt64_narrowToOrgPrefix(t *testing.T) { +func TestAnyPrefixInt64_narrowToOrgPrefix(t *testing.T) { suffix := "01hf7yat00c1s" - anyID, err := typeid.ParseAnyInt64("org_" + suffix) + anyID, err := typeid.ParseAnyPrefixInt64("org_" + suffix) if err != nil { t.Fatal(err) } diff --git a/typeid_test.go b/typeid_test.go index 3d488df..c63cdca 100644 --- a/typeid_test.go +++ b/typeid_test.go @@ -30,24 +30,24 @@ type ( var ( _ fmt.Stringer = UserID{} _ fmt.Stringer = OrgID{} - _ fmt.Stringer = typeid.AnyUUID{} - _ fmt.Stringer = typeid.AnyInt64{} + _ fmt.Stringer = typeid.AnyPrefixUUID{} + _ fmt.Stringer = typeid.AnyPrefixInt64{} _ encoding.TextMarshaler = UserID{} _ encoding.TextMarshaler = OrgID{} - _ encoding.TextMarshaler = typeid.AnyUUID{} - _ encoding.TextMarshaler = typeid.AnyInt64{} + _ encoding.TextMarshaler = typeid.AnyPrefixUUID{} + _ encoding.TextMarshaler = typeid.AnyPrefixInt64{} _ encoding.TextUnmarshaler = (*UserID)(nil) _ encoding.TextUnmarshaler = (*OrgID)(nil) - _ encoding.TextUnmarshaler = (*typeid.AnyUUID)(nil) - _ encoding.TextUnmarshaler = (*typeid.AnyInt64)(nil) + _ encoding.TextUnmarshaler = (*typeid.AnyPrefixUUID)(nil) + _ encoding.TextUnmarshaler = (*typeid.AnyPrefixInt64)(nil) _ driver.Valuer = UserID{} _ driver.Valuer = OrgID{} - _ driver.Valuer = typeid.AnyUUID{} - _ driver.Valuer = typeid.AnyInt64{} + _ driver.Valuer = typeid.AnyPrefixUUID{} + _ driver.Valuer = typeid.AnyPrefixInt64{} _ sql.Scanner = (*UserID)(nil) _ sql.Scanner = (*OrgID)(nil) - _ sql.Scanner = (*typeid.AnyUUID)(nil) - _ sql.Scanner = (*typeid.AnyInt64)(nil) + _ sql.Scanner = (*typeid.AnyPrefixUUID)(nil) + _ sql.Scanner = (*typeid.AnyPrefixInt64)(nil) ) func Example() { diff --git a/uuid_test.go b/uuid_test.go index cecc4b9..b843f23 100644 --- a/uuid_test.go +++ b/uuid_test.go @@ -258,9 +258,9 @@ func BenchmarkUUID_Parse(b *testing.B) { } } -func TestAnyUUID_json(t *testing.T) { +func TestAnyPrefixUUID_json(t *testing.T) { type Request struct { - ID typeid.AnyUUID `json:"id"` + ID typeid.AnyPrefixUUID `json:"id"` } suffix := "01jcp1ss00edg828t5cy4tqkff" @@ -280,12 +280,12 @@ func TestAnyUUID_json(t *testing.T) { } } -// ExampleAnyUUID_switchToTypedUUID shows narrowing [AnyUUID] to [UUID] after inspecting [AnyUUID.Prefix]. +// ExampleAnyPrefixUUID_switchToTypedUUID shows narrowing [AnyPrefixUUID] to [UUID] after inspecting [AnyPrefixUUID.Prefix]. // Use [UUIDFrom] when the prefix matches; it keeps the same UUID bytes under the typed wrapper. -func ExampleAnyUUID_switchToTypedUUID() { +func ExampleAnyPrefixUUID_switchToTypedUUID() { const payload = `{"id":"user_01jcp1ss00edg828t5cy4tqkff"}` type Request struct { - ID typeid.AnyUUID `json:"id"` + ID typeid.AnyPrefixUUID `json:"id"` } var req Request if err := json.Unmarshal([]byte(payload), &req); err != nil { @@ -311,9 +311,9 @@ func ExampleAnyUUID_switchToTypedUUID() { // user_01jcp1ss00edg828t5cy4tqkff } -func TestAnyUUID_narrowToUserPrefix(t *testing.T) { +func TestAnyPrefixUUID_narrowToUserPrefix(t *testing.T) { suffix := "01jcp1ss00edg828t5cy4tqkff" - anyID, err := typeid.ParseAnyUUID("user_" + suffix) + anyID, err := typeid.ParseAnyPrefixUUID("user_" + suffix) if err != nil { t.Fatal(err) } @@ -335,9 +335,9 @@ func TestAnyUUID_narrowToUserPrefix(t *testing.T) { } } -func TestAnyUUID_prefixAndSetPrefix(t *testing.T) { +func TestAnyPrefixUUID_prefixAndSetPrefix(t *testing.T) { suffix := "01jcp1ss00edg828t5cy4tqkff" - id, err := typeid.ParseAnyUUID("foo_" + suffix) + id, err := typeid.ParseAnyPrefixUUID("foo_" + suffix) if err != nil { t.Fatal(err) } From 891bf28915a92d40dfb4fa1ef44fe3d65cfacde7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20-=20=E3=82=A2=E3=83=AC=E3=83=83=E3=82=AF=E3=82=B9?= Date: Thu, 9 Apr 2026 17:20:55 +0200 Subject: [PATCH 4/9] refactor: rename AnyPrefixUUID/AnyPrefixInt64 to AnyUUID/AnyInt64 Shorter names, consistent with UUID[P]/Int64[P] naming convention. --- any_prefix_int64.go | 107 ----------------------------------------- any_prefix_uuid.go | 114 -------------------------------------------- anyint64.go | 107 +++++++++++++++++++++++++++++++++++++++++ anyuuid.go | 114 ++++++++++++++++++++++++++++++++++++++++++++ int64_test.go | 18 +++---- typeid_test.go | 20 ++++---- uuid_test.go | 18 +++---- 7 files changed, 249 insertions(+), 249 deletions(-) delete mode 100644 any_prefix_int64.go delete mode 100644 any_prefix_uuid.go create mode 100644 anyint64.go create mode 100644 anyuuid.go diff --git a/any_prefix_int64.go b/any_prefix_int64.go deleted file mode 100644 index e1fb876..0000000 --- a/any_prefix_int64.go +++ /dev/null @@ -1,107 +0,0 @@ -package typeid - -import ( - "crypto/rand" - "database/sql/driver" - "encoding/binary" - "fmt" - "strings" - "time" -) - -// AnyPrefixInt64 is a compact typeid string that accepts any prefix (or none) when parsing -// and keeps that prefix for [AnyPrefixInt64.Prefix], [AnyPrefixInt64.SetPrefix], and text marshaling. -type AnyPrefixInt64 struct { - val int64 - prefix string -} - -func NewAnyPrefixInt64() (AnyPrefixInt64, error) { - ms := time.Now().UnixMilli() - - var rb [2]byte - if _, err := rand.Read(rb[:]); err != nil { - return AnyPrefixInt64{}, fmt.Errorf("typeid: crypto/rand: %w", err) - } - r := int64(binary.BigEndian.Uint16(rb[:]) & 0x7FFF) - - return AnyPrefixInt64{val: (ms << randomBits) | r}, nil -} - -func AnyPrefixInt64From(v int64) (AnyPrefixInt64, error) { - if v <= 0 { - return AnyPrefixInt64{}, ErrNonPositiveInt - } - return AnyPrefixInt64{val: v}, nil -} - -func ParseAnyPrefixInt64(s string) (AnyPrefixInt64, error) { - j := strings.LastIndex(s, "_") + 1 - pref, suffix := s[:max(0, j-1)], s[j:] - if len(suffix) != int64SuffixLen { - return AnyPrefixInt64{}, fmt.Errorf("typeid: invalid format: %q", s) - } - v, err := decodeBase32Int64(suffix) - if err != nil { - return AnyPrefixInt64{}, err - } - if v <= 0 { - return AnyPrefixInt64{}, ErrNonPositiveInt - } - return AnyPrefixInt64{val: v, prefix: pref}, nil -} - -func (id AnyPrefixInt64) Int64() int64 { return id.val } -func (id AnyPrefixInt64) Prefix() string { return id.prefix } -func (id *AnyPrefixInt64) SetPrefix(s string) { - id.prefix = s -} - -func (id AnyPrefixInt64) appendText(dst []byte) []byte { - return appendBase32Int64(dst, id.prefix, id.val) -} - -func (id AnyPrefixInt64) String() string { return string(id.appendText(nil)) } - -func (id AnyPrefixInt64) IsZero() bool { return id.val == 0 } - -func (id AnyPrefixInt64) MarshalText() ([]byte, error) { - if id.val <= 0 { - return nil, ErrNonPositiveInt - } - return id.appendText(nil), nil -} - -func (id *AnyPrefixInt64) UnmarshalText(data []byte) error { - parsed, err := ParseAnyPrefixInt64(string(data)) - if err != nil { - return err - } - *id = parsed - return nil -} - -func (id AnyPrefixInt64) Value() (driver.Value, error) { - if id.val <= 0 { - return nil, ErrNonPositiveInt - } - return id.val, nil -} - -func (id *AnyPrefixInt64) Scan(src any) error { - var v int64 - switch sv := src.(type) { - case int64: - v = sv - case int: - v = int64(sv) - default: - return fmt.Errorf("typeid: cannot scan %T into AnyPrefixInt64", src) - } - if v <= 0 { - return ErrNonPositiveInt - } - id.val = v - id.prefix = "" - return nil -} diff --git a/any_prefix_uuid.go b/any_prefix_uuid.go deleted file mode 100644 index a08ad31..0000000 --- a/any_prefix_uuid.go +++ /dev/null @@ -1,114 +0,0 @@ -package typeid - -import ( - "database/sql/driver" - "fmt" - "strings" - - "github.com/google/uuid" -) - -// AnyPrefixUUID is a UUIDv7 typeid string that accepts any prefix (or none) when parsing -// and keeps that prefix for [AnyPrefixUUID.Prefix], [AnyPrefixUUID.SetPrefix], and text marshaling. -type AnyPrefixUUID struct { - val uuid.UUID - prefix string -} - -func NewAnyPrefixUUID() (AnyPrefixUUID, error) { - u, err := uuid.NewV7() - if err != nil { - return AnyPrefixUUID{}, err - } - return AnyPrefixUUID{val: u}, nil -} - -func AnyPrefixUUIDFrom(u uuid.UUID) (AnyPrefixUUID, error) { - if u.Version() != 7 { - return AnyPrefixUUID{}, ErrOnlyV7 - } - return AnyPrefixUUID{val: u}, nil -} - -func ParseAnyPrefixUUID(s string) (AnyPrefixUUID, error) { - j := strings.LastIndex(s, "_") + 1 - pref, suffix := s[:max(0, j-1)], s[j:] - if len(suffix) != uuidSuffixLen { - return AnyPrefixUUID{}, fmt.Errorf("typeid: invalid format: %q", s) - } - b, err := decodeBase32UUID(suffix) - if err != nil { - return AnyPrefixUUID{}, err - } - u := uuid.UUID(b) - if u.Version() != 7 { - return AnyPrefixUUID{}, ErrOnlyV7 - } - return AnyPrefixUUID{val: u, prefix: pref}, nil -} - -func (id AnyPrefixUUID) UUID() uuid.UUID { return id.val } -func (id AnyPrefixUUID) Prefix() string { return id.prefix } -func (id *AnyPrefixUUID) SetPrefix(s string) { - id.prefix = s -} - -func (id AnyPrefixUUID) appendText(dst []byte) []byte { - return appendBase32UUID(dst, id.prefix, id.val) -} - -func (id AnyPrefixUUID) String() string { return string(id.appendText(nil)) } - -func (id AnyPrefixUUID) IsZero() bool { return id.val == uuid.UUID{} } - -func (id AnyPrefixUUID) MarshalText() ([]byte, error) { - if id.IsZero() { - return nil, ErrZeroUUID - } - return id.appendText(nil), nil -} - -func (id *AnyPrefixUUID) UnmarshalText(data []byte) error { - parsed, err := ParseAnyPrefixUUID(string(data)) - if err != nil { - return err - } - *id = parsed - return nil -} - -func (id AnyPrefixUUID) Value() (driver.Value, error) { - if id.IsZero() { - return nil, ErrZeroUUID - } - return id.val.String(), nil -} - -func (id *AnyPrefixUUID) Scan(src any) (err error) { - var u uuid.UUID - switch v := src.(type) { - case string: - if u, err = uuid.Parse(v); err != nil { - return err - } - case []byte: - switch { - case len(v) == 16: - copy(u[:], v) - default: - if u, err = uuid.ParseBytes(v); err != nil { - return err - } - } - case [16]byte: - u = uuid.UUID(v) - default: - return fmt.Errorf("typeid: cannot scan %T into AnyPrefixUUID", src) - } - if u.Version() != 7 { - return ErrOnlyV7 - } - id.val = u - id.prefix = "" - return nil -} diff --git a/anyint64.go b/anyint64.go new file mode 100644 index 0000000..3db2fbf --- /dev/null +++ b/anyint64.go @@ -0,0 +1,107 @@ +package typeid + +import ( + "crypto/rand" + "database/sql/driver" + "encoding/binary" + "fmt" + "strings" + "time" +) + +// AnyInt64 is a compact typeid string that accepts any prefix (or none) when parsing +// and keeps that prefix for [AnyInt64.Prefix], [AnyInt64.SetPrefix], and text marshaling. +type AnyInt64 struct { + val int64 + prefix string +} + +func NewAnyInt64() (AnyInt64, error) { + ms := time.Now().UnixMilli() + + var rb [2]byte + if _, err := rand.Read(rb[:]); err != nil { + return AnyInt64{}, fmt.Errorf("typeid: crypto/rand: %w", err) + } + r := int64(binary.BigEndian.Uint16(rb[:]) & 0x7FFF) + + return AnyInt64{val: (ms << randomBits) | r}, nil +} + +func AnyInt64From(v int64) (AnyInt64, error) { + if v <= 0 { + return AnyInt64{}, ErrNonPositiveInt + } + return AnyInt64{val: v}, nil +} + +func ParseAnyInt64(s string) (AnyInt64, error) { + j := strings.LastIndex(s, "_") + 1 + pref, suffix := s[:max(0, j-1)], s[j:] + if len(suffix) != int64SuffixLen { + return AnyInt64{}, fmt.Errorf("typeid: invalid format: %q", s) + } + v, err := decodeBase32Int64(suffix) + if err != nil { + return AnyInt64{}, err + } + if v <= 0 { + return AnyInt64{}, ErrNonPositiveInt + } + return AnyInt64{val: v, prefix: pref}, nil +} + +func (id AnyInt64) Int64() int64 { return id.val } +func (id AnyInt64) Prefix() string { return id.prefix } +func (id *AnyInt64) SetPrefix(s string) { + id.prefix = s +} + +func (id AnyInt64) appendText(dst []byte) []byte { + return appendBase32Int64(dst, id.prefix, id.val) +} + +func (id AnyInt64) String() string { return string(id.appendText(nil)) } + +func (id AnyInt64) IsZero() bool { return id.val == 0 } + +func (id AnyInt64) MarshalText() ([]byte, error) { + if id.val <= 0 { + return nil, ErrNonPositiveInt + } + return id.appendText(nil), nil +} + +func (id *AnyInt64) UnmarshalText(data []byte) error { + parsed, err := ParseAnyInt64(string(data)) + if err != nil { + return err + } + *id = parsed + return nil +} + +func (id AnyInt64) Value() (driver.Value, error) { + if id.val <= 0 { + return nil, ErrNonPositiveInt + } + return id.val, nil +} + +func (id *AnyInt64) Scan(src any) error { + var v int64 + switch sv := src.(type) { + case int64: + v = sv + case int: + v = int64(sv) + default: + return fmt.Errorf("typeid: cannot scan %T into AnyInt64", src) + } + if v <= 0 { + return ErrNonPositiveInt + } + id.val = v + id.prefix = "" + return nil +} diff --git a/anyuuid.go b/anyuuid.go new file mode 100644 index 0000000..b28184c --- /dev/null +++ b/anyuuid.go @@ -0,0 +1,114 @@ +package typeid + +import ( + "database/sql/driver" + "fmt" + "strings" + + "github.com/google/uuid" +) + +// AnyUUID is a UUIDv7 typeid string that accepts any prefix (or none) when parsing +// and keeps that prefix for [AnyUUID.Prefix], [AnyUUID.SetPrefix], and text marshaling. +type AnyUUID struct { + val uuid.UUID + prefix string +} + +func NewAnyUUID() (AnyUUID, error) { + u, err := uuid.NewV7() + if err != nil { + return AnyUUID{}, err + } + return AnyUUID{val: u}, nil +} + +func AnyUUIDFrom(u uuid.UUID) (AnyUUID, error) { + if u.Version() != 7 { + return AnyUUID{}, ErrOnlyV7 + } + return AnyUUID{val: u}, nil +} + +func ParseAnyUUID(s string) (AnyUUID, error) { + j := strings.LastIndex(s, "_") + 1 + pref, suffix := s[:max(0, j-1)], s[j:] + if len(suffix) != uuidSuffixLen { + return AnyUUID{}, fmt.Errorf("typeid: invalid format: %q", s) + } + b, err := decodeBase32UUID(suffix) + if err != nil { + return AnyUUID{}, err + } + u := uuid.UUID(b) + if u.Version() != 7 { + return AnyUUID{}, ErrOnlyV7 + } + return AnyUUID{val: u, prefix: pref}, nil +} + +func (id AnyUUID) UUID() uuid.UUID { return id.val } +func (id AnyUUID) Prefix() string { return id.prefix } +func (id *AnyUUID) SetPrefix(s string) { + id.prefix = s +} + +func (id AnyUUID) appendText(dst []byte) []byte { + return appendBase32UUID(dst, id.prefix, id.val) +} + +func (id AnyUUID) String() string { return string(id.appendText(nil)) } + +func (id AnyUUID) IsZero() bool { return id.val == uuid.UUID{} } + +func (id AnyUUID) MarshalText() ([]byte, error) { + if id.IsZero() { + return nil, ErrZeroUUID + } + return id.appendText(nil), nil +} + +func (id *AnyUUID) UnmarshalText(data []byte) error { + parsed, err := ParseAnyUUID(string(data)) + if err != nil { + return err + } + *id = parsed + return nil +} + +func (id AnyUUID) Value() (driver.Value, error) { + if id.IsZero() { + return nil, ErrZeroUUID + } + return id.val.String(), nil +} + +func (id *AnyUUID) Scan(src any) (err error) { + var u uuid.UUID + switch v := src.(type) { + case string: + if u, err = uuid.Parse(v); err != nil { + return err + } + case []byte: + switch { + case len(v) == 16: + copy(u[:], v) + default: + if u, err = uuid.ParseBytes(v); err != nil { + return err + } + } + case [16]byte: + u = uuid.UUID(v) + default: + return fmt.Errorf("typeid: cannot scan %T into AnyUUID", src) + } + if u.Version() != 7 { + return ErrOnlyV7 + } + id.val = u + id.prefix = "" + return nil +} diff --git a/int64_test.go b/int64_test.go index faf0380..16ba40f 100644 --- a/int64_test.go +++ b/int64_test.go @@ -238,11 +238,11 @@ func BenchmarkInt64_Parse(b *testing.B) { } } -// ExampleAnyPrefixInt64_switchToTypedInt64 narrows [AnyPrefixInt64] to [Int64] after a prefix switch. -func ExampleAnyPrefixInt64_switchToTypedInt64() { +// ExampleAnyInt64_switchToTypedInt64 narrows [AnyInt64] to [Int64] after a prefix switch. +func ExampleAnyInt64_switchToTypedInt64() { const payload = `{"id":"org_01hf7yat00c1s"}` type Request struct { - ID typeid.AnyPrefixInt64 `json:"id"` + ID typeid.AnyInt64 `json:"id"` } var req Request if err := json.Unmarshal([]byte(payload), &req); err != nil { @@ -268,9 +268,9 @@ func ExampleAnyPrefixInt64_switchToTypedInt64() { // org_01hf7yat00c1s } -func TestAnyPrefixInt64_json(t *testing.T) { +func TestAnyInt64_json(t *testing.T) { type Request struct { - ID typeid.AnyPrefixInt64 `json:"id"` + ID typeid.AnyInt64 `json:"id"` } suffix := "01hf7yat00c1s" @@ -290,9 +290,9 @@ func TestAnyPrefixInt64_json(t *testing.T) { } } -func TestAnyPrefixInt64_prefixAndSetPrefix(t *testing.T) { +func TestAnyInt64_prefixAndSetPrefix(t *testing.T) { suffix := "01hf7yat00c1s" - id, err := typeid.ParseAnyPrefixInt64("foo_" + suffix) + id, err := typeid.ParseAnyInt64("foo_" + suffix) if err != nil { t.Fatal(err) } @@ -310,9 +310,9 @@ func TestAnyPrefixInt64_prefixAndSetPrefix(t *testing.T) { } } -func TestAnyPrefixInt64_narrowToOrgPrefix(t *testing.T) { +func TestAnyInt64_narrowToOrgPrefix(t *testing.T) { suffix := "01hf7yat00c1s" - anyID, err := typeid.ParseAnyPrefixInt64("org_" + suffix) + anyID, err := typeid.ParseAnyInt64("org_" + suffix) if err != nil { t.Fatal(err) } diff --git a/typeid_test.go b/typeid_test.go index c63cdca..3d488df 100644 --- a/typeid_test.go +++ b/typeid_test.go @@ -30,24 +30,24 @@ type ( var ( _ fmt.Stringer = UserID{} _ fmt.Stringer = OrgID{} - _ fmt.Stringer = typeid.AnyPrefixUUID{} - _ fmt.Stringer = typeid.AnyPrefixInt64{} + _ fmt.Stringer = typeid.AnyUUID{} + _ fmt.Stringer = typeid.AnyInt64{} _ encoding.TextMarshaler = UserID{} _ encoding.TextMarshaler = OrgID{} - _ encoding.TextMarshaler = typeid.AnyPrefixUUID{} - _ encoding.TextMarshaler = typeid.AnyPrefixInt64{} + _ encoding.TextMarshaler = typeid.AnyUUID{} + _ encoding.TextMarshaler = typeid.AnyInt64{} _ encoding.TextUnmarshaler = (*UserID)(nil) _ encoding.TextUnmarshaler = (*OrgID)(nil) - _ encoding.TextUnmarshaler = (*typeid.AnyPrefixUUID)(nil) - _ encoding.TextUnmarshaler = (*typeid.AnyPrefixInt64)(nil) + _ encoding.TextUnmarshaler = (*typeid.AnyUUID)(nil) + _ encoding.TextUnmarshaler = (*typeid.AnyInt64)(nil) _ driver.Valuer = UserID{} _ driver.Valuer = OrgID{} - _ driver.Valuer = typeid.AnyPrefixUUID{} - _ driver.Valuer = typeid.AnyPrefixInt64{} + _ driver.Valuer = typeid.AnyUUID{} + _ driver.Valuer = typeid.AnyInt64{} _ sql.Scanner = (*UserID)(nil) _ sql.Scanner = (*OrgID)(nil) - _ sql.Scanner = (*typeid.AnyPrefixUUID)(nil) - _ sql.Scanner = (*typeid.AnyPrefixInt64)(nil) + _ sql.Scanner = (*typeid.AnyUUID)(nil) + _ sql.Scanner = (*typeid.AnyInt64)(nil) ) func Example() { diff --git a/uuid_test.go b/uuid_test.go index b843f23..cecc4b9 100644 --- a/uuid_test.go +++ b/uuid_test.go @@ -258,9 +258,9 @@ func BenchmarkUUID_Parse(b *testing.B) { } } -func TestAnyPrefixUUID_json(t *testing.T) { +func TestAnyUUID_json(t *testing.T) { type Request struct { - ID typeid.AnyPrefixUUID `json:"id"` + ID typeid.AnyUUID `json:"id"` } suffix := "01jcp1ss00edg828t5cy4tqkff" @@ -280,12 +280,12 @@ func TestAnyPrefixUUID_json(t *testing.T) { } } -// ExampleAnyPrefixUUID_switchToTypedUUID shows narrowing [AnyPrefixUUID] to [UUID] after inspecting [AnyPrefixUUID.Prefix]. +// ExampleAnyUUID_switchToTypedUUID shows narrowing [AnyUUID] to [UUID] after inspecting [AnyUUID.Prefix]. // Use [UUIDFrom] when the prefix matches; it keeps the same UUID bytes under the typed wrapper. -func ExampleAnyPrefixUUID_switchToTypedUUID() { +func ExampleAnyUUID_switchToTypedUUID() { const payload = `{"id":"user_01jcp1ss00edg828t5cy4tqkff"}` type Request struct { - ID typeid.AnyPrefixUUID `json:"id"` + ID typeid.AnyUUID `json:"id"` } var req Request if err := json.Unmarshal([]byte(payload), &req); err != nil { @@ -311,9 +311,9 @@ func ExampleAnyPrefixUUID_switchToTypedUUID() { // user_01jcp1ss00edg828t5cy4tqkff } -func TestAnyPrefixUUID_narrowToUserPrefix(t *testing.T) { +func TestAnyUUID_narrowToUserPrefix(t *testing.T) { suffix := "01jcp1ss00edg828t5cy4tqkff" - anyID, err := typeid.ParseAnyPrefixUUID("user_" + suffix) + anyID, err := typeid.ParseAnyUUID("user_" + suffix) if err != nil { t.Fatal(err) } @@ -335,9 +335,9 @@ func TestAnyPrefixUUID_narrowToUserPrefix(t *testing.T) { } } -func TestAnyPrefixUUID_prefixAndSetPrefix(t *testing.T) { +func TestAnyUUID_prefixAndSetPrefix(t *testing.T) { suffix := "01jcp1ss00edg828t5cy4tqkff" - id, err := typeid.ParseAnyPrefixUUID("foo_" + suffix) + id, err := typeid.ParseAnyUUID("foo_" + suffix) if err != nil { t.Fatal(err) } From dab57d49d8b738b9d6dad8acfb570b2a067bba15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20-=20=E3=82=A2=E3=83=AC=E3=83=83=E3=82=AF=E3=82=B9?= Date: Thu, 9 Apr 2026 17:22:19 +0200 Subject: [PATCH 5/9] feat: accept prefix in AnyUUID/AnyInt64 constructors NewAnyUUID/NewAnyInt64 and AnyUUIDFrom/AnyInt64From now take a prefix argument. Scan no longer clears the prefix. --- anyint64.go | 13 ++++++------- anyuuid.go | 13 ++++++------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/anyint64.go b/anyint64.go index 3db2fbf..2772022 100644 --- a/anyint64.go +++ b/anyint64.go @@ -9,14 +9,14 @@ import ( "time" ) -// AnyInt64 is a compact typeid string that accepts any prefix (or none) when parsing -// and keeps that prefix for [AnyInt64.Prefix], [AnyInt64.SetPrefix], and text marshaling. +// AnyInt64 is a compact identifier with a runtime-configurable prefix. +// Unlike [Int64], the prefix is not fixed at compile time. type AnyInt64 struct { val int64 prefix string } -func NewAnyInt64() (AnyInt64, error) { +func NewAnyInt64(prefix string) (AnyInt64, error) { ms := time.Now().UnixMilli() var rb [2]byte @@ -25,14 +25,14 @@ func NewAnyInt64() (AnyInt64, error) { } r := int64(binary.BigEndian.Uint16(rb[:]) & 0x7FFF) - return AnyInt64{val: (ms << randomBits) | r}, nil + return AnyInt64{val: (ms << randomBits) | r, prefix: prefix}, nil } -func AnyInt64From(v int64) (AnyInt64, error) { +func AnyInt64From(prefix string, v int64) (AnyInt64, error) { if v <= 0 { return AnyInt64{}, ErrNonPositiveInt } - return AnyInt64{val: v}, nil + return AnyInt64{val: v, prefix: prefix}, nil } func ParseAnyInt64(s string) (AnyInt64, error) { @@ -102,6 +102,5 @@ func (id *AnyInt64) Scan(src any) error { return ErrNonPositiveInt } id.val = v - id.prefix = "" return nil } diff --git a/anyuuid.go b/anyuuid.go index b28184c..33116ec 100644 --- a/anyuuid.go +++ b/anyuuid.go @@ -8,26 +8,26 @@ import ( "github.com/google/uuid" ) -// AnyUUID is a UUIDv7 typeid string that accepts any prefix (or none) when parsing -// and keeps that prefix for [AnyUUID.Prefix], [AnyUUID.SetPrefix], and text marshaling. +// AnyUUID is a UUIDv7 identifier with a runtime-configurable prefix. +// Unlike [UUID], the prefix is not fixed at compile time. type AnyUUID struct { val uuid.UUID prefix string } -func NewAnyUUID() (AnyUUID, error) { +func NewAnyUUID(prefix string) (AnyUUID, error) { u, err := uuid.NewV7() if err != nil { return AnyUUID{}, err } - return AnyUUID{val: u}, nil + return AnyUUID{val: u, prefix: prefix}, nil } -func AnyUUIDFrom(u uuid.UUID) (AnyUUID, error) { +func AnyUUIDFrom(prefix string, u uuid.UUID) (AnyUUID, error) { if u.Version() != 7 { return AnyUUID{}, ErrOnlyV7 } - return AnyUUID{val: u}, nil + return AnyUUID{val: u, prefix: prefix}, nil } func ParseAnyUUID(s string) (AnyUUID, error) { @@ -109,6 +109,5 @@ func (id *AnyUUID) Scan(src any) (err error) { return ErrOnlyV7 } id.val = u - id.prefix = "" return nil } From de325b5c715fdeb9beafe9d2bb86a287906fbefa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20-=20=E3=82=A2=E3=83=AC=E3=83=83=E3=82=AF=E3=82=B9?= Date: Thu, 9 Apr 2026 17:24:17 +0200 Subject: [PATCH 6/9] feat: add GetTime() to AnyUUID --- anyuuid.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/anyuuid.go b/anyuuid.go index 33116ec..f012fc5 100644 --- a/anyuuid.go +++ b/anyuuid.go @@ -2,8 +2,10 @@ package typeid import ( "database/sql/driver" + "encoding/binary" "fmt" "strings" + "time" "github.com/google/uuid" ) @@ -111,3 +113,9 @@ func (id *AnyUUID) Scan(src any) (err error) { id.val = u return nil } + +// GetTime extracts the millisecond-precision creation timestamp from the UUIDv7. +func (id AnyUUID) GetTime() time.Time { + ms := int64(binary.BigEndian.Uint16(id.val[:2]))<<32 | int64(binary.BigEndian.Uint32(id.val[2:6])) + return time.UnixMilli(ms) +} From 8734e48644e9b990c82f27af24b5e17bbba4254e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20-=20=E3=82=A2=E3=83=AC=E3=83=83=E3=82=AF=E3=82=B9?= Date: Thu, 9 Apr 2026 17:24:39 +0200 Subject: [PATCH 7/9] feat: add UUID[P].Any() conversion to AnyUUID --- uuid.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/uuid.go b/uuid.go index 715a736..28b2c6a 100644 --- a/uuid.go +++ b/uuid.go @@ -48,9 +48,9 @@ func (id UUID[P]) appendText(dst []byte) []byte { var p P return appendBase32UUID(dst, p.Prefix(), id.val) } -func (id UUID[P]) String() string { return string(id.appendText(nil)) } -func (id UUID[P]) UUID() uuid.UUID { return id.val } -func (id UUID[P]) IsZero() bool { return id.val == uuid.UUID{} } +func (id UUID[P]) String() string { return string(id.appendText(nil)) } +func (id UUID[P]) UUID() uuid.UUID { return id.val } +func (id UUID[P]) IsZero() bool { return id.val == uuid.UUID{} } func (id UUID[P]) MarshalText() ([]byte, error) { if id.IsZero() { return nil, ErrZeroUUID @@ -74,6 +74,12 @@ func (id UUID[P]) Value() (driver.Value, error) { return id.val.String(), nil } +// Any converts a typed UUID to an AnyUUID with the same prefix and value. +func (id UUID[P]) Any() AnyUUID { + var p P + return AnyUUID{val: id.val, prefix: p.Prefix()} +} + func (id *UUID[P]) Scan(src any) (err error) { var u uuid.UUID switch v := src.(type) { From 19128bed16e77a127cbecd44de990374c6d8b1ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20-=20=E3=82=A2=E3=83=AC=E3=83=83=E3=82=AF=E3=82=B9?= Date: Thu, 9 Apr 2026 17:26:18 +0200 Subject: [PATCH 8/9] test: add AnyUUID constructor, serialization, DB, Any(), GetTime, JSON, benchmarks --- uuid_test.go | 274 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) diff --git a/uuid_test.go b/uuid_test.go index cecc4b9..9d32038 100644 --- a/uuid_test.go +++ b/uuid_test.go @@ -355,6 +355,280 @@ func TestAnyUUID_prefixAndSetPrefix(t *testing.T) { } } +func TestNewAnyUUID(t *testing.T) { + id, err := typeid.NewAnyUUID("user") + if err != nil { + t.Fatal(err) + } + if id.Prefix() != "user" { + t.Errorf("prefix = %q, want %q", id.Prefix(), "user") + } + if id.IsZero() { + t.Error("new AnyUUID should not be zero") + } + if id.UUID().Version() != 7 { + t.Error("expected UUIDv7") + } +} + +func TestAnyUUIDFrom(t *testing.T) { + raw := uuid.Must(uuid.NewV7()) + id, err := typeid.AnyUUIDFrom("team", raw) + if err != nil { + t.Fatal(err) + } + if id.UUID() != raw { + t.Errorf("UUID mismatch: got %s, want %s", id.UUID(), raw) + } + if id.Prefix() != "team" { + t.Errorf("prefix = %q, want %q", id.Prefix(), "team") + } +} + +func TestAnyUUIDFrom_RejectsV4(t *testing.T) { + v4 := uuid.New() + _, err := typeid.AnyUUIDFrom("user", v4) + if err == nil { + t.Error("expected error for non-v7 UUID") + } +} + +func TestAnyUUID_String(t *testing.T) { + id, _ := typeid.NewAnyUUID("user") + s := id.String() + if !strings.HasPrefix(s, "user_") { + t.Errorf("expected user_ prefix, got %q", s) + } + if len(s) != len("user")+1+26 { + t.Errorf("unexpected length %d", len(s)) + } +} + +func TestAnyUUID_SetPrefix(t *testing.T) { + id, _ := typeid.NewAnyUUID("apiKey") + if !strings.HasPrefix(id.String(), "apiKey_") { + t.Fatalf("expected apiKey_ prefix, got %q", id.String()) + } + + id.SetPrefix("apiKeySandbox") + if !strings.HasPrefix(id.String(), "apiKeySandbox_") { + t.Errorf("expected apiKeySandbox_ prefix after SetPrefix, got %q", id.String()) + } + + // Underlying UUID unchanged + id2, _ := typeid.NewAnyUUID("apiKey") + raw := id2.UUID() + id2.SetPrefix("other") + if id2.UUID() != raw { + t.Error("SetPrefix should not change the UUID") + } +} + +func TestAnyUUID_MarshalText_RejectsZero(t *testing.T) { + var id typeid.AnyUUID + _, err := id.MarshalText() + if err == nil { + t.Error("MarshalText should reject zero") + } +} + +func TestAnyUUID_UnmarshalText(t *testing.T) { + original, _ := typeid.NewAnyUUID("proj") + data, _ := original.MarshalText() + + var parsed typeid.AnyUUID + if err := parsed.UnmarshalText(data); err != nil { + t.Fatal(err) + } + if parsed.UUID() != original.UUID() { + t.Error("UUID mismatch after unmarshal") + } + if parsed.Prefix() != "proj" { + t.Errorf("prefix = %q, want %q", parsed.Prefix(), "proj") + } +} + +func TestAnyUUID_UnmarshalText_MultiWordPrefix(t *testing.T) { + original, _ := typeid.NewAnyUUID("apiKeySandbox") + data, _ := original.MarshalText() + + var parsed typeid.AnyUUID + if err := parsed.UnmarshalText(data); err != nil { + t.Fatal(err) + } + if parsed.Prefix() != "apiKeySandbox" { + t.Errorf("prefix = %q, want %q", parsed.Prefix(), "apiKeySandbox") + } + if parsed.UUID() != original.UUID() { + t.Error("UUID mismatch") + } +} + +func TestAnyUUID_Value(t *testing.T) { + id, _ := typeid.NewAnyUUID("key") + val, err := id.Value() + if err != nil { + t.Fatal(err) + } + s, ok := val.(string) + if !ok { + t.Fatal("Value should return string") + } + if _, err := uuid.Parse(s); err != nil { + t.Errorf("Value should return valid UUID string: %v", err) + } +} + +func TestAnyUUID_Value_RejectsZero(t *testing.T) { + var id typeid.AnyUUID + _, err := id.Value() + if err == nil { + t.Error("Value should reject zero") + } +} + +func TestAnyUUID_Scan(t *testing.T) { + original, _ := typeid.NewAnyUUID("user") + raw := original.UUID().String() + + var scanned typeid.AnyUUID + if err := scanned.Scan(raw); err != nil { + t.Fatal(err) + } + if scanned.UUID() != original.UUID() { + t.Error("UUID mismatch after scan") + } +} + +func TestAnyUUID_ScanRawBytes(t *testing.T) { + original, _ := typeid.NewAnyUUID("user") + raw := original.UUID() + + var scanned typeid.AnyUUID + if err := scanned.Scan(raw[:]); err != nil { + t.Fatal(err) + } + if scanned.UUID() != original.UUID() { + t.Error("UUID mismatch after scan from bytes") + } +} + +func TestAnyUUID_ScanInvalid(t *testing.T) { + var id typeid.AnyUUID + if err := id.Scan(123); err == nil { + t.Error("Scan should reject int") + } + v4 := uuid.New() + if err := id.Scan(v4.String()); err == nil { + t.Error("Scan should reject non-v7") + } +} + +func TestAnyUUID_DBRoundTrip(t *testing.T) { + id, _ := typeid.NewAnyUUID("apiKey") + + val, err := id.Value() + if err != nil { + t.Fatal(err) + } + + var scanned typeid.AnyUUID + if err := scanned.Scan(val); err != nil { + t.Fatal(err) + } + + scanned.SetPrefix("apiKeySandbox") + + if scanned.UUID() != id.UUID() { + t.Error("UUID mismatch in round-trip") + } + if !strings.HasPrefix(scanned.String(), "apiKeySandbox_") { + t.Errorf("expected apiKeySandbox_ prefix, got %q", scanned.String()) + } +} + +func TestUUID_Any(t *testing.T) { + typed, _ := typeid.NewUUID[userPrefix]() + any := typed.Any() + + if any.UUID() != typed.UUID() { + t.Error("UUID mismatch") + } + if any.Prefix() != "user" { + t.Errorf("prefix = %q, want %q", any.Prefix(), "user") + } + if any.String() != typed.String() { + t.Errorf("String mismatch: any=%q, typed=%q", any.String(), typed.String()) + } + + any.SetPrefix("admin") + if any.UUID() != typed.UUID() { + t.Error("UUID changed after SetPrefix") + } + if !strings.HasPrefix(any.String(), "admin_") { + t.Errorf("expected admin_ prefix, got %q", any.String()) + } +} + +func TestAnyUUID_GetTime(t *testing.T) { + before := time.Now() + id, _ := typeid.NewAnyUUID("user") + after := time.Now() + + got := id.GetTime() + if got.Before(before.Truncate(time.Millisecond)) { + t.Errorf("GetTime %v before creation time %v", got, before) + } + if got.After(after.Add(time.Millisecond)) { + t.Errorf("GetTime %v after creation time %v", got, after) + } +} + +func TestAnyUUID_JSON(t *testing.T) { + type Record struct { + ID typeid.AnyUUID `json:"id"` + Name string `json:"name"` + } + + id, _ := typeid.NewAnyUUID("apiKey") + original := Record{ID: id, Name: "test"} + data, err := json.Marshal(original) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `"id":"apiKey_`) { + t.Errorf("JSON should contain apiKey_ prefix: %s", data) + } + + var decoded Record + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if decoded.ID.UUID() != original.ID.UUID() { + t.Error("UUID mismatch after JSON round-trip") + } + if decoded.ID.Prefix() != "apiKey" { + t.Errorf("prefix = %q, want %q", decoded.ID.Prefix(), "apiKey") + } +} + +func BenchmarkAnyUUID_String(b *testing.B) { + id, _ := typeid.NewAnyUUID("apiKeySandbox") + b.ResetTimer() + for b.Loop() { + _ = id.String() + } +} + +func BenchmarkAnyUUID_Parse(b *testing.B) { + id, _ := typeid.NewAnyUUID("apiKeySandbox") + s := id.String() + b.ResetTimer() + for b.Loop() { + typeid.ParseAnyUUID(s) //nolint:errcheck + } +} + func TestUUID_Sortable(t *testing.T) { a, err := typeid.NewUUID[userPrefix]() if err != nil { From d883e6a7538d836b77b1ba68f42c4e70285bcfe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20-=20=E3=82=A2=E3=83=AC=E3=83=83=E3=82=AF=E3=82=B9?= Date: Thu, 9 Apr 2026 17:30:09 +0200 Subject: [PATCH 9/9] perf: use stack buffer in String() to reduce allocations Pass a [64]byte stack buffer to appendText, eliminating the slice growth allocation. Typed UUID/Int64 drop to 0 allocs; AnyUUID/AnyInt64 drop from 2 to 1. --- anyint64.go | 5 ++++- anyuuid.go | 5 ++++- int64.go | 9 ++++++--- uuid.go | 5 ++++- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/anyint64.go b/anyint64.go index 2772022..6634464 100644 --- a/anyint64.go +++ b/anyint64.go @@ -61,7 +61,10 @@ func (id AnyInt64) appendText(dst []byte) []byte { return appendBase32Int64(dst, id.prefix, id.val) } -func (id AnyInt64) String() string { return string(id.appendText(nil)) } +func (id AnyInt64) String() string { + var buf [64]byte + return string(id.appendText(buf[:0])) +} func (id AnyInt64) IsZero() bool { return id.val == 0 } diff --git a/anyuuid.go b/anyuuid.go index f012fc5..6272f9b 100644 --- a/anyuuid.go +++ b/anyuuid.go @@ -59,7 +59,10 @@ func (id AnyUUID) appendText(dst []byte) []byte { return appendBase32UUID(dst, id.prefix, id.val) } -func (id AnyUUID) String() string { return string(id.appendText(nil)) } +func (id AnyUUID) String() string { + var buf [64]byte + return string(id.appendText(buf[:0])) +} func (id AnyUUID) IsZero() bool { return id.val == uuid.UUID{} } diff --git a/int64.go b/int64.go index dc54f98..42fb61c 100644 --- a/int64.go +++ b/int64.go @@ -82,9 +82,12 @@ func (id Int64[P]) appendText(dst []byte) []byte { return appendBase32Int64(dst, p.Prefix(), id.val) } -func (id Int64[P]) String() string { return string(id.appendText(nil)) } -func (id Int64[P]) Int64() int64 { return id.val } -func (id Int64[P]) IsZero() bool { return id.val == 0 } +func (id Int64[P]) String() string { + var buf [64]byte + return string(id.appendText(buf[:0])) +} +func (id Int64[P]) Int64() int64 { return id.val } +func (id Int64[P]) IsZero() bool { return id.val == 0 } func (id Int64[P]) MarshalText() ([]byte, error) { if id.val <= 0 { return nil, ErrNonPositiveInt diff --git a/uuid.go b/uuid.go index 28b2c6a..2b19277 100644 --- a/uuid.go +++ b/uuid.go @@ -48,7 +48,10 @@ func (id UUID[P]) appendText(dst []byte) []byte { var p P return appendBase32UUID(dst, p.Prefix(), id.val) } -func (id UUID[P]) String() string { return string(id.appendText(nil)) } +func (id UUID[P]) String() string { + var buf [64]byte + return string(id.appendText(buf[:0])) +} func (id UUID[P]) UUID() uuid.UUID { return id.val } func (id UUID[P]) IsZero() bool { return id.val == uuid.UUID{} } func (id UUID[P]) MarshalText() ([]byte, error) {