diff --git a/README.md b/README.md index 1c69349..a3fe85a 100644 --- a/README.md +++ b/README.md @@ -71,21 +71,20 @@ 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: +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 = 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, trogonobjectid.Definition{ + Validate: trogonobjectid.ValidateUUID, + Generate: 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/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 0a3663b..e81f664 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. @@ -38,7 +40,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{} // @@ -66,7 +69,15 @@ type spec struct { prefix string } -func specFor[T Definer]() (spec, 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) { + return loadOrBuild(&specs, reflect.TypeFor[T](), newSpec[T]) +} + +func newSpec[T Definer]() (spec, error) { var marker T def := marker.Definition() if def.ObjectType == "" { @@ -78,7 +89,7 @@ func specFor[T Definer]() (spec, error) { return spec{Definition: def, prefix: def.ObjectType + def.Separator}, nil } -func (s spec) check(id string) error { +func (s *spec) check(id string) error { if id == "" { return &ValidationError{ObjectType: s.ObjectType, Value: id, Err: ErrEmptyValue} } @@ -90,7 +101,7 @@ func (s spec) check(id string) error { return 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} @@ -101,7 +112,7 @@ func (s spec) parse(full string) (string, error) { return bare, nil } -func (s spec) decode(format Format, value string) (string, error) { +func (s *spec) decode(format Format, value string) (string, error) { if format == FormatDropPrefix { if err := s.check(value); err != nil { return "", err @@ -111,7 +122,7 @@ func (s spec) decode(format Format, value string) (string, error) { 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 } @@ -195,7 +206,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) @@ -203,6 +214,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 == "" } @@ -210,10 +229,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/objectid_test.go b/objectid_test.go index d62c357..0412196 100644 --- a/objectid_test.go +++ b/objectid_test.go @@ -397,6 +397,47 @@ func TestSpanner(t *testing.T) { }) } +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) + } +} + func BenchmarkValidateUUID(b *testing.B) { b.ReportAllocs() for b.Loop() { diff --git a/objectidproto/objectidproto.go b/objectidproto/objectidproto.go index 57038cc..ee78369 100644 --- a/objectidproto/objectidproto.go +++ b/objectidproto/objectidproto.go @@ -7,15 +7,12 @@ // } // // 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, and the other fields come from a base Definition: // -// 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, trogonobjectid.Definition{ +// StorageFormat: trogonobjectid.FormatDropPrefix, +// Validate: trogonobjectid.ValidateUUID, +// }) // // type ticketType struct{} // @@ -35,22 +32,23 @@ 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) { +// 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", 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,12 +58,14 @@ func Definition(value protoreflect.Enum) (trogonobjectid.Definition, error) { if ext.Separator != nil { separator = ext.GetSeparator() } - return trogonobjectid.Definition{ObjectType: ext.GetObjectType(), Separator: separator}, 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) trogonobjectid.Definition { - def, err := Definition(value) +func MustDefinition(value protoreflect.Enum, base trogonobjectid.Definition) trogonobjectid.Definition { + def, err := Definition(value, base) if err != nil { panic(err) } diff --git a/objectidproto/objectidproto_test.go b/objectidproto/objectidproto_test.go index 9877ddd..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 } @@ -122,3 +122,21 @@ func TestDefinitionWithID(t *testing.T) { _, err = trogonobjectid.Parse[ticketType]("tkt_abc") require.ErrorIs(t, err, trogonobjectid.ErrInvalidInteger) } + +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(valueOrder), base) + require.NoError(t, err) + + want := base + want.ObjectType = "order" + want.Separator = "#" + assert.Equal(t, want, def) +} diff --git a/union.go b/union.go index 5a61853..c2f9c25 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,13 @@ type unionSpec struct { members []MemberType } -func unionSpecFor[U UnionDefiner]() (unionSpec, error) { +var unionSpecs sync.Map + +func unionSpecFor[U UnionDefiner]() (*unionSpec, error) { + return loadOrBuild(&unionSpecs, reflect.TypeFor[U](), newUnionSpec[U]) +} + +func newUnionSpec[U UnionDefiner]() (unionSpec, error) { var marker U members := marker.Members() if len(members) == 0 {