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..ec298e2 --- /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 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{ObjectTypes: mustSpec[T]().objectTypes, Err: ErrEmptyValue} + } + u, err := uuid.Parse(id.id) + if err != nil { + return uuid.Nil, &ValidationError{ObjectTypes: mustSpec[T]().objectTypes, 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) + }) +}