diff --git a/anyint64.go b/anyint64.go new file mode 100644 index 0000000..6634464 --- /dev/null +++ b/anyint64.go @@ -0,0 +1,109 @@ +package typeid + +import ( + "crypto/rand" + "database/sql/driver" + "encoding/binary" + "fmt" + "strings" + "time" +) + +// 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(prefix string) (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, prefix: prefix}, nil +} + +func AnyInt64From(prefix string, v int64) (AnyInt64, error) { + if v <= 0 { + return AnyInt64{}, ErrNonPositiveInt + } + return AnyInt64{val: v, prefix: prefix}, 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 { + var buf [64]byte + return string(id.appendText(buf[:0])) +} + +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 + return nil +} diff --git a/anyuuid.go b/anyuuid.go new file mode 100644 index 0000000..6272f9b --- /dev/null +++ b/anyuuid.go @@ -0,0 +1,124 @@ +package typeid + +import ( + "database/sql/driver" + "encoding/binary" + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +// 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(prefix string) (AnyUUID, error) { + u, err := uuid.NewV7() + if err != nil { + return AnyUUID{}, err + } + return AnyUUID{val: u, prefix: prefix}, nil +} + +func AnyUUIDFrom(prefix string, u uuid.UUID) (AnyUUID, error) { + if u.Version() != 7 { + return AnyUUID{}, ErrOnlyV7 + } + return AnyUUID{val: u, prefix: prefix}, 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 { + var buf [64]byte + return string(id.appendText(buf[:0])) +} + +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 + 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) +} 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..42fb61c 100644 --- a/int64.go +++ b/int64.go @@ -79,12 +79,15 @@ 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]) 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/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 88526fb..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. @@ -25,31 +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 -} - -func growSlice(dst []byte, n int) []byte { - if cap(dst)-len(dst) >= n { - return dst + if prefix != want { + return "", fmt.Errorf("typeid: prefix mismatch: expected %q, got %q", want, prefix) } - 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 55caaf5..2b19277 100644 --- a/uuid.go +++ b/uuid.go @@ -46,12 +46,14 @@ func ParseUUID[P Prefixer](s string) (UUID[P], error) { func (id UUID[P]) appendText(dst []byte) []byte { var p P - dst = growSlice(dst, len(p.Prefix())+1+uuidSuffixLen) - return appendBase32UUID(appendID[P](dst), id.val) + 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 { + 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) { if id.IsZero() { return nil, ErrZeroUUID @@ -75,6 +77,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) { diff --git a/uuid_test.go b/uuid_test.go index 0894e1d..9d32038 100644 --- a/uuid_test.go +++ b/uuid_test.go @@ -258,6 +258,377 @@ func BenchmarkUUID_Parse(b *testing.B) { } } +func TestAnyUUID_json(t *testing.T) { + type Request struct { + ID typeid.AnyUUID `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()) + } + } +} + +// 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.ParseAnyUUID("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 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 {