From ee862509dc88eea8702faffc047681091244f44c Mon Sep 17 00:00:00 2001 From: Erik Nilsen Date: Thu, 22 Oct 2026 07:04:27 -0700 Subject: [PATCH 1/5] feat: normalize bare values and encode IDs for Spanner Signed-off-by: Erik Nilsen --- README.md | 8 +++- example_test.go | 18 ++++++++ objectid.go | 51 +++++++++++++++------- objectid_test.go | 108 +++++++++++++++++++++++++++++++++++++++++++++++ validate.go | 10 +++++ 5 files changed, 178 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 3e167c6..418131a 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ Keep the result in a package-level variable, because `Definition` methods run on | `Separator` | `"_"` | Goes between the object type and the bare value | | `StorageFormat` | `FormatFull` | Form for `Storage`, `FromStorage`, `Value` and `Scan` | | `JSONFormat` | `FormatFull` | Form for `MarshalJSON` and `UnmarshalJSON` | +| `Normalize` | none | Rewrites the bare value to its canonical form before `Validate`; use `NormalizeUUID` for lowercase hyphenated UUIDs | | `Validate` | none | Checks the bare value; use `ValidateUUID`, `ValidateInteger` or your own function | | `Generate` | none | Makes a bare value for `Generate`; use `GenerateUUIDv7`, `GenerateUUIDv4` or your own function | @@ -108,7 +109,12 @@ Keep the result in a package-level variable, because `Definition` methods run on | `MarshalJSON`, `UnmarshalJSON` | Full or bare | `JSONFormat` | | `Storage`, `FromStorage` | Full or bare | `StorageFormat` | | `Value`, `Scan` (`database/sql`) | Full or bare | `StorageFormat` | +| `EncodeSpanner`, `DecodeSpanner` (Cloud Spanner) | Full or bare | `StorageFormat` | `LogValue` exists because `slog.JSONHandler` otherwise calls `MarshalJSON`, which logs the bare value when `JSONFormat` is `FormatDropPrefix`. `MarshalText` always uses the full form because JSON map keys and flag values must parse back without other context. -Decoders accept only the form that the matching encoder emits, so each encoding round trips. The zero ID means "absent": it encodes as SQL `NULL` and JSON `null`, and the JSON `omitzero` tag option skips it. +Decoders accept only the form that the matching encoder emits, so each encoding round trips. The zero ID means "absent": it encodes as SQL `NULL`, Spanner `NULL` and JSON `null`, and the JSON `omitzero` tag option skips it. + +The Spanner methods implement `spanner.Encoder` and `spanner.Decoder` by method set, so the package does not import the Spanner client. + +`Normalize` has no counterpart in the Elixir package, which keeps UUIDs as given. Without it, `inv_0199E3A1-...` and `inv_0199e3a1-...` are two different IDs. diff --git a/example_test.go b/example_test.go index fd78a0e..2725d70 100644 --- a/example_test.go +++ b/example_test.go @@ -33,3 +33,21 @@ func ExampleParse() { // abc-123 // trogonobjectid: invalid user value "order_abc-123": invalid format } + +func ExampleNormalizeUUID() { + id := trogonobjectid.MustNew[canonicalType]("0199E3A1-7C2B-7D4E-9F10-2A3B4C5D6E7F") + fmt.Println(id) + // Output: + // inv_0199e3a1-7c2b-7d4e-9f10-2a3b4c5d6e7f +} + +func ExampleID_EncodeSpanner() { + value, _ := trogonobjectid.MustNew[invoiceType]("0199e3a1-7c2b-7d4e-9f10-2a3b4c5d6e7f").EncodeSpanner() + fmt.Println(value) + + null, _ := trogonobjectid.ID[invoiceType]{}.EncodeSpanner() + fmt.Println(null == (*string)(nil)) + // Output: + // 0199e3a1-7c2b-7d4e-9f10-2a3b4c5d6e7f + // true +} diff --git a/objectid.go b/objectid.go index bb42a68..5b0f944 100644 --- a/objectid.go +++ b/objectid.go @@ -31,6 +31,9 @@ type Definition struct { StorageFormat Format // JSONFormat controls MarshalJSON and UnmarshalJSON. JSONFormat Format + // Normalize rewrites the bare value into its canonical form before Validate runs, so + // equal IDs compare equal whatever form they arrived in. Nil keeps the value as given. + Normalize func(id string) (string, error) // Validate checks the bare value. Nil accepts any non-empty value. Validate func(id string) error // Generate returns a new bare value for Generate. Nil makes Generate fail. @@ -78,16 +81,23 @@ func specFor[T Definer]() (spec, error) { return spec{Definition: def, prefix: def.ObjectType + def.Separator}, nil } -func (s spec) build(id string) error { +func (s spec) build(id string) (string, error) { if id == "" { - return &ValidationError{ObjectType: s.ObjectType, Value: id, Err: ErrEmptyValue} + return "", &ValidationError{ObjectType: s.ObjectType, Value: id, Err: ErrEmptyValue} + } + if s.Normalize != nil { + normalized, err := s.Normalize(id) + if err != nil { + return "", &ValidationError{ObjectType: s.ObjectType, Value: id, Err: err} + } + id = normalized } if s.Validate != nil { if err := s.Validate(id); err != nil { - return &ValidationError{ObjectType: s.ObjectType, Value: id, Err: err} + return "", &ValidationError{ObjectType: s.ObjectType, Value: id, Err: err} } } - return nil + return id, nil } func (s spec) parse(full string) (string, error) { @@ -95,18 +105,12 @@ func (s spec) parse(full string) (string, error) { if !ok || bare == "" { return "", &ValidationError{ObjectType: s.ObjectType, Value: full, Err: ErrInvalidFormat} } - if err := s.build(bare); err != nil { - return "", err - } - return bare, nil + return s.build(bare) } func (s spec) decode(format Format, value string) (string, error) { if format == FormatDropPrefix { - if err := s.build(value); err != nil { - return "", err - } - return value, nil + return s.build(value) } return s.parse(value) } @@ -124,10 +128,11 @@ func New[T Definer](id string) (ID[T], error) { if err != nil { return ID[T]{}, err } - if err := s.build(id); err != nil { + bare, err := s.build(id) + if err != nil { return ID[T]{}, err } - return ID[T]{id: id}, nil + return ID[T]{id: bare}, nil } // MustNew is like New but panics on error. Use it for constants and tests. @@ -174,10 +179,11 @@ func Generate[T Definer]() (ID[T], error) { if err != nil { return ID[T]{}, fmt.Errorf("trogonobjectid: generate %s: %w", s.ObjectType, err) } - if err := s.build(id); err != nil { + bare, err := s.build(id) + if err != nil { return ID[T]{}, err } - return ID[T]{id: id}, nil + return ID[T]{id: bare}, nil } // FromStorage returns the ID for a value in the type's StorageFormat. @@ -319,3 +325,16 @@ func (id *ID[T]) Scan(src any) error { *id = v return nil } + +// EncodeSpanner implements spanner.Encoder with the type's StorageFormat. The zero ID +// is a NULL STRING; an untyped nil would leave Spanner without a column type. +func (id ID[T]) EncodeSpanner() (any, error) { + if id.IsZero() { + return (*string)(nil), nil + } + return id.Storage(), nil +} + +// DecodeSpanner implements spanner.Decoder for STRING values in the type's StorageFormat. +// NULL gives the zero ID. +func (id *ID[T]) DecodeSpanner(val any) error { return id.Scan(val) } diff --git a/objectid_test.go b/objectid_test.go index 37439ee..5a75e1c 100644 --- a/objectid_test.go +++ b/objectid_test.go @@ -352,3 +352,111 @@ func TestZero(t *testing.T) { assert.Empty(t, id.Storage()) assert.Equal(t, UserID{}, id) } + +type canonicalType struct{} + +func (canonicalType) Definition() trogonobjectid.Definition { + return trogonobjectid.Definition{ + ObjectType: "inv", + StorageFormat: trogonobjectid.FormatDropPrefix, + JSONFormat: trogonobjectid.FormatDropPrefix, + Normalize: trogonobjectid.NormalizeUUID, + } +} + +const upperInvoiceUUID = "0199E3A1-7C2B-7D4E-9F10-2A3B4C5D6E7F" + +func TestNormalize(t *testing.T) { + want := trogonobjectid.MustNew[canonicalType](invoiceUUID) + + decoders := map[string]func() (trogonobjectid.ID[canonicalType], error){ + "New": func() (trogonobjectid.ID[canonicalType], error) { + return trogonobjectid.New[canonicalType](upperInvoiceUUID) + }, + "Parse": func() (trogonobjectid.ID[canonicalType], error) { + return trogonobjectid.Parse[canonicalType]("inv_" + upperInvoiceUUID) + }, + "URN": func() (trogonobjectid.ID[canonicalType], error) { + return trogonobjectid.New[canonicalType]("urn:uuid:" + invoiceUUID) + }, + "Storage": func() (trogonobjectid.ID[canonicalType], error) { + return trogonobjectid.FromStorage[canonicalType](upperInvoiceUUID) + }, + "Scan": func() (trogonobjectid.ID[canonicalType], error) { + var id trogonobjectid.ID[canonicalType] + return id, id.Scan(upperInvoiceUUID) + }, + "JSON": func() (trogonobjectid.ID[canonicalType], error) { + var id trogonobjectid.ID[canonicalType] + return id, json.Unmarshal([]byte(`"`+upperInvoiceUUID+`"`), &id) + }, + } + for name, decode := range decoders { + t.Run(name+" stores the canonical form", func(t *testing.T) { + got, err := decode() + require.NoError(t, err) + assert.Equal(t, want, got) + }) + } + + t.Run("rejects a value that is not a UUID", func(t *testing.T) { + _, err := trogonobjectid.New[canonicalType]("nope") + require.ErrorIs(t, err, trogonobjectid.ErrInvalidUUID) + }) + + t.Run("runs before Validate", func(t *testing.T) { + _, err := trogonobjectid.New[validatedCanonicalType](upperInvoiceUUID) + require.NoError(t, err) + }) +} + +type validatedCanonicalType struct{} + +func (validatedCanonicalType) Definition() trogonobjectid.Definition { + return trogonobjectid.Definition{ + ObjectType: "inv", + Normalize: trogonobjectid.NormalizeUUID, + Validate: func(id string) error { + if id != invoiceUUID { + return errors.New("not canonical") + } + return nil + }, + } +} + +func TestSpanner(t *testing.T) { + t.Run("encodes in the storage format", func(t *testing.T) { + got, err := trogonobjectid.MustNew[invoiceType](invoiceUUID).EncodeSpanner() + require.NoError(t, err) + assert.Equal(t, invoiceUUID, got) + + got, err = trogonobjectid.MustNew[userType]("abc-123").EncodeSpanner() + require.NoError(t, err) + assert.Equal(t, "user_abc-123", got) + }) + + t.Run("encodes the zero ID as a typed NULL", func(t *testing.T) { + got, err := InvoiceID{}.EncodeSpanner() + require.NoError(t, err) + assert.Equal(t, (*string)(nil), got) + }) + + t.Run("decodes what it encodes", func(t *testing.T) { + for _, want := range []InvoiceID{trogonobjectid.MustNew[invoiceType](invoiceUUID), {}} { + encoded, err := want.EncodeSpanner() + require.NoError(t, err) + if p, ok := encoded.(*string); ok && p == nil { + encoded = nil + } + var got InvoiceID + require.NoError(t, got.DecodeSpanner(encoded)) + assert.Equal(t, want, got) + } + }) + + t.Run("rejects a value in the other format", func(t *testing.T) { + var got InvoiceID + require.ErrorIs(t, got.DecodeSpanner("inv_"+invoiceUUID), trogonobjectid.ErrInvalidUUID) + }) +} diff --git a/validate.go b/validate.go index 795e2af..cbae6d5 100644 --- a/validate.go +++ b/validate.go @@ -45,3 +45,13 @@ func GenerateUUIDv4() (string, error) { } return u.String(), nil } + +// NormalizeUUID returns the canonical lowercase hyphenated form of any UUID form that +// uuid.Parse accepts, so upper-case and URN inputs name the same ID. +func NormalizeUUID(id string) (string, error) { + u, err := uuid.Parse(id) + if err != nil { + return "", ErrInvalidUUID + } + return u.String(), nil +} From 87bf9aa1d75d85aa85c825a807af23743de31bcb Mon Sep 17 00:00:00 2001 From: Erik Nilsen Date: Thu, 22 Oct 2026 07:09:40 -0700 Subject: [PATCH 2/5] feat: add proto definition options and cache definitions per type Signed-off-by: Erik Nilsen --- README.md | 15 +++---- objectid.go | 33 ++++++++++++++- objectid_test.go | 41 +++++++++++++++++++ objectidproto/objectidproto.go | 62 +++++++++++++++++++++-------- objectidproto/objectidproto_test.go | 31 +++++++++++++++ union.go | 20 ++++++++++ 6 files changed, 178 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 418131a..45c1140 100644 --- a/README.md +++ b/README.md @@ -71,21 +71,22 @@ A union always encodes the full form, in JSON and in storage too, because the pr ### Read the prefix from a proto annotation -Package `objectidproto` reads `object_type` and `separator` from the `trogon.object_id.v1alpha1.enum_value` annotation on an enum value, so every language that uses the proto shares one prefix: +Package `objectidproto` reads `object_type` and `separator` from the `trogon.object_id.v1alpha1.enum_value` annotation on an enum value, so every language that uses the proto shares one prefix. Options set the fields that the annotation does not carry: ```go -var ticketDefinition = func() trogonobjectid.Definition { - def := objectidproto.MustDefinition(acmev1.ObjectType_OBJECT_TYPE_TICKET) - def.Validate = trogonobjectid.ValidateUUID - return def -}() +var ticketDefinition = objectidproto.MustDefinition( + acmev1.ObjectType_OBJECT_TYPE_TICKET, + objectidproto.WithNormalize(trogonobjectid.NormalizeUUID), + objectidproto.WithValidate(trogonobjectid.ValidateUUID), + objectidproto.WithGenerate(trogonobjectid.GenerateUUIDv7), +) type ticketType struct{} func (ticketType) Definition() trogonobjectid.Definition { return ticketDefinition } ``` -Keep the result in a package-level variable, because `Definition` methods run on every call. +The package caches each type's definition, so `Definition` must always return the same value. `ObjectTypeOf[T]()` and `PrefixOf[T]()` read it without an ID. ## Options diff --git a/objectid.go b/objectid.go index 5b0f944..e028c6a 100644 --- a/objectid.go +++ b/objectid.go @@ -5,7 +5,9 @@ import ( "encoding/json" "fmt" "log/slog" + "reflect" "strings" + "sync" ) // DefaultSeparator is the separator used when Definition.Separator is empty. @@ -41,7 +43,8 @@ type Definition struct { } // Definer is implemented by the marker type that names an object ID type. -// Implement it on an empty struct with a value receiver: +// The result of Definition is cached per type, so it must always return the same +// value. Implement it on an empty struct with a value receiver: // // type userType struct{} // @@ -69,7 +72,27 @@ type spec struct { prefix string } +type cachedSpec struct { + spec spec + err error +} + +// specs caches each marker type's spec, keyed by reflect.Type, so encoding an ID does +// not rebuild the prefix on every call. +var specs sync.Map + func specFor[T Definer]() (spec, error) { + key := reflect.TypeFor[T]() + if cached, ok := specs.Load(key); ok { + c := cached.(cachedSpec) + return c.spec, c.err + } + s, err := newSpec[T]() + specs.Store(key, cachedSpec{spec: s, err: err}) + return s, err +} + +func newSpec[T Definer]() (spec, error) { var marker T def := marker.Definition() if def.ObjectType == "" { @@ -209,6 +232,14 @@ func mustSpec[T Definer]() spec { return s } +// ObjectTypeOf returns T's object type, for example "user". It panics if T's +// Definition is invalid. +func ObjectTypeOf[T Definer]() string { return mustSpec[T]().ObjectType } + +// PrefixOf returns T's object type and separator, for example "user_". It panics if +// T's Definition is invalid. +func PrefixOf[T Definer]() string { return mustSpec[T]().prefix } + // IsZero reports whether id is the zero (absent) ID. func (id ID[T]) IsZero() bool { return id.id == "" } diff --git a/objectid_test.go b/objectid_test.go index 5a75e1c..c1871e9 100644 --- a/objectid_test.go +++ b/objectid_test.go @@ -460,3 +460,44 @@ func TestSpanner(t *testing.T) { require.ErrorIs(t, got.DecodeSpanner("inv_"+invoiceUUID), trogonobjectid.ErrInvalidUUID) }) } + +func TestTypeLevelAccessors(t *testing.T) { + assert.Equal(t, "inv", trogonobjectid.ObjectTypeOf[invoiceType]()) + assert.Equal(t, "inv_", trogonobjectid.PrefixOf[invoiceType]()) + assert.Equal(t, "order#", trogonobjectid.PrefixOf[orderType]()) + assert.Panics(t, func() { trogonobjectid.PrefixOf[badType]() }) +} + +var definitionCalls int + +type countedType struct{} + +func (countedType) Definition() trogonobjectid.Definition { + definitionCalls++ + return trogonobjectid.Definition{ObjectType: "cnt"} +} + +func TestDefinitionRunsOnce(t *testing.T) { + id := trogonobjectid.MustNew[countedType]("a") + for range 3 { + _ = id.String() + _, _ = trogonobjectid.Parse[countedType]("cnt_a") + } + assert.Equal(t, 1, definitionCalls) +} + +func BenchmarkString(b *testing.B) { + id := trogonobjectid.MustNew[invoiceType](invoiceUUID) + b.ReportAllocs() + for b.Loop() { + _ = id.String() + } +} + +func BenchmarkParse(b *testing.B) { + full := "inv_" + invoiceUUID + b.ReportAllocs() + for b.Loop() { + _, _ = trogonobjectid.Parse[invoiceType](full) + } +} diff --git a/objectidproto/objectidproto.go b/objectidproto/objectidproto.go index 57038cc..c0e4434 100644 --- a/objectidproto/objectidproto.go +++ b/objectidproto/objectidproto.go @@ -7,15 +7,13 @@ // } // // The object type and separator then live in one place for every language that uses -// the proto. Keep the result in a package-level variable, because Definition methods -// run on every call: +// the proto. Options set the other fields: // -// var ticketDefinition = func() trogonobjectid.Definition { -// def := objectidproto.MustDefinition(acmev1.ObjectType_OBJECT_TYPE_TICKET) -// def.StorageFormat = trogonobjectid.FormatDropPrefix -// def.Validate = trogonobjectid.ValidateUUID -// return def -// }() +// var ticketDefinition = objectidproto.MustDefinition( +// acmev1.ObjectType_OBJECT_TYPE_TICKET, +// objectidproto.WithStorageFormat(trogonobjectid.FormatDropPrefix), +// objectidproto.WithValidate(trogonobjectid.ValidateUUID), +// ) // // type ticketType struct{} // @@ -36,21 +34,21 @@ import ( // Definition returns a Definition with ObjectType and Separator read from the enum // value's annotation. An absent separator gives trogonobjectid.DefaultSeparator. -// Set the other fields on the result. -func Definition(value protoreflect.Enum) (trogonobjectid.Definition, error) { +// Options set the fields that the annotation does not carry. +func Definition(value protoreflect.Enum, opts ...Option) (trogonobjectid.Definition, error) { desc := value.Descriptor().Values().ByNumber(value.Number()) if desc == nil { return trogonobjectid.Definition{}, fmt.Errorf("%w: %s has no value %d", trogonobjectid.ErrInvalidDefinition, value.Descriptor().FullName(), value.Number()) } - opts, _ := desc.Options().(*descriptorpb.EnumValueOptions) - if opts == nil || !proto.HasExtension(opts, objectidv1alpha1.E_EnumValue) { + valueOpts, _ := desc.Options().(*descriptorpb.EnumValueOptions) + if valueOpts == nil || !proto.HasExtension(valueOpts, objectidv1alpha1.E_EnumValue) { return trogonobjectid.Definition{}, fmt.Errorf("%w: %s has no %s annotation", trogonobjectid.ErrInvalidDefinition, desc.FullName(), objectidv1alpha1.E_EnumValue.TypeDescriptor().FullName()) } - ext, _ := proto.GetExtension(opts, objectidv1alpha1.E_EnumValue).(*objectidv1alpha1.EnumValueOptions) + ext, _ := proto.GetExtension(valueOpts, objectidv1alpha1.E_EnumValue).(*objectidv1alpha1.EnumValueOptions) if ext.GetObjectType() == "" { return trogonobjectid.Definition{}, fmt.Errorf("%w: %s has an empty object_type", trogonobjectid.ErrInvalidDefinition, desc.FullName()) @@ -60,14 +58,46 @@ func Definition(value protoreflect.Enum) (trogonobjectid.Definition, error) { if ext.Separator != nil { separator = ext.GetSeparator() } - return trogonobjectid.Definition{ObjectType: ext.GetObjectType(), Separator: separator}, nil + def := trogonobjectid.Definition{ObjectType: ext.GetObjectType(), Separator: separator} + for _, opt := range opts { + opt(&def) + } + return def, nil } // MustDefinition is like Definition but panics on error. Use it in package-level variables. -func MustDefinition(value protoreflect.Enum) trogonobjectid.Definition { - def, err := Definition(value) +func MustDefinition(value protoreflect.Enum, opts ...Option) trogonobjectid.Definition { + def, err := Definition(value, opts...) if err != nil { panic(err) } return def } + +// Option sets a Definition field that the proto annotation does not carry. +type Option func(*trogonobjectid.Definition) + +// WithStorageFormat sets Definition.StorageFormat. +func WithStorageFormat(format trogonobjectid.Format) Option { + return func(d *trogonobjectid.Definition) { d.StorageFormat = format } +} + +// WithJSONFormat sets Definition.JSONFormat. +func WithJSONFormat(format trogonobjectid.Format) Option { + return func(d *trogonobjectid.Definition) { d.JSONFormat = format } +} + +// WithNormalize sets Definition.Normalize. +func WithNormalize(normalize func(id string) (string, error)) Option { + return func(d *trogonobjectid.Definition) { d.Normalize = normalize } +} + +// WithValidate sets Definition.Validate. +func WithValidate(validate func(id string) error) Option { + return func(d *trogonobjectid.Definition) { d.Validate = validate } +} + +// WithGenerate sets Definition.Generate. +func WithGenerate(generate func() (string, error)) Option { + return func(d *trogonobjectid.Definition) { d.Generate = generate } +} diff --git a/objectidproto/objectidproto_test.go b/objectidproto/objectidproto_test.go index 9877ddd..5bc1d91 100644 --- a/objectidproto/objectidproto_test.go +++ b/objectidproto/objectidproto_test.go @@ -122,3 +122,34 @@ func TestDefinitionWithID(t *testing.T) { _, err = trogonobjectid.Parse[ticketType]("tkt_abc") require.ErrorIs(t, err, trogonobjectid.ErrInvalidInteger) } + +func TestDefinitionOptions(t *testing.T) { + enum := objectTypeEnum(t) + + def, err := objectidproto.Definition(enum.New(valueTicket), + objectidproto.WithStorageFormat(trogonobjectid.FormatDropPrefix), + objectidproto.WithJSONFormat(trogonobjectid.FormatDropPrefix), + objectidproto.WithNormalize(trogonobjectid.NormalizeUUID), + objectidproto.WithValidate(trogonobjectid.ValidateUUID), + objectidproto.WithGenerate(trogonobjectid.GenerateUUIDv7), + ) + require.NoError(t, err) + + assert.Equal(t, "tkt", def.ObjectType) + assert.Equal(t, trogonobjectid.FormatDropPrefix, def.StorageFormat) + assert.Equal(t, trogonobjectid.FormatDropPrefix, def.JSONFormat) + normalized, err := def.Normalize("0199E3A1-7C2B-7D4E-9F10-2A3B4C5D6E7F") + require.NoError(t, err) + assert.Equal(t, "0199e3a1-7c2b-7d4e-9f10-2a3b4c5d6e7f", normalized) + require.ErrorIs(t, def.Validate("nope"), trogonobjectid.ErrInvalidUUID) + generated, err := def.Generate() + require.NoError(t, err) + require.NoError(t, trogonobjectid.ValidateUUID(generated)) + + t.Run("options do not change the annotation fields", func(t *testing.T) { + def, err := objectidproto.Definition(enum.New(valueOrder), objectidproto.WithStorageFormat(trogonobjectid.FormatDropPrefix)) + require.NoError(t, err) + assert.Equal(t, "order", def.ObjectType) + assert.Equal(t, "#", def.Separator) + }) +} diff --git a/union.go b/union.go index d700dff..5caa2ad 100644 --- a/union.go +++ b/union.go @@ -5,8 +5,10 @@ import ( "encoding/json" "fmt" "log/slog" + "reflect" "sort" "strings" + "sync" ) // Member is an ID that a Union can hold. Every ID[T] implements it. @@ -67,7 +69,25 @@ type unionSpec struct { members []MemberType } +type cachedUnionSpec struct { + spec unionSpec + err error +} + +var unionSpecs sync.Map + func unionSpecFor[U UnionDefiner]() (unionSpec, error) { + key := reflect.TypeFor[U]() + if cached, ok := unionSpecs.Load(key); ok { + c := cached.(cachedUnionSpec) + return c.spec, c.err + } + s, err := newUnionSpec[U]() + unionSpecs.Store(key, cachedUnionSpec{spec: s, err: err}) + return s, err +} + +func newUnionSpec[U UnionDefiner]() (unionSpec, error) { var marker U members := marker.Members() if len(members) == 0 { From 86dfc59524bfec27691d737e8ee0182a6ab688a1 Mon Sep 17 00:00:00 2001 From: Erik Nilsen Date: Thu, 22 Oct 2026 07:20:20 -0700 Subject: [PATCH 3/5] refactor: share UUID parsing, encode unions for Spanner, and trim tests Signed-off-by: Erik Nilsen --- README.md | 12 ++++----- objectid_test.go | 67 ++++++++++++++++-------------------------------- union.go | 12 +++++++++ union_test.go | 16 ++++++++++++ validate.go | 21 ++++++++++----- 5 files changed, 69 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 418131a..1024864 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ func (invoiceType) Definition() trogonobjectid.Definition { ObjectType: "inv", StorageFormat: trogonobjectid.FormatDropPrefix, JSONFormat: trogonobjectid.FormatDropPrefix, - Validate: trogonobjectid.ValidateUUID, + Normalize: trogonobjectid.NormalizeUUID, Generate: trogonobjectid.GenerateUUIDv7, } } @@ -67,7 +67,7 @@ case SystemID: } ``` -A union always encodes the full form, in JSON and in storage too, because the prefix is the only thing that tells the member types apart. When prefixes overlap, such as `acme_` and `acme_admin_`, the longest prefix wins. +A union always encodes the full form, in JSON, SQL and Spanner too, because the prefix is the only thing that tells the member types apart. When prefixes overlap, such as `acme_` and `acme_admin_`, the longest prefix wins. ### Read the prefix from a proto annotation @@ -95,7 +95,7 @@ Keep the result in a package-level variable, because `Definition` methods run on | `Separator` | `"_"` | Goes between the object type and the bare value | | `StorageFormat` | `FormatFull` | Form for `Storage`, `FromStorage`, `Value` and `Scan` | | `JSONFormat` | `FormatFull` | Form for `MarshalJSON` and `UnmarshalJSON` | -| `Normalize` | none | Rewrites the bare value to its canonical form before `Validate`; use `NormalizeUUID` for lowercase hyphenated UUIDs | +| `Normalize` | none | Rewrites the bare value to its canonical form before `Validate`. `NormalizeUUID` gives lowercase hyphenated UUIDs and also rejects non-UUIDs, so a type that sets it needs no `ValidateUUID`. The Elixir package has no counterpart and keeps UUIDs as given | | `Validate` | none | Checks the bare value; use `ValidateUUID`, `ValidateInteger` or your own function | | `Generate` | none | Makes a bare value for `Generate`; use `GenerateUUIDv7`, `GenerateUUIDv4` or your own function | @@ -109,12 +109,10 @@ Keep the result in a package-level variable, because `Definition` methods run on | `MarshalJSON`, `UnmarshalJSON` | Full or bare | `JSONFormat` | | `Storage`, `FromStorage` | Full or bare | `StorageFormat` | | `Value`, `Scan` (`database/sql`) | Full or bare | `StorageFormat` | -| `EncodeSpanner`, `DecodeSpanner` (Cloud Spanner) | Full or bare | `StorageFormat` | +| `EncodeSpanner`, `DecodeSpanner` (Cloud Spanner) | Full or bare; unions always full | `StorageFormat` | `LogValue` exists because `slog.JSONHandler` otherwise calls `MarshalJSON`, which logs the bare value when `JSONFormat` is `FormatDropPrefix`. `MarshalText` always uses the full form because JSON map keys and flag values must parse back without other context. -Decoders accept only the form that the matching encoder emits, so each encoding round trips. The zero ID means "absent": it encodes as SQL `NULL`, Spanner `NULL` and JSON `null`, and the JSON `omitzero` tag option skips it. +Decoders accept only the form that the matching encoder emits, apart from what `Normalize` rewrites, so each encoding round trips. The zero ID means "absent": it encodes as SQL `NULL`, Spanner `NULL` and JSON `null`, and the JSON `omitzero` tag option skips it. The Spanner methods implement `spanner.Encoder` and `spanner.Decoder` by method set, so the package does not import the Spanner client. - -`Normalize` has no counterpart in the Elixir package, which keeps UUIDs as given. Without it, `inv_0199E3A1-...` and `inv_0199e3a1-...` are two different IDs. diff --git a/objectid_test.go b/objectid_test.go index 5a75e1c..1301870 100644 --- a/objectid_test.go +++ b/objectid_test.go @@ -353,6 +353,8 @@ func TestZero(t *testing.T) { assert.Equal(t, UserID{}, id) } +// canonicalType's Validate accepts only the canonical form, so every decoder case +// also proves that Normalize runs before Validate. type canonicalType struct{} func (canonicalType) Definition() trogonobjectid.Definition { @@ -361,6 +363,12 @@ func (canonicalType) Definition() trogonobjectid.Definition { StorageFormat: trogonobjectid.FormatDropPrefix, JSONFormat: trogonobjectid.FormatDropPrefix, Normalize: trogonobjectid.NormalizeUUID, + Validate: func(id string) error { + if id != invoiceUUID { + return errors.New("not canonical") + } + return nil + }, } } @@ -373,14 +381,11 @@ func TestNormalize(t *testing.T) { "New": func() (trogonobjectid.ID[canonicalType], error) { return trogonobjectid.New[canonicalType](upperInvoiceUUID) }, - "Parse": func() (trogonobjectid.ID[canonicalType], error) { - return trogonobjectid.Parse[canonicalType]("inv_" + upperInvoiceUUID) - }, "URN": func() (trogonobjectid.ID[canonicalType], error) { return trogonobjectid.New[canonicalType]("urn:uuid:" + invoiceUUID) }, - "Storage": func() (trogonobjectid.ID[canonicalType], error) { - return trogonobjectid.FromStorage[canonicalType](upperInvoiceUUID) + "Parse": func() (trogonobjectid.ID[canonicalType], error) { + return trogonobjectid.Parse[canonicalType]("inv_" + upperInvoiceUUID) }, "Scan": func() (trogonobjectid.ID[canonicalType], error) { var id trogonobjectid.ID[canonicalType] @@ -403,56 +408,28 @@ func TestNormalize(t *testing.T) { _, err := trogonobjectid.New[canonicalType]("nope") require.ErrorIs(t, err, trogonobjectid.ErrInvalidUUID) }) - - t.Run("runs before Validate", func(t *testing.T) { - _, err := trogonobjectid.New[validatedCanonicalType](upperInvoiceUUID) - require.NoError(t, err) - }) -} - -type validatedCanonicalType struct{} - -func (validatedCanonicalType) Definition() trogonobjectid.Definition { - return trogonobjectid.Definition{ - ObjectType: "inv", - Normalize: trogonobjectid.NormalizeUUID, - Validate: func(id string) error { - if id != invoiceUUID { - return errors.New("not canonical") - } - return nil - }, - } } func TestSpanner(t *testing.T) { - t.Run("encodes in the storage format", func(t *testing.T) { - got, err := trogonobjectid.MustNew[invoiceType](invoiceUUID).EncodeSpanner() - require.NoError(t, err) - assert.Equal(t, invoiceUUID, got) - - got, err = trogonobjectid.MustNew[userType]("abc-123").EncodeSpanner() + t.Run("encodes the full storage format", func(t *testing.T) { + got, err := trogonobjectid.MustNew[userType]("abc-123").EncodeSpanner() require.NoError(t, err) assert.Equal(t, "user_abc-123", got) }) - t.Run("encodes the zero ID as a typed NULL", func(t *testing.T) { - got, err := InvoiceID{}.EncodeSpanner() + t.Run("decodes what it encodes", func(t *testing.T) { + want := trogonobjectid.MustNew[invoiceType](invoiceUUID) + encoded, err := want.EncodeSpanner() require.NoError(t, err) - assert.Equal(t, (*string)(nil), got) + var got InvoiceID + require.NoError(t, got.DecodeSpanner(encoded)) + assert.Equal(t, want, got) }) - t.Run("decodes what it encodes", func(t *testing.T) { - for _, want := range []InvoiceID{trogonobjectid.MustNew[invoiceType](invoiceUUID), {}} { - encoded, err := want.EncodeSpanner() - require.NoError(t, err) - if p, ok := encoded.(*string); ok && p == nil { - encoded = nil - } - var got InvoiceID - require.NoError(t, got.DecodeSpanner(encoded)) - assert.Equal(t, want, got) - } + t.Run("decodes NULL as the zero ID", func(t *testing.T) { + got := trogonobjectid.MustNew[invoiceType](invoiceUUID) + require.NoError(t, got.DecodeSpanner(nil)) + assert.True(t, got.IsZero()) }) t.Run("rejects a value in the other format", func(t *testing.T) { diff --git a/union.go b/union.go index d700dff..5a61853 100644 --- a/union.go +++ b/union.go @@ -230,3 +230,15 @@ func (u *Union[U]) set(str string) error { *u = v return nil } + +// EncodeSpanner implements spanner.Encoder with the full form. The zero union is a NULL +// STRING, the same as the zero ID. +func (u Union[U]) EncodeSpanner() (any, error) { + if u.IsZero() { + return (*string)(nil), nil + } + return u.String(), nil +} + +// DecodeSpanner implements spanner.Decoder for STRING values in the full form. +func (u *Union[U]) DecodeSpanner(val any) error { return u.Scan(val) } diff --git a/union_test.go b/union_test.go index 5e4d382..4d1b88c 100644 --- a/union_test.go +++ b/union_test.go @@ -248,3 +248,19 @@ func TestUnionZero(t *testing.T) { assert.Nil(t, p.Member()) assert.Empty(t, p.String()) } + +func TestUnionSpanner(t *testing.T) { + want := trogonobjectid.MustNewUnion[principalUnion](trogonobjectid.MustNew[userType]("abc-123")) + + encoded, err := want.EncodeSpanner() + require.NoError(t, err) + assert.Equal(t, "user_abc-123", encoded) + + var got PrincipalID + require.NoError(t, got.DecodeSpanner(encoded)) + assert.Equal(t, want, got) + + zero, err := PrincipalID{}.EncodeSpanner() + require.NoError(t, err) + assert.Equal(t, (*string)(nil), zero) +} diff --git a/validate.go b/validate.go index cbae6d5..1864fc3 100644 --- a/validate.go +++ b/validate.go @@ -1,13 +1,16 @@ package trogonobjectid -import "github.com/google/uuid" +import ( + "strings" -// ValidateUUID accepts the UUID forms that uuid.Parse accepts. It keeps the value as given. + "github.com/google/uuid" +) + +// ValidateUUID accepts the UUID forms that uuid.Parse accepts. It keeps the value as +// given; use NormalizeUUID instead to store one canonical form. func ValidateUUID(id string) error { - if _, err := uuid.Parse(id); err != nil { - return ErrInvalidUUID - } - return nil + _, err := NormalizeUUID(id) + return err } // ValidateInteger accepts an optional sign followed by decimal digits, with no size limit, @@ -47,11 +50,15 @@ func GenerateUUIDv4() (string, error) { } // NormalizeUUID returns the canonical lowercase hyphenated form of any UUID form that -// uuid.Parse accepts, so upper-case and URN inputs name the same ID. +// uuid.Parse accepts, so upper-case and URN inputs name the same ID. It also rejects +// values that are not UUIDs, so a type that sets it needs no ValidateUUID. func NormalizeUUID(id string) (string, error) { u, err := uuid.Parse(id) if err != nil { return "", ErrInvalidUUID } + if len(id) == 36 && id == strings.ToLower(id) { + return id, nil + } return u.String(), nil } From 8cf62c1f232b17eba951340c1e98fbfb1e3a2a8f Mon Sep 17 00:00:00 2001 From: Erik Nilsen Date: Thu, 22 Oct 2026 07:22:26 -0700 Subject: [PATCH 4/5] refactor: share the spec cache, store pointers, and take a base Definition Signed-off-by: Erik Nilsen --- README.md | 12 +++--- cache.go | 24 ++++++++++++ objectid.go | 30 +++++---------- objectidproto/objectidproto.go | 58 +++++++---------------------- objectidproto/objectidproto_test.go | 47 +++++++++-------------- union.go | 16 +------- 6 files changed, 71 insertions(+), 116 deletions(-) create mode 100644 cache.go diff --git a/README.md b/README.md index 7d3501c..79b3a56 100644 --- a/README.md +++ b/README.md @@ -71,15 +71,13 @@ A union always encodes the full form, in JSON, SQL and Spanner too, because the ### Read the prefix from a proto annotation -Package `objectidproto` reads `object_type` and `separator` from the `trogon.object_id.v1alpha1.enum_value` annotation on an enum value, so every language that uses the proto shares one prefix. Options set the fields that the annotation does not carry: +Package `objectidproto` reads `object_type` and `separator` from the `trogon.object_id.v1alpha1.enum_value` annotation on an enum value, so every language that uses the proto shares one prefix. The other fields come from the base `Definition` you pass: ```go -var ticketDefinition = objectidproto.MustDefinition( - acmev1.ObjectType_OBJECT_TYPE_TICKET, - objectidproto.WithNormalize(trogonobjectid.NormalizeUUID), - objectidproto.WithValidate(trogonobjectid.ValidateUUID), - objectidproto.WithGenerate(trogonobjectid.GenerateUUIDv7), -) +var ticketDefinition = objectidproto.MustDefinition(acmev1.ObjectType_OBJECT_TYPE_TICKET, trogonobjectid.Definition{ + Normalize: trogonobjectid.NormalizeUUID, + Generate: trogonobjectid.GenerateUUIDv7, +}) type ticketType struct{} diff --git a/cache.go b/cache.go new file mode 100644 index 0000000..af0d74f --- /dev/null +++ b/cache.go @@ -0,0 +1,24 @@ +package trogonobjectid + +import ( + "reflect" + "sync" +) + +type cached[V any] struct { + value V + err error +} + +// loadOrBuild returns the value cached for key, building it on first use. The cache +// holds pointers, so a lookup on the encode and parse paths copies no struct. +func loadOrBuild[V any](cache *sync.Map, key reflect.Type, build func() (V, error)) (*V, error) { + if c, ok := cache.Load(key); ok { + entry := c.(*cached[V]) + return &entry.value, entry.err + } + value, err := build() + c, _ := cache.LoadOrStore(key, &cached[V]{value: value, err: err}) + entry := c.(*cached[V]) + return &entry.value, entry.err +} diff --git a/objectid.go b/objectid.go index e028c6a..8a533ce 100644 --- a/objectid.go +++ b/objectid.go @@ -72,24 +72,12 @@ type spec struct { prefix string } -type cachedSpec struct { - spec spec - err error -} - // specs caches each marker type's spec, keyed by reflect.Type, so encoding an ID does // not rebuild the prefix on every call. var specs sync.Map -func specFor[T Definer]() (spec, error) { - key := reflect.TypeFor[T]() - if cached, ok := specs.Load(key); ok { - c := cached.(cachedSpec) - return c.spec, c.err - } - s, err := newSpec[T]() - specs.Store(key, cachedSpec{spec: s, err: err}) - return s, err +func specFor[T Definer]() (*spec, error) { + return loadOrBuild(&specs, reflect.TypeFor[T](), newSpec[T]) } func newSpec[T Definer]() (spec, error) { @@ -104,7 +92,7 @@ func newSpec[T Definer]() (spec, error) { return spec{Definition: def, prefix: def.ObjectType + def.Separator}, nil } -func (s spec) build(id string) (string, error) { +func (s *spec) build(id string) (string, error) { if id == "" { return "", &ValidationError{ObjectType: s.ObjectType, Value: id, Err: ErrEmptyValue} } @@ -123,7 +111,7 @@ func (s spec) build(id string) (string, error) { return id, nil } -func (s spec) parse(full string) (string, error) { +func (s *spec) parse(full string) (string, error) { bare, ok := strings.CutPrefix(full, s.prefix) if !ok || bare == "" { return "", &ValidationError{ObjectType: s.ObjectType, Value: full, Err: ErrInvalidFormat} @@ -131,14 +119,14 @@ func (s spec) parse(full string) (string, error) { return s.build(bare) } -func (s spec) decode(format Format, value string) (string, error) { +func (s *spec) decode(format Format, value string) (string, error) { if format == FormatDropPrefix { return s.build(value) } return s.parse(value) } -func (s spec) encode(format Format, id string) string { +func (s *spec) encode(format Format, id string) string { if format == FormatDropPrefix { return id } @@ -224,7 +212,7 @@ func FromStorage[T Definer](value string) (ID[T], error) { } // mustSpec is for methods on an ID that already exists, which proves the definition is valid. -func mustSpec[T Definer]() spec { +func mustSpec[T Definer]() *spec { s, err := specFor[T]() if err != nil { panic(err) @@ -247,10 +235,10 @@ func (id ID[T]) IsZero() bool { return id.id == "" } func (id ID[T]) Bare() string { return id.id } // ObjectType returns the type's object type, for example "user". -func (id ID[T]) ObjectType() string { return mustSpec[T]().ObjectType } +func (id ID[T]) ObjectType() string { return ObjectTypeOf[T]() } // Prefix returns the object type and separator, for example "user_". -func (id ID[T]) Prefix() string { return mustSpec[T]().prefix } +func (id ID[T]) Prefix() string { return PrefixOf[T]() } // String returns the prefixed form, for example "user_abc-123", or "" for the zero ID. func (id ID[T]) String() string { diff --git a/objectidproto/objectidproto.go b/objectidproto/objectidproto.go index c0e4434..5644b94 100644 --- a/objectidproto/objectidproto.go +++ b/objectidproto/objectidproto.go @@ -7,13 +7,12 @@ // } // // The object type and separator then live in one place for every language that uses -// the proto. Options set the other fields: +// the proto, and the other fields come from a base Definition: // -// var ticketDefinition = objectidproto.MustDefinition( -// acmev1.ObjectType_OBJECT_TYPE_TICKET, -// objectidproto.WithStorageFormat(trogonobjectid.FormatDropPrefix), -// objectidproto.WithValidate(trogonobjectid.ValidateUUID), -// ) +// var ticketDefinition = objectidproto.MustDefinition(acmev1.ObjectType_OBJECT_TYPE_TICKET, trogonobjectid.Definition{ +// StorageFormat: trogonobjectid.FormatDropPrefix, +// Normalize: trogonobjectid.NormalizeUUID, +// }) // // type ticketType struct{} // @@ -33,9 +32,10 @@ import ( ) // Definition returns a Definition with ObjectType and Separator read from the enum -// value's annotation. An absent separator gives trogonobjectid.DefaultSeparator. -// Options set the fields that the annotation does not carry. -func Definition(value protoreflect.Enum, opts ...Option) (trogonobjectid.Definition, error) { +// value's annotation, over base. An absent separator gives +// trogonobjectid.DefaultSeparator. base carries the fields the annotation does not, so +// a new Definition field needs no change here. +func Definition(value protoreflect.Enum, base trogonobjectid.Definition) (trogonobjectid.Definition, error) { desc := value.Descriptor().Values().ByNumber(value.Number()) if desc == nil { return trogonobjectid.Definition{}, fmt.Errorf("%w: %s has no value %d", @@ -58,46 +58,16 @@ func Definition(value protoreflect.Enum, opts ...Option) (trogonobjectid.Definit if ext.Separator != nil { separator = ext.GetSeparator() } - def := trogonobjectid.Definition{ObjectType: ext.GetObjectType(), Separator: separator} - for _, opt := range opts { - opt(&def) - } - return def, nil + base.ObjectType = ext.GetObjectType() + base.Separator = separator + return base, nil } // MustDefinition is like Definition but panics on error. Use it in package-level variables. -func MustDefinition(value protoreflect.Enum, opts ...Option) trogonobjectid.Definition { - def, err := Definition(value, opts...) +func MustDefinition(value protoreflect.Enum, base trogonobjectid.Definition) trogonobjectid.Definition { + def, err := Definition(value, base) if err != nil { panic(err) } return def } - -// Option sets a Definition field that the proto annotation does not carry. -type Option func(*trogonobjectid.Definition) - -// WithStorageFormat sets Definition.StorageFormat. -func WithStorageFormat(format trogonobjectid.Format) Option { - return func(d *trogonobjectid.Definition) { d.StorageFormat = format } -} - -// WithJSONFormat sets Definition.JSONFormat. -func WithJSONFormat(format trogonobjectid.Format) Option { - return func(d *trogonobjectid.Definition) { d.JSONFormat = format } -} - -// WithNormalize sets Definition.Normalize. -func WithNormalize(normalize func(id string) (string, error)) Option { - return func(d *trogonobjectid.Definition) { d.Normalize = normalize } -} - -// WithValidate sets Definition.Validate. -func WithValidate(validate func(id string) error) Option { - return func(d *trogonobjectid.Definition) { d.Validate = validate } -} - -// WithGenerate sets Definition.Generate. -func WithGenerate(generate func() (string, error)) Option { - return func(d *trogonobjectid.Definition) { d.Generate = generate } -} diff --git a/objectidproto/objectidproto_test.go b/objectidproto/objectidproto_test.go index 5bc1d91..9e41519 100644 --- a/objectidproto/objectidproto_test.go +++ b/objectidproto/objectidproto_test.go @@ -71,13 +71,13 @@ func TestDefinition(t *testing.T) { enum := objectTypeEnum(t) t.Run("reads the object type and uses the default separator", func(t *testing.T) { - def, err := objectidproto.Definition(enum.New(valueTicket)) + def, err := objectidproto.Definition(enum.New(valueTicket), trogonobjectid.Definition{}) require.NoError(t, err) assert.Equal(t, trogonobjectid.Definition{ObjectType: "tkt", Separator: "_"}, def) }) t.Run("reads the separator", func(t *testing.T) { - def, err := objectidproto.Definition(enum.New(valueOrder)) + def, err := objectidproto.Definition(enum.New(valueOrder), trogonobjectid.Definition{}) require.NoError(t, err) assert.Equal(t, trogonobjectid.Definition{ObjectType: "order", Separator: "#"}, def) }) @@ -92,13 +92,13 @@ func TestDefinition(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := objectidproto.Definition(enum.New(tt.number)) + _, err := objectidproto.Definition(enum.New(tt.number), trogonobjectid.Definition{}) require.ErrorIs(t, err, trogonobjectid.ErrInvalidDefinition) }) } t.Run("MustDefinition panics on error", func(t *testing.T) { - assert.Panics(t, func() { objectidproto.MustDefinition(enum.New(valueUnspecified)) }) + assert.Panics(t, func() { objectidproto.MustDefinition(enum.New(valueUnspecified), trogonobjectid.Definition{}) }) }) } @@ -107,7 +107,7 @@ var ticketEnum protoreflect.EnumType type ticketType struct{} func (ticketType) Definition() trogonobjectid.Definition { - def := objectidproto.MustDefinition(ticketEnum.New(valueTicket)) + def := objectidproto.MustDefinition(ticketEnum.New(valueTicket), trogonobjectid.Definition{}) def.Validate = trogonobjectid.ValidateInteger return def } @@ -123,33 +123,20 @@ func TestDefinitionWithID(t *testing.T) { require.ErrorIs(t, err, trogonobjectid.ErrInvalidInteger) } -func TestDefinitionOptions(t *testing.T) { +func TestDefinitionBase(t *testing.T) { enum := objectTypeEnum(t) + base := trogonobjectid.Definition{ + ObjectType: "ignored", + Separator: "ignored", + StorageFormat: trogonobjectid.FormatDropPrefix, + JSONFormat: trogonobjectid.FormatDropPrefix, + } - def, err := objectidproto.Definition(enum.New(valueTicket), - objectidproto.WithStorageFormat(trogonobjectid.FormatDropPrefix), - objectidproto.WithJSONFormat(trogonobjectid.FormatDropPrefix), - objectidproto.WithNormalize(trogonobjectid.NormalizeUUID), - objectidproto.WithValidate(trogonobjectid.ValidateUUID), - objectidproto.WithGenerate(trogonobjectid.GenerateUUIDv7), - ) - require.NoError(t, err) - - assert.Equal(t, "tkt", def.ObjectType) - assert.Equal(t, trogonobjectid.FormatDropPrefix, def.StorageFormat) - assert.Equal(t, trogonobjectid.FormatDropPrefix, def.JSONFormat) - normalized, err := def.Normalize("0199E3A1-7C2B-7D4E-9F10-2A3B4C5D6E7F") + def, err := objectidproto.Definition(enum.New(valueOrder), base) require.NoError(t, err) - assert.Equal(t, "0199e3a1-7c2b-7d4e-9f10-2a3b4c5d6e7f", normalized) - require.ErrorIs(t, def.Validate("nope"), trogonobjectid.ErrInvalidUUID) - generated, err := def.Generate() - require.NoError(t, err) - require.NoError(t, trogonobjectid.ValidateUUID(generated)) - t.Run("options do not change the annotation fields", func(t *testing.T) { - def, err := objectidproto.Definition(enum.New(valueOrder), objectidproto.WithStorageFormat(trogonobjectid.FormatDropPrefix)) - require.NoError(t, err) - assert.Equal(t, "order", def.ObjectType) - assert.Equal(t, "#", def.Separator) - }) + want := base + want.ObjectType = "order" + want.Separator = "#" + assert.Equal(t, want, def) } diff --git a/union.go b/union.go index da19ccb..c2f9c25 100644 --- a/union.go +++ b/union.go @@ -69,22 +69,10 @@ type unionSpec struct { members []MemberType } -type cachedUnionSpec struct { - spec unionSpec - err error -} - var unionSpecs sync.Map -func unionSpecFor[U UnionDefiner]() (unionSpec, error) { - key := reflect.TypeFor[U]() - if cached, ok := unionSpecs.Load(key); ok { - c := cached.(cachedUnionSpec) - return c.spec, c.err - } - s, err := newUnionSpec[U]() - unionSpecs.Store(key, cachedUnionSpec{spec: s, err: err}) - return s, err +func unionSpecFor[U UnionDefiner]() (*unionSpec, error) { + return loadOrBuild(&unionSpecs, reflect.TypeFor[U](), newUnionSpec[U]) } func newUnionSpec[U UnionDefiner]() (unionSpec, error) { From 1dab7e697d28d3f1350421103d1192e8064b3b5e Mon Sep 17 00:00:00 2001 From: Erik Nilsen Date: Thu, 22 Oct 2026 07:22:46 -0700 Subject: [PATCH 5/5] perf: skip rebuilding canonical UUIDs and share UUID parsing Signed-off-by: Erik Nilsen --- objectid_test.go | 7 +++++++ validate.go | 36 +++++++++++++++++++++++++++--------- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/objectid_test.go b/objectid_test.go index 1301870..da962c3 100644 --- a/objectid_test.go +++ b/objectid_test.go @@ -437,3 +437,10 @@ func TestSpanner(t *testing.T) { require.ErrorIs(t, got.DecodeSpanner("inv_"+invoiceUUID), trogonobjectid.ErrInvalidUUID) }) } + +func BenchmarkNormalizeUUID(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + _, _ = trogonobjectid.NormalizeUUID(invoiceUUID) + } +} diff --git a/validate.go b/validate.go index 1864fc3..1fe3b0c 100644 --- a/validate.go +++ b/validate.go @@ -1,18 +1,22 @@ package trogonobjectid -import ( - "strings" - - "github.com/google/uuid" -) +import "github.com/google/uuid" // ValidateUUID accepts the UUID forms that uuid.Parse accepts. It keeps the value as // given; use NormalizeUUID instead to store one canonical form. func ValidateUUID(id string) error { - _, err := NormalizeUUID(id) + _, err := parseUUID(id) return err } +func parseUUID(id string) (uuid.UUID, error) { + u, err := uuid.Parse(id) + if err != nil { + return uuid.Nil, ErrInvalidUUID + } + return u, nil +} + // ValidateInteger accepts an optional sign followed by decimal digits, with no size limit, // to match the Elixir package. func ValidateInteger(id string) error { @@ -53,12 +57,26 @@ func GenerateUUIDv4() (string, error) { // uuid.Parse accepts, so upper-case and URN inputs name the same ID. It also rejects // values that are not UUIDs, so a type that sets it needs no ValidateUUID. func NormalizeUUID(id string) (string, error) { - u, err := uuid.Parse(id) + u, err := parseUUID(id) if err != nil { - return "", ErrInvalidUUID + return "", err } - if len(id) == 36 && id == strings.ToLower(id) { + if isCanonicalUUID(id) { return id, nil } return u.String(), nil } + +// isCanonicalUUID reports whether a value that uuid.Parse accepted is already in the +// 36-character lowercase form, so the common read path skips building a new string. +func isCanonicalUUID(id string) bool { + if len(id) != 36 { + return false + } + for i := 0; i < len(id); i++ { + if id[i] >= 'A' && id[i] <= 'F' { + return false + } + } + return true +}