Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 24 additions & 0 deletions cache.go
Original file line number Diff line number Diff line change
@@ -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
}
37 changes: 28 additions & 9 deletions objectid.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import (
"encoding/json"
"fmt"
"log/slog"
"reflect"
"strings"
"sync"
)

// DefaultSeparator is the separator used when Definition.Separator is empty.
Expand Down Expand Up @@ -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{}
//
Expand Down Expand Up @@ -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 == "" {
Expand All @@ -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}
}
Expand All @@ -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}
Expand All @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -195,25 +206,33 @@ 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)
}
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 == "" }

// Bare returns the value without the prefix, for example "abc-123".
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 {
Expand Down
41 changes: 41 additions & 0 deletions objectid_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
34 changes: 17 additions & 17 deletions objectidproto/objectidproto.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
//
Expand All @@ -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())
Expand All @@ -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)
}
Expand Down
28 changes: 23 additions & 5 deletions objectidproto/objectidproto_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand All @@ -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{}) })
})
}

Expand All @@ -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
}
Expand All @@ -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)
}
10 changes: 9 additions & 1 deletion union.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
Loading