From b8a1c3d5208db64308daf4746591cea68e1121b1 Mon Sep 17 00:00:00 2001 From: Erik Nilsen Date: Thu, 22 Oct 2026 07:12:16 -0700 Subject: [PATCH 1/3] feat: build IDs from UUIDs and scan native UUID values Signed-off-by: Erik Nilsen --- README.md | 4 ++++ example_test.go | 13 ++++++++++ objectid.go | 6 +++++ uuid.go | 25 ++++++++++++++++++++ uuid_test.go | 63 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 111 insertions(+) create mode 100644 uuid.go create mode 100644 uuid_test.go diff --git a/README.md b/README.md index a52a179..a5a898a 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,10 @@ A value without one of the type's prefixes fails with a `*WrongPrefixError`, whi `ObjectType`, `Separator` and `Prefix` are named string types, so an object type, a separator and a prefix cannot be swapped by mistake. `Bare()` and the parse inputs stay `string`, because they are the wire and storage boundary. +### Work with UUIDs + +`FromUUID[T](u)` builds an ID from a `uuid.UUID`, and `id.UUID()` returns the bare value as a `uuid.UUID`. `Scan` and `DecodeSpanner` also accept `uuid.UUID` and `[16]byte`, for drivers and columns that carry native UUIDs. These need `StorageFormat: FormatDropPrefix`, because a native UUID has no prefix. + ## Emitted forms | Method | Emits | Controlled by | diff --git a/example_test.go b/example_test.go index 60ca10b..c64cdf5 100644 --- a/example_test.go +++ b/example_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" + "github.com/google/uuid" + "github.com/TrogonStack/trogonobjectid" ) @@ -44,3 +46,14 @@ func ExampleID_EncodeSpanner() { // 0199e3a1-7c2b-7d4e-9f10-2a3b4c5d6e7f // true } + +func ExampleFromUUID() { + u := uuid.MustParse("0199e3a1-7c2b-7d4e-9f10-2a3b4c5d6e7f") + id, _ := trogonobjectid.FromUUID[invoiceType](u) + back, _ := id.UUID() + fmt.Println(id) + fmt.Println(back == u) + // Output: + // inv_0199e3a1-7c2b-7d4e-9f10-2a3b4c5d6e7f + // true +} diff --git a/objectid.go b/objectid.go index ea7e39c..cd92e06 100644 --- a/objectid.go +++ b/objectid.go @@ -9,6 +9,8 @@ import ( "reflect" "strings" "sync" + + "github.com/google/uuid" ) // ObjectType names an object ID type, for example "user". It is lowercase snake_case @@ -385,6 +387,10 @@ func (id *ID[T]) Scan(src any) error { value = v case []byte: value = string(v) + case uuid.UUID: + value = v.String() + case [16]byte: + value = uuid.UUID(v).String() default: return fmt.Errorf("%w: cannot scan %T", ErrInvalidFormat, src) } diff --git a/uuid.go b/uuid.go new file mode 100644 index 0000000..dbacabf --- /dev/null +++ b/uuid.go @@ -0,0 +1,25 @@ +package trogonobjectid + +import ( + "fmt" + + "github.com/google/uuid" +) + +// FromUUID returns the ID for u, after the type's Normalize and Validate. +func FromUUID[T Definer](u uuid.UUID) (ID[T], error) { + return New[T](u.String()) +} + +// UUID returns the bare value as a uuid.UUID, so callers that work with UUIDs do not +// parse Bare themselves. The zero ID fails with ErrEmptyValue. +func (id ID[T]) UUID() (uuid.UUID, error) { + if id.IsZero() { + return uuid.Nil, &ValidationError{ObjectType: id.ObjectType(), Err: ErrEmptyValue} + } + u, err := uuid.Parse(id.id) + if err != nil { + return uuid.Nil, &ValidationError{ObjectType: id.ObjectType(), Value: id.id, Err: fmt.Errorf("%w: %w", ErrInvalidUUID, err)} + } + return u, nil +} diff --git a/uuid_test.go b/uuid_test.go new file mode 100644 index 0000000..20d1164 --- /dev/null +++ b/uuid_test.go @@ -0,0 +1,63 @@ +package trogonobjectid_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/TrogonStack/trogonobjectid" +) + +func TestFromUUID(t *testing.T) { + u := uuid.MustParse(invoiceUUID) + + id, err := trogonobjectid.FromUUID[invoiceType](u) + require.NoError(t, err) + assert.Equal(t, trogonobjectid.MustNew[invoiceType](invoiceUUID), id) + + t.Run("validates against the type", func(t *testing.T) { + _, err := trogonobjectid.FromUUID[seqType](u) + require.ErrorIs(t, err, trogonobjectid.ErrInvalidInteger) + }) +} + +func TestIDUUID(t *testing.T) { + got, err := trogonobjectid.MustNew[invoiceType](invoiceUUID).UUID() + require.NoError(t, err) + assert.Equal(t, uuid.MustParse(invoiceUUID), got) + + t.Run("the zero ID has no UUID", func(t *testing.T) { + _, err := InvoiceID{}.UUID() + require.ErrorIs(t, err, trogonobjectid.ErrEmptyValue) + }) + + t.Run("a bare value that is not a UUID has no UUID", func(t *testing.T) { + _, err := trogonobjectid.MustNew[userType]("abc-123").UUID() + require.ErrorIs(t, err, trogonobjectid.ErrInvalidUUID) + }) +} + +func TestScanNativeUUID(t *testing.T) { + want := trogonobjectid.MustNew[invoiceType](invoiceUUID) + u := uuid.MustParse(invoiceUUID) + + for name, src := range map[string]any{"uuid.UUID": u, "[16]byte": [16]byte(u)} { + t.Run("Scan accepts "+name, func(t *testing.T) { + var got InvoiceID + require.NoError(t, got.Scan(src)) + assert.Equal(t, want, got) + }) + t.Run("DecodeSpanner accepts "+name, func(t *testing.T) { + var got InvoiceID + require.NoError(t, got.DecodeSpanner(src)) + assert.Equal(t, want, got) + }) + } + + t.Run("a full storage format rejects a native UUID", func(t *testing.T) { + var got UserID + require.ErrorIs(t, got.Scan(u), trogonobjectid.ErrInvalidFormat) + }) +} From ee0f91fcc1c53c78b97747f317f084a824bb2ce5 Mon Sep 17 00:00:00 2001 From: Erik Nilsen Date: Thu, 22 Oct 2026 07:26:05 -0700 Subject: [PATCH 2/3] Merge branch 'feat/named-types-and-checks' into feat/typed-uuid-access Signed-off-by: Erik Nilsen --- uuid.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uuid.go b/uuid.go index dbacabf..d1132be 100644 --- a/uuid.go +++ b/uuid.go @@ -15,11 +15,11 @@ func FromUUID[T Definer](u uuid.UUID) (ID[T], error) { // parse Bare themselves. The zero ID fails with ErrEmptyValue. func (id ID[T]) UUID() (uuid.UUID, error) { if id.IsZero() { - return uuid.Nil, &ValidationError{ObjectType: id.ObjectType(), Err: ErrEmptyValue} + return uuid.Nil, &ValidationError{ObjectTypes: mustSpec[T]().objectTypes, Err: ErrEmptyValue} } u, err := uuid.Parse(id.id) if err != nil { - return uuid.Nil, &ValidationError{ObjectType: id.ObjectType(), Value: id.id, Err: fmt.Errorf("%w: %w", ErrInvalidUUID, err)} + return uuid.Nil, &ValidationError{ObjectTypes: mustSpec[T]().objectTypes, Value: id.id, Err: fmt.Errorf("%w: %w", ErrInvalidUUID, err)} } return u, nil } From c22f34b8ec3a1797689db9cdeca8082648e0543d Mon Sep 17 00:00:00 2001 From: Erik Nilsen Date: Thu, 22 Oct 2026 08:24:27 -0700 Subject: [PATCH 3/3] docs: drop the Normalize mention from FromUUID Signed-off-by: Erik Nilsen --- uuid.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uuid.go b/uuid.go index d1132be..ec298e2 100644 --- a/uuid.go +++ b/uuid.go @@ -6,7 +6,7 @@ import ( "github.com/google/uuid" ) -// FromUUID returns the ID for u, after the type's Normalize and Validate. +// FromUUID returns the ID for u, after the type's Validate. func FromUUID[T Definer](u uuid.UUID) (ID[T], error) { return New[T](u.String()) }