From ee862509dc88eea8702faffc047681091244f44c Mon Sep 17 00:00:00 2001 From: Erik Nilsen Date: Thu, 22 Oct 2026 07:04:27 -0700 Subject: [PATCH 1/8] 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/8] 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 383a6530f45400b8da6390f58457b590d8aeb26d Mon Sep 17 00:00:00 2001 From: Erik Nilsen Date: Thu, 22 Oct 2026 07:11:31 -0700 Subject: [PATCH 3/8] feat!: name object types and separators, and check definitions early Signed-off-by: Erik Nilsen --- README.md | 17 ++++- check_test.go | 106 ++++++++++++++++++++++++++++ errors.go | 15 +++- example_test.go | 2 +- objectid.go | 62 ++++++++++++++-- objectid_test.go | 6 +- objectidproto/objectidproto.go | 4 +- objectidproto/objectidproto_test.go | 6 +- union.go | 15 ++-- 9 files changed, 210 insertions(+), 23 deletions(-) create mode 100644 check_test.go diff --git a/README.md b/README.md index 45c1140..0c08a95 100644 --- a/README.md +++ b/README.md @@ -92,14 +92,27 @@ The package caches each type's definition, so `Definition` must always return th | Field | Default | Effect | | --- | --- | --- | -| `ObjectType` | required | Prefix without the separator, for example `"user"` | -| `Separator` | `"_"` | Goes between the object type and the bare value | +| `ObjectType` | required | Prefix without the separator, for example `"user"`. Lowercase snake_case of at most 63 characters, the TypeID prefix rule | +| `Separator` | `DefaultSeparator` (`"_"`) | 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 | +### Check definitions in a test + +`Check[T]()` and `CheckUnion[U]()` report a bad definition before the first ID is built: an invalid object type, a generator that the type's own validation rejects, or duplicate union prefixes. The Elixir package does these checks at compile time. + +```go +func TestIDDefinitions(t *testing.T) { + require.NoError(t, trogonobjectid.Check[invoiceType]()) + require.NoError(t, trogonobjectid.CheckUnion[principalUnion]()) +} +``` + +A value with another type's prefix fails with a `*WrongPrefixError`, which names the prefix it got and the one it expected. It unwraps to `ErrInvalidFormat`. + ## Emitted forms | Method | Emits | Controlled by | diff --git a/check_test.go b/check_test.go new file mode 100644 index 0000000..06d2333 --- /dev/null +++ b/check_test.go @@ -0,0 +1,106 @@ +package trogonobjectid_test + +import ( + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/TrogonStack/trogonobjectid" +) + +type namedType[N interface { + name() trogonobjectid.ObjectType +}] struct{} + +func (namedType[N]) Definition() trogonobjectid.Definition { + var n N + return trogonobjectid.Definition{ObjectType: n.name()} +} + +type ( + upperName struct{} + digitName struct{} + dashName struct{} + leadingName struct{} + trailingName struct{} + longName struct{} + maxName struct{} + snakeName struct{} +) + +func (upperName) name() trogonobjectid.ObjectType { return "User" } +func (digitName) name() trogonobjectid.ObjectType { return "user2" } +func (dashName) name() trogonobjectid.ObjectType { return "credit-note" } +func (leadingName) name() trogonobjectid.ObjectType { return "_user" } +func (trailingName) name() trogonobjectid.ObjectType { return "user_" } +func (longName) name() trogonobjectid.ObjectType { + return trogonobjectid.ObjectType(strings.Repeat("a", 64)) +} +func (maxName) name() trogonobjectid.ObjectType { + return trogonobjectid.ObjectType(strings.Repeat("a", 63)) +} +func (snakeName) name() trogonobjectid.ObjectType { return "credit_note" } + +func TestCheck(t *testing.T) { + t.Run("accepts valid definitions", func(t *testing.T) { + require.NoError(t, trogonobjectid.Check[invoiceType]()) + require.NoError(t, trogonobjectid.Check[namedType[snakeName]]()) + require.NoError(t, trogonobjectid.Check[namedType[maxName]]()) + }) + + invalid := map[string]error{ + "empty object type": trogonobjectid.Check[badType](), + "upper case": trogonobjectid.Check[namedType[upperName]](), + "digit": trogonobjectid.Check[namedType[digitName]](), + "dash": trogonobjectid.Check[namedType[dashName]](), + "leading underscore": trogonobjectid.Check[namedType[leadingName]](), + "trailing underscore": trogonobjectid.Check[namedType[trailingName]](), + "64 characters": trogonobjectid.Check[namedType[longName]](), + } + for name, err := range invalid { + t.Run("rejects "+name, func(t *testing.T) { + require.ErrorIs(t, err, trogonobjectid.ErrInvalidDefinition) + }) + } + + t.Run("rejects a generator that its own validation rejects", func(t *testing.T) { + err := trogonobjectid.Check[seqType]() + require.ErrorIs(t, err, trogonobjectid.ErrInvalidDefinition) + require.ErrorIs(t, err, trogonobjectid.ErrInvalidInteger) + }) + + t.Run("an invalid object type fails every constructor", func(t *testing.T) { + _, err := trogonobjectid.New[namedType[upperName]]("a") + require.ErrorIs(t, err, trogonobjectid.ErrInvalidDefinition) + }) +} + +func TestCheckUnion(t *testing.T) { + require.NoError(t, trogonobjectid.CheckUnion[principalUnion]()) + require.ErrorIs(t, trogonobjectid.CheckUnion[duplicateUnion](), trogonobjectid.ErrInvalidDefinition) + require.ErrorIs(t, trogonobjectid.CheckUnion[emptyUnion](), trogonobjectid.ErrInvalidDefinition) +} + +func TestWrongPrefix(t *testing.T) { + _, err := trogonobjectid.Parse[invoiceType]("order_" + invoiceUUID) + + var wrong *trogonobjectid.WrongPrefixError + require.True(t, errors.As(err, &wrong)) + assert.Equal(t, "order_", wrong.Got) + assert.Equal(t, "inv_", wrong.Want) + require.ErrorIs(t, err, trogonobjectid.ErrInvalidFormat) + + t.Run("no separator is a plain format error", func(t *testing.T) { + _, err := trogonobjectid.Parse[invoiceType](invoiceUUID) + require.ErrorIs(t, err, trogonobjectid.ErrInvalidFormat) + assert.False(t, errors.As(err, &wrong)) + }) +} + +func TestNamedTypes(t *testing.T) { + assert.Equal(t, trogonobjectid.ObjectType("order"), trogonobjectid.ObjectTypeOf[orderType]()) + assert.Equal(t, trogonobjectid.Separator("_"), trogonobjectid.DefaultSeparator) +} diff --git a/errors.go b/errors.go index 7d935f9..08ba80f 100644 --- a/errors.go +++ b/errors.go @@ -25,7 +25,7 @@ var ( // ValidationError reports a value that is not a valid ID of ObjectType. // Err is ErrEmptyValue, ErrInvalidFormat, or the error from Definition.Validate. type ValidationError struct { - ObjectType string + ObjectType ObjectType Value string Err error } @@ -35,3 +35,16 @@ func (e *ValidationError) Error() string { } func (e *ValidationError) Unwrap() error { return e.Err } + +// WrongPrefixError reports a value that carries another type's prefix, for example an +// order ID where an invoice ID is expected. It unwraps to ErrInvalidFormat. +type WrongPrefixError struct { + Got string + Want string +} + +func (e *WrongPrefixError) Error() string { + return fmt.Sprintf("%v: prefix %q, want %q", ErrInvalidFormat, e.Got, e.Want) +} + +func (e *WrongPrefixError) Unwrap() error { return ErrInvalidFormat } diff --git a/example_test.go b/example_test.go index 2725d70..224e098 100644 --- a/example_test.go +++ b/example_test.go @@ -31,7 +31,7 @@ func ExampleParse() { fmt.Println(err) // Output: // abc-123 - // trogonobjectid: invalid user value "order_abc-123": invalid format + // trogonobjectid: invalid user value "order_abc-123": invalid format: prefix "order_", want "user_" } func ExampleNormalizeUUID() { diff --git a/objectid.go b/objectid.go index e028c6a..c188cdd 100644 --- a/objectid.go +++ b/objectid.go @@ -6,12 +6,22 @@ import ( "fmt" "log/slog" "reflect" + "regexp" "strings" "sync" ) +// ObjectType names an object ID type, for example "user". It is lowercase snake_case +// of at most 63 characters, the TypeID prefix rule. +type ObjectType string + +// Separator goes between the object type and the bare value. +type Separator string + // DefaultSeparator is the separator used when Definition.Separator is empty. -const DefaultSeparator = "_" +const DefaultSeparator Separator = "_" + +var objectTypePattern = regexp.MustCompile(`^[a-z]([a-z_]{0,61}[a-z])?$`) // Format selects whether an encoding keeps the prefix. type Format int @@ -26,9 +36,9 @@ const ( // Definition configures an object ID type. type Definition struct { // ObjectType is the prefix without the separator, for example "user". It is required. - ObjectType string + ObjectType ObjectType // Separator goes between the object type and the bare value. Empty means DefaultSeparator. - Separator string + Separator Separator // StorageFormat controls Storage, FromStorage, Value and Scan. StorageFormat Format // JSONFormat controls MarshalJSON and UnmarshalJSON. @@ -98,10 +108,14 @@ func newSpec[T Definer]() (spec, error) { if def.ObjectType == "" { return spec{}, fmt.Errorf("%w: empty object type for %T", ErrInvalidDefinition, marker) } + if !objectTypePattern.MatchString(string(def.ObjectType)) { + return spec{}, fmt.Errorf("%w: object type %q for %T is not lowercase snake_case of at most 63 characters", + ErrInvalidDefinition, def.ObjectType, marker) + } if def.Separator == "" { def.Separator = DefaultSeparator } - return spec{Definition: def, prefix: def.ObjectType + def.Separator}, nil + return spec{Definition: def, prefix: string(def.ObjectType) + string(def.Separator)}, nil } func (s spec) build(id string) (string, error) { @@ -125,12 +139,25 @@ func (s spec) build(id string) (string, error) { func (s spec) parse(full string) (string, error) { bare, ok := strings.CutPrefix(full, s.prefix) - if !ok || bare == "" { + if !ok { + return "", &ValidationError{ObjectType: s.ObjectType, Value: full, Err: s.prefixError(full)} + } + if bare == "" { return "", &ValidationError{ObjectType: s.ObjectType, Value: full, Err: ErrInvalidFormat} } return s.build(bare) } +// prefixError names the prefix that full carries when it has one, so a mixed-up ID +// type reads as such instead of as a malformed value. +func (s spec) prefixError(full string) error { + i := strings.Index(full, string(s.Separator)) + if i <= 0 { + return ErrInvalidFormat + } + return &WrongPrefixError{Got: full[:i+len(s.Separator)], Want: s.prefix} +} + func (s spec) decode(format Format, value string) (string, error) { if format == FormatDropPrefix { return s.build(value) @@ -234,7 +261,28 @@ func mustSpec[T Definer]() spec { // 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 } +func ObjectTypeOf[T Definer]() ObjectType { return mustSpec[T]().ObjectType } + +// Check reports whether T's Definition is usable: a valid object type, and a generator +// whose values pass the type's own Normalize and Validate. Call it from a test or init, +// the way the Elixir package checks options at compile time. +func Check[T Definer]() error { + s, err := specFor[T]() + if err != nil { + return err + } + if s.Generate == nil { + return nil + } + id, err := s.Generate() + if err != nil { + return fmt.Errorf("%w: generate %s: %w", ErrInvalidDefinition, s.ObjectType, err) + } + if _, err := s.build(id); err != nil { + return fmt.Errorf("%w: generated %s value is invalid: %w", ErrInvalidDefinition, s.ObjectType, err) + } + return nil +} // PrefixOf returns T's object type and separator, for example "user_". It panics if // T's Definition is invalid. @@ -247,7 +295,7 @@ 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() ObjectType { return mustSpec[T]().ObjectType } // Prefix returns the object type and separator, for example "user_". func (id ID[T]) Prefix() string { return mustSpec[T]().prefix } diff --git a/objectid_test.go b/objectid_test.go index c1871e9..04655e0 100644 --- a/objectid_test.go +++ b/objectid_test.go @@ -68,7 +68,7 @@ func TestNew(t *testing.T) { require.NoError(t, err) assert.Equal(t, "abc-123", id.Bare()) assert.Equal(t, "user_abc-123", id.String()) - assert.Equal(t, "user", id.ObjectType()) + assert.Equal(t, trogonobjectid.ObjectType("user"), id.ObjectType()) assert.Equal(t, "user_", id.Prefix()) assert.False(t, id.IsZero()) }) @@ -99,7 +99,7 @@ func TestNew(t *testing.T) { _, err := trogonobjectid.New[invoiceType]("nope") var verr *trogonobjectid.ValidationError require.ErrorAs(t, err, &verr) - assert.Equal(t, "inv", verr.ObjectType) + assert.Equal(t, trogonobjectid.ObjectType("inv"), verr.ObjectType) assert.Equal(t, "nope", verr.Value) assert.EqualError(t, err, `trogonobjectid: invalid inv value "nope": invalid uuid`) }) @@ -462,7 +462,7 @@ func TestSpanner(t *testing.T) { } func TestTypeLevelAccessors(t *testing.T) { - assert.Equal(t, "inv", trogonobjectid.ObjectTypeOf[invoiceType]()) + assert.Equal(t, trogonobjectid.ObjectType("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]() }) diff --git a/objectidproto/objectidproto.go b/objectidproto/objectidproto.go index c0e4434..07588bc 100644 --- a/objectidproto/objectidproto.go +++ b/objectidproto/objectidproto.go @@ -56,9 +56,9 @@ func Definition(value protoreflect.Enum, opts ...Option) (trogonobjectid.Definit separator := trogonobjectid.DefaultSeparator if ext.Separator != nil { - separator = ext.GetSeparator() + separator = trogonobjectid.Separator(ext.GetSeparator()) } - def := trogonobjectid.Definition{ObjectType: ext.GetObjectType(), Separator: separator} + def := trogonobjectid.Definition{ObjectType: trogonobjectid.ObjectType(ext.GetObjectType()), Separator: separator} for _, opt := range opts { opt(&def) } diff --git a/objectidproto/objectidproto_test.go b/objectidproto/objectidproto_test.go index 5bc1d91..549935d 100644 --- a/objectidproto/objectidproto_test.go +++ b/objectidproto/objectidproto_test.go @@ -135,7 +135,7 @@ func TestDefinitionOptions(t *testing.T) { ) require.NoError(t, err) - assert.Equal(t, "tkt", def.ObjectType) + assert.Equal(t, trogonobjectid.ObjectType("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") @@ -149,7 +149,7 @@ func TestDefinitionOptions(t *testing.T) { 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) + assert.Equal(t, trogonobjectid.ObjectType("order"), def.ObjectType) + assert.Equal(t, trogonobjectid.Separator("#"), def.Separator) }) } diff --git a/union.go b/union.go index 5caa2ad..76864aa 100644 --- a/union.go +++ b/union.go @@ -20,7 +20,7 @@ type Member interface { // MemberType describes one ID type that a Union accepts. Make it with MemberOf. type MemberType struct { - objectType string + objectType ObjectType prefix string err error parse func(s string) (Member, error) @@ -65,7 +65,7 @@ type Union[U UnionDefiner] struct { } type unionSpec struct { - name string + name ObjectType members []MemberType } @@ -104,13 +104,20 @@ func newUnionSpec[U UnionDefiner]() (unionSpec, error) { return unionSpec{}, fmt.Errorf("%w: %T has duplicate prefix %q", ErrInvalidDefinition, marker, m.prefix) } seen[m.prefix] = true - names = append(names, m.objectType) + names = append(names, string(m.objectType)) } // Longest prefix first, so "acme_admin_" wins over "acme_" for "acme_admin_xyz". sorted := append([]MemberType(nil), members...) sort.SliceStable(sorted, func(i, j int) bool { return len(sorted[i].prefix) > len(sorted[j].prefix) }) - return unionSpec{name: strings.Join(names, "|"), members: sorted}, nil + return unionSpec{name: ObjectType(strings.Join(names, "|")), members: sorted}, nil +} + +// CheckUnion reports whether U's member list is usable: at least one member, valid +// member definitions and no duplicate prefixes. +func CheckUnion[U UnionDefiner]() error { + _, err := unionSpecFor[U]() + return err } // NewUnion wraps m, which must be a non-zero ID of one of the union's member types. From 86dfc59524bfec27691d737e8ee0182a6ab688a1 Mon Sep 17 00:00:00 2001 From: Erik Nilsen Date: Thu, 22 Oct 2026 07:20:20 -0700 Subject: [PATCH 4/8] 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 5/8] 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 6/8] 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 +} From f68966b7f194c38875c6e800c607f9c573ea4eb0 Mon Sep 17 00:00:00 2001 From: Erik Nilsen Date: Thu, 22 Oct 2026 07:24:35 -0700 Subject: [PATCH 7/8] refactor: check object types with a loop and list accepted prefixes in errors Signed-off-by: Erik Nilsen --- README.md | 2 +- check_test.go | 89 +++++++++---------------------------- errors.go | 12 ++--- example_test.go | 2 +- objectid.go | 50 +++++++++++---------- objectid_test.go | 2 +- objecttype_internal_test.go | 20 +++++++++ union.go | 10 +++-- 8 files changed, 84 insertions(+), 103 deletions(-) create mode 100644 objecttype_internal_test.go diff --git a/README.md b/README.md index 87932f3..6e57f92 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ func TestIDDefinitions(t *testing.T) { } ``` -A value with another type's prefix fails with a `*WrongPrefixError`, which names the prefix it got and the one it expected. It unwraps to `ErrInvalidFormat`. +A value without one of the type's prefixes fails with a `*WrongPrefixError`, which lists the prefixes the type accepts. It unwraps to `ErrInvalidFormat`, and `ValidationError.Value` holds the input. ## Emitted forms diff --git a/check_test.go b/check_test.go index 06d2333..b38e52a 100644 --- a/check_test.go +++ b/check_test.go @@ -2,7 +2,6 @@ package trogonobjectid_test import ( "errors" - "strings" "testing" "github.com/stretchr/testify/assert" @@ -11,60 +10,16 @@ import ( "github.com/TrogonStack/trogonobjectid" ) -type namedType[N interface { - name() trogonobjectid.ObjectType -}] struct{} +type upperCaseType struct{} -func (namedType[N]) Definition() trogonobjectid.Definition { - var n N - return trogonobjectid.Definition{ObjectType: n.name()} +func (upperCaseType) Definition() trogonobjectid.Definition { + return trogonobjectid.Definition{ObjectType: "User"} } -type ( - upperName struct{} - digitName struct{} - dashName struct{} - leadingName struct{} - trailingName struct{} - longName struct{} - maxName struct{} - snakeName struct{} -) - -func (upperName) name() trogonobjectid.ObjectType { return "User" } -func (digitName) name() trogonobjectid.ObjectType { return "user2" } -func (dashName) name() trogonobjectid.ObjectType { return "credit-note" } -func (leadingName) name() trogonobjectid.ObjectType { return "_user" } -func (trailingName) name() trogonobjectid.ObjectType { return "user_" } -func (longName) name() trogonobjectid.ObjectType { - return trogonobjectid.ObjectType(strings.Repeat("a", 64)) -} -func (maxName) name() trogonobjectid.ObjectType { - return trogonobjectid.ObjectType(strings.Repeat("a", 63)) -} -func (snakeName) name() trogonobjectid.ObjectType { return "credit_note" } - func TestCheck(t *testing.T) { - t.Run("accepts valid definitions", func(t *testing.T) { - require.NoError(t, trogonobjectid.Check[invoiceType]()) - require.NoError(t, trogonobjectid.Check[namedType[snakeName]]()) - require.NoError(t, trogonobjectid.Check[namedType[maxName]]()) - }) - - invalid := map[string]error{ - "empty object type": trogonobjectid.Check[badType](), - "upper case": trogonobjectid.Check[namedType[upperName]](), - "digit": trogonobjectid.Check[namedType[digitName]](), - "dash": trogonobjectid.Check[namedType[dashName]](), - "leading underscore": trogonobjectid.Check[namedType[leadingName]](), - "trailing underscore": trogonobjectid.Check[namedType[trailingName]](), - "64 characters": trogonobjectid.Check[namedType[longName]](), - } - for name, err := range invalid { - t.Run("rejects "+name, func(t *testing.T) { - require.ErrorIs(t, err, trogonobjectid.ErrInvalidDefinition) - }) - } + require.NoError(t, trogonobjectid.Check[invoiceType]()) + require.ErrorIs(t, trogonobjectid.Check[badType](), trogonobjectid.ErrInvalidDefinition) + require.ErrorIs(t, trogonobjectid.Check[upperCaseType](), trogonobjectid.ErrInvalidDefinition) t.Run("rejects a generator that its own validation rejects", func(t *testing.T) { err := trogonobjectid.Check[seqType]() @@ -73,7 +28,7 @@ func TestCheck(t *testing.T) { }) t.Run("an invalid object type fails every constructor", func(t *testing.T) { - _, err := trogonobjectid.New[namedType[upperName]]("a") + _, err := trogonobjectid.New[upperCaseType]("a") require.ErrorIs(t, err, trogonobjectid.ErrInvalidDefinition) }) } @@ -85,22 +40,20 @@ func TestCheckUnion(t *testing.T) { } func TestWrongPrefix(t *testing.T) { - _, err := trogonobjectid.Parse[invoiceType]("order_" + invoiceUUID) - - var wrong *trogonobjectid.WrongPrefixError - require.True(t, errors.As(err, &wrong)) - assert.Equal(t, "order_", wrong.Got) - assert.Equal(t, "inv_", wrong.Want) - require.ErrorIs(t, err, trogonobjectid.ErrInvalidFormat) + for name, input := range map[string]string{"another type's prefix": "order_" + invoiceUUID, "no prefix": invoiceUUID} { + t.Run(name, func(t *testing.T) { + _, err := trogonobjectid.Parse[invoiceType](input) + var wrong *trogonobjectid.WrongPrefixError + require.True(t, errors.As(err, &wrong)) + assert.Equal(t, []string{"inv_"}, wrong.Want) + require.ErrorIs(t, err, trogonobjectid.ErrInvalidFormat) + }) + } - t.Run("no separator is a plain format error", func(t *testing.T) { - _, err := trogonobjectid.Parse[invoiceType](invoiceUUID) - require.ErrorIs(t, err, trogonobjectid.ErrInvalidFormat) - assert.False(t, errors.As(err, &wrong)) + t.Run("a union lists every member prefix", func(t *testing.T) { + _, err := trogonobjectid.ParseUnion[principalUnion]("order_abc") + var wrong *trogonobjectid.WrongPrefixError + require.True(t, errors.As(err, &wrong)) + assert.ElementsMatch(t, []string{"user_", "system_", "inv_"}, wrong.Want) }) } - -func TestNamedTypes(t *testing.T) { - assert.Equal(t, trogonobjectid.ObjectType("order"), trogonobjectid.ObjectTypeOf[orderType]()) - assert.Equal(t, trogonobjectid.Separator("_"), trogonobjectid.DefaultSeparator) -} diff --git a/errors.go b/errors.go index 08ba80f..645d6f9 100644 --- a/errors.go +++ b/errors.go @@ -25,7 +25,7 @@ var ( // ValidationError reports a value that is not a valid ID of ObjectType. // Err is ErrEmptyValue, ErrInvalidFormat, or the error from Definition.Validate. type ValidationError struct { - ObjectType ObjectType + ObjectType string Value string Err error } @@ -36,15 +36,15 @@ func (e *ValidationError) Error() string { func (e *ValidationError) Unwrap() error { return e.Err } -// WrongPrefixError reports a value that carries another type's prefix, for example an -// order ID where an invoice ID is expected. It unwraps to ErrInvalidFormat. +// WrongPrefixError reports a value without one of the prefixes the type accepts, for +// example an order ID where an invoice ID is expected. ValidationError.Value holds the +// input. It unwraps to ErrInvalidFormat. type WrongPrefixError struct { - Got string - Want string + Want []string } func (e *WrongPrefixError) Error() string { - return fmt.Sprintf("%v: prefix %q, want %q", ErrInvalidFormat, e.Got, e.Want) + return fmt.Sprintf("%v: want prefix %q", ErrInvalidFormat, e.Want) } func (e *WrongPrefixError) Unwrap() error { return ErrInvalidFormat } diff --git a/example_test.go b/example_test.go index 224e098..b8ed1d1 100644 --- a/example_test.go +++ b/example_test.go @@ -31,7 +31,7 @@ func ExampleParse() { fmt.Println(err) // Output: // abc-123 - // trogonobjectid: invalid user value "order_abc-123": invalid format: prefix "order_", want "user_" + // trogonobjectid: invalid user value "order_abc-123": invalid format: want prefix ["user_"] } func ExampleNormalizeUUID() { diff --git a/objectid.go b/objectid.go index 673b6d9..c45cdb9 100644 --- a/objectid.go +++ b/objectid.go @@ -3,10 +3,10 @@ package trogonobjectid import ( "database/sql/driver" "encoding/json" + "errors" "fmt" "log/slog" "reflect" - "regexp" "strings" "sync" ) @@ -21,7 +21,25 @@ type Separator string // DefaultSeparator is the separator used when Definition.Separator is empty. const DefaultSeparator Separator = "_" -var objectTypePattern = regexp.MustCompile(`^[a-z]([a-z_]{0,61}[a-z])?$`) +// validate applies the TypeID prefix rule: lowercase letters and underscores, at most +// 63 characters, starting and ending with a letter. +func (t ObjectType) validate() error { + if t == "" { + return errors.New("empty object type") + } + if len(t) > 63 { + return fmt.Errorf("object type %q is longer than 63 characters", t) + } + if t[0] == '_' || t[len(t)-1] == '_' { + return fmt.Errorf("object type %q starts or ends with an underscore", t) + } + for i := 0; i < len(t); i++ { + if c := t[i]; (c < 'a' || c > 'z') && c != '_' { + return fmt.Errorf("object type %q is not lowercase snake_case", t) + } + } + return nil +} // Format selects whether an encoding keeps the prefix. type Format int @@ -93,12 +111,8 @@ func specFor[T Definer]() (*spec, error) { func newSpec[T Definer]() (spec, error) { var marker T def := marker.Definition() - if def.ObjectType == "" { - return spec{}, fmt.Errorf("%w: empty object type for %T", ErrInvalidDefinition, marker) - } - if !objectTypePattern.MatchString(string(def.ObjectType)) { - return spec{}, fmt.Errorf("%w: object type %q for %T is not lowercase snake_case of at most 63 characters", - ErrInvalidDefinition, def.ObjectType, marker) + if err := def.ObjectType.validate(); err != nil { + return spec{}, fmt.Errorf("%w: %T: %w", ErrInvalidDefinition, marker, err) } if def.Separator == "" { def.Separator = DefaultSeparator @@ -108,18 +122,18 @@ func newSpec[T Definer]() (spec, error) { func (s *spec) build(id string) (string, error) { if id == "" { - return "", &ValidationError{ObjectType: s.ObjectType, Value: id, Err: ErrEmptyValue} + return "", &ValidationError{ObjectType: string(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} + return "", &ValidationError{ObjectType: string(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: string(s.ObjectType), Value: id, Err: err} } } return id, nil @@ -128,24 +142,14 @@ func (s *spec) build(id string) (string, error) { func (s *spec) parse(full string) (string, error) { bare, ok := strings.CutPrefix(full, s.prefix) if !ok { - return "", &ValidationError{ObjectType: s.ObjectType, Value: full, Err: s.prefixError(full)} + return "", &ValidationError{ObjectType: string(s.ObjectType), Value: full, Err: &WrongPrefixError{Want: []string{s.prefix}}} } if bare == "" { - return "", &ValidationError{ObjectType: s.ObjectType, Value: full, Err: ErrInvalidFormat} + return "", &ValidationError{ObjectType: string(s.ObjectType), Value: full, Err: ErrInvalidFormat} } return s.build(bare) } -// prefixError names the prefix that full carries when it has one, so a mixed-up ID -// type reads as such instead of as a malformed value. -func (s *spec) prefixError(full string) error { - i := strings.Index(full, string(s.Separator)) - if i <= 0 { - return ErrInvalidFormat - } - return &WrongPrefixError{Got: full[:i+len(s.Separator)], Want: s.prefix} -} - func (s *spec) decode(format Format, value string) (string, error) { if format == FormatDropPrefix { return s.build(value) diff --git a/objectid_test.go b/objectid_test.go index 05d7598..ff67021 100644 --- a/objectid_test.go +++ b/objectid_test.go @@ -99,7 +99,7 @@ func TestNew(t *testing.T) { _, err := trogonobjectid.New[invoiceType]("nope") var verr *trogonobjectid.ValidationError require.ErrorAs(t, err, &verr) - assert.Equal(t, trogonobjectid.ObjectType("inv"), verr.ObjectType) + assert.Equal(t, "inv", verr.ObjectType) assert.Equal(t, "nope", verr.Value) assert.EqualError(t, err, `trogonobjectid: invalid inv value "nope": invalid uuid`) }) diff --git a/objecttype_internal_test.go b/objecttype_internal_test.go new file mode 100644 index 0000000..777d622 --- /dev/null +++ b/objecttype_internal_test.go @@ -0,0 +1,20 @@ +package trogonobjectid + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestObjectTypeValidate(t *testing.T) { + valid := []ObjectType{"inv", "credit_note", ObjectType(strings.Repeat("a", 63))} + for _, objectType := range valid { + assert.NoError(t, objectType.validate(), objectType) + } + + invalid := []ObjectType{"", "User", "user2", "credit-note", "_user", "user_", ObjectType(strings.Repeat("a", 64))} + for _, objectType := range invalid { + assert.Error(t, objectType.validate(), objectType) + } +} diff --git a/union.go b/union.go index 04f1955..9858200 100644 --- a/union.go +++ b/union.go @@ -65,7 +65,7 @@ type Union[U UnionDefiner] struct { } type unionSpec struct { - name ObjectType + name string members []MemberType } @@ -98,7 +98,7 @@ func newUnionSpec[U UnionDefiner]() (unionSpec, error) { // Longest prefix first, so "acme_admin_" wins over "acme_" for "acme_admin_xyz". sorted := append([]MemberType(nil), members...) sort.SliceStable(sorted, func(i, j int) bool { return len(sorted[i].prefix) > len(sorted[j].prefix) }) - return unionSpec{name: ObjectType(strings.Join(names, "|")), members: sorted}, nil + return unionSpec{name: strings.Join(names, "|"), members: sorted}, nil } // CheckUnion reports whether U's member list is usable: at least one member, valid @@ -150,7 +150,11 @@ func ParseUnion[U UnionDefiner](str string) (Union[U], error) { return Union[U]{member: m}, nil } } - return Union[U]{}, &ValidationError{ObjectType: s.name, Value: str, Err: ErrInvalidFormat} + prefixes := make([]string, len(s.members)) + for i, mt := range s.members { + prefixes[i] = mt.prefix + } + return Union[U]{}, &ValidationError{ObjectType: s.name, Value: str, Err: &WrongPrefixError{Want: prefixes}} } // MustParseUnion is like ParseUnion but panics on error. Use it for constants and tests. From ec1eb1d191c59e8f40da7bf592a212556c5f8b9a Mon Sep 17 00:00:00 2001 From: Erik Nilsen Date: Thu, 22 Oct 2026 07:25:50 -0700 Subject: [PATCH 8/8] refactor: add a Prefix type and list ObjectTypes on validation errors Signed-off-by: Erik Nilsen --- README.md | 4 +++- check_test.go | 4 ++-- errors.go | 20 +++++++++++++------- objectid.go | 25 +++++++++++++++++-------- objectid_test.go | 8 ++++---- union.go | 22 +++++++++++----------- 6 files changed, 50 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 6e57f92..02f63da 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,9 @@ func TestIDDefinitions(t *testing.T) { } ``` -A value without one of the type's prefixes fails with a `*WrongPrefixError`, which lists the prefixes the type accepts. It unwraps to `ErrInvalidFormat`, and `ValidationError.Value` holds the input. +A value without one of the type's prefixes fails with a `*WrongPrefixError`, which lists the prefixes the type accepts. It unwraps to `ErrInvalidFormat`. Every parse error is a `*ValidationError` with the `ObjectTypes` it was checked against (every member's for a union) and the input `Value`. + +`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. ## Emitted forms diff --git a/check_test.go b/check_test.go index b38e52a..60e3b8f 100644 --- a/check_test.go +++ b/check_test.go @@ -45,7 +45,7 @@ func TestWrongPrefix(t *testing.T) { _, err := trogonobjectid.Parse[invoiceType](input) var wrong *trogonobjectid.WrongPrefixError require.True(t, errors.As(err, &wrong)) - assert.Equal(t, []string{"inv_"}, wrong.Want) + assert.Equal(t, []trogonobjectid.Prefix{"inv_"}, wrong.Want) require.ErrorIs(t, err, trogonobjectid.ErrInvalidFormat) }) } @@ -54,6 +54,6 @@ func TestWrongPrefix(t *testing.T) { _, err := trogonobjectid.ParseUnion[principalUnion]("order_abc") var wrong *trogonobjectid.WrongPrefixError require.True(t, errors.As(err, &wrong)) - assert.ElementsMatch(t, []string{"user_", "system_", "inv_"}, wrong.Want) + assert.ElementsMatch(t, []trogonobjectid.Prefix{"user_", "system_", "inv_"}, wrong.Want) }) } diff --git a/errors.go b/errors.go index 645d6f9..c9fed0e 100644 --- a/errors.go +++ b/errors.go @@ -3,6 +3,7 @@ package trogonobjectid import ( "errors" "fmt" + "strings" ) var ( @@ -22,16 +23,21 @@ var ( ErrNoGenerator = errors.New("trogonobjectid: no generator") ) -// ValidationError reports a value that is not a valid ID of ObjectType. -// Err is ErrEmptyValue, ErrInvalidFormat, or the error from Definition.Validate. +// ValidationError reports a value that is not a valid ID of the listed object types: one +// for an ID, every member's for a union. Err is ErrEmptyValue, ErrInvalidFormat, a +// *WrongPrefixError, or the error from Definition.Validate. type ValidationError struct { - ObjectType string - Value string - Err error + ObjectTypes []ObjectType + Value string + Err error } func (e *ValidationError) Error() string { - return fmt.Sprintf("trogonobjectid: invalid %s value %q: %v", e.ObjectType, e.Value, e.Err) + names := make([]string, len(e.ObjectTypes)) + for i, t := range e.ObjectTypes { + names[i] = string(t) + } + return fmt.Sprintf("trogonobjectid: invalid %s value %q: %v", strings.Join(names, "|"), e.Value, e.Err) } func (e *ValidationError) Unwrap() error { return e.Err } @@ -40,7 +46,7 @@ func (e *ValidationError) Unwrap() error { return e.Err } // example an order ID where an invoice ID is expected. ValidationError.Value holds the // input. It unwraps to ErrInvalidFormat. type WrongPrefixError struct { - Want []string + Want []Prefix } func (e *WrongPrefixError) Error() string { diff --git a/objectid.go b/objectid.go index c45cdb9..f0602ea 100644 --- a/objectid.go +++ b/objectid.go @@ -18,6 +18,9 @@ type ObjectType string // Separator goes between the object type and the bare value. type Separator string +// Prefix is an object type followed by its separator, for example "user_". +type Prefix string + // DefaultSeparator is the separator used when Definition.Separator is empty. const DefaultSeparator Separator = "_" @@ -98,6 +101,8 @@ type ID[T Definer] struct { type spec struct { Definition prefix string + // objectTypes is the one-element list that every ValidationError carries. + objectTypes []ObjectType } // specs caches each marker type's spec, keyed by reflect.Type, so encoding an ID does @@ -117,23 +122,27 @@ func newSpec[T Definer]() (spec, error) { if def.Separator == "" { def.Separator = DefaultSeparator } - return spec{Definition: def, prefix: string(def.ObjectType) + string(def.Separator)}, nil + return spec{ + Definition: def, + prefix: string(def.ObjectType) + string(def.Separator), + objectTypes: []ObjectType{def.ObjectType}, + }, nil } func (s *spec) build(id string) (string, error) { if id == "" { - return "", &ValidationError{ObjectType: string(s.ObjectType), Value: id, Err: ErrEmptyValue} + return "", &ValidationError{ObjectTypes: s.objectTypes, Value: id, Err: ErrEmptyValue} } if s.Normalize != nil { normalized, err := s.Normalize(id) if err != nil { - return "", &ValidationError{ObjectType: string(s.ObjectType), Value: id, Err: err} + return "", &ValidationError{ObjectTypes: s.objectTypes, Value: id, Err: err} } id = normalized } if s.Validate != nil { if err := s.Validate(id); err != nil { - return "", &ValidationError{ObjectType: string(s.ObjectType), Value: id, Err: err} + return "", &ValidationError{ObjectTypes: s.objectTypes, Value: id, Err: err} } } return id, nil @@ -142,10 +151,10 @@ func (s *spec) build(id string) (string, error) { func (s *spec) parse(full string) (string, error) { bare, ok := strings.CutPrefix(full, s.prefix) if !ok { - return "", &ValidationError{ObjectType: string(s.ObjectType), Value: full, Err: &WrongPrefixError{Want: []string{s.prefix}}} + return "", &ValidationError{ObjectTypes: s.objectTypes, Value: full, Err: &WrongPrefixError{Want: []Prefix{Prefix(s.prefix)}}} } if bare == "" { - return "", &ValidationError{ObjectType: string(s.ObjectType), Value: full, Err: ErrInvalidFormat} + return "", &ValidationError{ObjectTypes: s.objectTypes, Value: full, Err: ErrInvalidFormat} } return s.build(bare) } @@ -278,7 +287,7 @@ func Check[T Definer]() error { // 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 } +func PrefixOf[T Definer]() Prefix { return Prefix(mustSpec[T]().prefix) } // IsZero reports whether id is the zero (absent) ID. func (id ID[T]) IsZero() bool { return id.id == "" } @@ -290,7 +299,7 @@ func (id ID[T]) Bare() string { return id.id } func (id ID[T]) ObjectType() ObjectType { return ObjectTypeOf[T]() } // Prefix returns the object type and separator, for example "user_". -func (id ID[T]) Prefix() string { return PrefixOf[T]() } +func (id ID[T]) Prefix() Prefix { 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 ff67021..955b0a3 100644 --- a/objectid_test.go +++ b/objectid_test.go @@ -69,7 +69,7 @@ func TestNew(t *testing.T) { assert.Equal(t, "abc-123", id.Bare()) assert.Equal(t, "user_abc-123", id.String()) assert.Equal(t, trogonobjectid.ObjectType("user"), id.ObjectType()) - assert.Equal(t, "user_", id.Prefix()) + assert.Equal(t, trogonobjectid.Prefix("user_"), id.Prefix()) assert.False(t, id.IsZero()) }) @@ -99,7 +99,7 @@ func TestNew(t *testing.T) { _, err := trogonobjectid.New[invoiceType]("nope") var verr *trogonobjectid.ValidationError require.ErrorAs(t, err, &verr) - assert.Equal(t, "inv", verr.ObjectType) + assert.Equal(t, []trogonobjectid.ObjectType{"inv"}, verr.ObjectTypes) assert.Equal(t, "nope", verr.Value) assert.EqualError(t, err, `trogonobjectid: invalid inv value "nope": invalid uuid`) }) @@ -440,8 +440,8 @@ func TestSpanner(t *testing.T) { func TestTypeLevelAccessors(t *testing.T) { assert.Equal(t, trogonobjectid.ObjectType("inv"), trogonobjectid.ObjectTypeOf[invoiceType]()) - assert.Equal(t, "inv_", trogonobjectid.PrefixOf[invoiceType]()) - assert.Equal(t, "order#", trogonobjectid.PrefixOf[orderType]()) + assert.Equal(t, trogonobjectid.Prefix("inv_"), trogonobjectid.PrefixOf[invoiceType]()) + assert.Equal(t, trogonobjectid.Prefix("order#"), trogonobjectid.PrefixOf[orderType]()) assert.Panics(t, func() { trogonobjectid.PrefixOf[badType]() }) } diff --git a/union.go b/union.go index 9858200..0c071db 100644 --- a/union.go +++ b/union.go @@ -14,7 +14,7 @@ import ( // Member is an ID that a Union can hold. Every ID[T] implements it. type Member interface { fmt.Stringer - Prefix() string + Prefix() Prefix IsZero() bool } @@ -65,8 +65,8 @@ type Union[U UnionDefiner] struct { } type unionSpec struct { - name string - members []MemberType + objectTypes []ObjectType + members []MemberType } var unionSpecs sync.Map @@ -82,7 +82,7 @@ func newUnionSpec[U UnionDefiner]() (unionSpec, error) { return unionSpec{}, fmt.Errorf("%w: %T has no members", ErrInvalidDefinition, marker) } - names := make([]string, 0, len(members)) + objectTypes := make([]ObjectType, 0, len(members)) seen := make(map[string]bool, len(members)) for _, m := range members { if m.err != nil { @@ -92,13 +92,13 @@ func newUnionSpec[U UnionDefiner]() (unionSpec, error) { return unionSpec{}, fmt.Errorf("%w: %T has duplicate prefix %q", ErrInvalidDefinition, marker, m.prefix) } seen[m.prefix] = true - names = append(names, string(m.objectType)) + objectTypes = append(objectTypes, m.objectType) } // Longest prefix first, so "acme_admin_" wins over "acme_" for "acme_admin_xyz". sorted := append([]MemberType(nil), members...) sort.SliceStable(sorted, func(i, j int) bool { return len(sorted[i].prefix) > len(sorted[j].prefix) }) - return unionSpec{name: strings.Join(names, "|"), members: sorted}, nil + return unionSpec{objectTypes: objectTypes, members: sorted}, nil } // CheckUnion reports whether U's member list is usable: at least one member, valid @@ -115,14 +115,14 @@ func NewUnion[U UnionDefiner](m Member) (Union[U], error) { return Union[U]{}, err } if m == nil || m.IsZero() { - return Union[U]{}, &ValidationError{ObjectType: s.name, Err: ErrEmptyValue} + return Union[U]{}, &ValidationError{ObjectTypes: s.objectTypes, Err: ErrEmptyValue} } for _, mt := range s.members { if mt.holds(m) { return Union[U]{member: m}, nil } } - return Union[U]{}, &ValidationError{ObjectType: s.name, Value: m.String(), Err: ErrNotMember} + return Union[U]{}, &ValidationError{ObjectTypes: s.objectTypes, Value: m.String(), Err: ErrNotMember} } // MustNewUnion is like NewUnion but panics on error. Use it for constants and tests. @@ -150,11 +150,11 @@ func ParseUnion[U UnionDefiner](str string) (Union[U], error) { return Union[U]{member: m}, nil } } - prefixes := make([]string, len(s.members)) + prefixes := make([]Prefix, len(s.members)) for i, mt := range s.members { - prefixes[i] = mt.prefix + prefixes[i] = Prefix(mt.prefix) } - return Union[U]{}, &ValidationError{ObjectType: s.name, Value: str, Err: &WrongPrefixError{Want: prefixes}} + return Union[U]{}, &ValidationError{ObjectTypes: s.objectTypes, Value: str, Err: &WrongPrefixError{Want: prefixes}} } // MustParseUnion is like ParseUnion but panics on error. Use it for constants and tests.