Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
ee86250
feat: normalize bare values and encode IDs for Spanner
enilsen16 Oct 22, 2026
87bf9aa
feat: add proto definition options and cache definitions per type
enilsen16 Oct 22, 2026
383a653
feat!: name object types and separators, and check definitions early
enilsen16 Oct 22, 2026
ca69a49
feat: build IDs from UUIDs and scan native UUID values
enilsen16 Oct 22, 2026
a600fe5
feat: add objectidtest for deterministic IDs in tests
enilsen16 Oct 22, 2026
cd30639
feat!: narrow unions with UnionAs and name member lists Types
enilsen16 Oct 22, 2026
440f5c5
feat: accept earlier prefixes with Definition.Aliases
enilsen16 Oct 22, 2026
86dfc59
refactor: share UUID parsing, encode unions for Spanner, and trim tests
enilsen16 Oct 22, 2026
4162f19
Merge branch 'feat/normalize-and-spanner' into feat/proto-definition-…
enilsen16 Oct 22, 2026
8cf62c1
refactor: share the spec cache, store pointers, and take a base Defin…
enilsen16 Oct 22, 2026
1dab7e6
perf: skip rebuilding canonical UUIDs and share UUID parsing
enilsen16 Oct 22, 2026
f3f8ae1
Merge branch 'feat/normalize-and-spanner' into feat/proto-definition-…
enilsen16 Oct 22, 2026
37fbfbc
Merge branch 'feat/proto-definition-options' into feat/named-types-an…
enilsen16 Oct 22, 2026
f68966b
refactor: check object types with a loop and list accepted prefixes i…
enilsen16 Oct 22, 2026
ec1eb1d
refactor: add a Prefix type and list ObjectTypes on validation errors
enilsen16 Oct 22, 2026
2d05350
Merge branch 'feat/named-types-and-checks' into feat/typed-uuid-access
enilsen16 Oct 22, 2026
76f106d
Merge branch 'feat/named-types-and-checks' into feat/typed-uuid-access
enilsen16 Oct 22, 2026
d193488
Merge branch 'feat/typed-uuid-access' into feat/objectidtest
enilsen16 Oct 22, 2026
a091f89
Merge branch 'feat/objectidtest' into feat/union-as
enilsen16 Oct 22, 2026
4d4888f
Merge branch 'feat/union-as' into feat/prefix-aliases
enilsen16 Oct 22, 2026
7dc80af
Merge remote-tracking branch 'origin/main' into feat/proto-definition…
enilsen16 Oct 22, 2026
2a567cd
refactor: rename TypeOf back to MemberOf
enilsen16 Oct 22, 2026
f2377a9
Merge branch 'feat/union-as' into feat/prefix-aliases
enilsen16 Oct 22, 2026
53ebadc
refactor: use MemberOf in the alias tests
enilsen16 Oct 22, 2026
bef78d5
Merge remote-tracking branch 'origin/main' into feat/proto-definition…
enilsen16 Oct 22, 2026
bac0683
Merge branch 'feat/proto-definition-options' into feat/named-types-an…
enilsen16 Oct 22, 2026
d7fea09
Merge branch 'feat/named-types-and-checks' into feat/typed-uuid-access
enilsen16 Oct 22, 2026
658fd8c
docs: drop the Normalize mention from FromUUID
enilsen16 Oct 22, 2026
e2ba276
Merge branch 'feat/typed-uuid-access' into feat/objectidtest
enilsen16 Oct 22, 2026
a86472e
Merge branch 'feat/objectidtest' into feat/union-as
enilsen16 Oct 22, 2026
cce98f7
Merge branch 'feat/union-as' into feat/prefix-aliases
enilsen16 Oct 22, 2026
bd3a274
Merge remote-tracking branch 'origin/main' into feat/union-as
enilsen16 Oct 22, 2026
8a208b8
Merge remote-tracking branch 'origin/feat/union-as' into feat/prefix-…
enilsen16 Oct 22, 2026
b442a29
Merge remote-tracking branch 'origin/main' into feat/prefix-aliases
enilsen16 Oct 22, 2026
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ The package caches each type's definition, so `Definition` must always return th
| `JSONFormat` | `FormatFull` | Form for `MarshalJSON` and `UnmarshalJSON` |
| `Validate` | none | Checks the bare value; use `ValidateUUID`, `ValidateInteger` or your own function. `ValidateUUID` accepts only the lowercase hyphenated form |
| `Generate` | none | Makes a bare value for `Generate`; use `GenerateUUIDv7`, `GenerateUUIDv4` or your own function |
| `Aliases` | none | Earlier object types that parsing still accepts. Encoders always emit `ObjectType`, so a prefix can be renamed without breaking stored or sent values |

### Check definitions in a test

Expand Down
97 changes: 97 additions & 0 deletions alias_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package trogonobjectid_test

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/TrogonStack/trogonobjectid"
)

type paymentMethodType struct{}

func (paymentMethodType) Definition() trogonobjectid.Definition {
return trogonobjectid.Definition{ObjectType: "pm", Aliases: []trogonobjectid.ObjectType{"card", "src"}}
}

type selfAliasType struct{}

func (selfAliasType) Definition() trogonobjectid.Definition {
return trogonobjectid.Definition{ObjectType: "pm", Aliases: []trogonobjectid.ObjectType{"pm"}}
}

type badAliasType struct{}

func (badAliasType) Definition() trogonobjectid.Definition {
return trogonobjectid.Definition{ObjectType: "pm", Aliases: []trogonobjectid.ObjectType{"Card"}}
}

type paymentUnion struct{}

func (paymentUnion) Types() []trogonobjectid.MemberType {
return []trogonobjectid.MemberType{
trogonobjectid.MemberOf[paymentMethodType](),
trogonobjectid.MemberOf[userType](),
}
}

type clashUnion struct{}

func (clashUnion) Types() []trogonobjectid.MemberType {
return []trogonobjectid.MemberType{
trogonobjectid.MemberOf[paymentMethodType](),
trogonobjectid.MemberOf[cardType](),
}
}

type cardType struct{}

func (cardType) Definition() trogonobjectid.Definition {
return trogonobjectid.Definition{ObjectType: "card"}
}

func TestAliases(t *testing.T) {
want := trogonobjectid.MustNew[paymentMethodType]("abc")

for _, input := range []string{"pm_abc", "card_abc", "src_abc"} {
t.Run("Parse accepts "+input, func(t *testing.T) {
got, err := trogonobjectid.Parse[paymentMethodType](input)
require.NoError(t, err)
assert.Equal(t, want, got)
assert.Equal(t, "pm_abc", got.String())
})
}

t.Run("full storage reads an aliased value", func(t *testing.T) {
var got trogonobjectid.ID[paymentMethodType]
require.NoError(t, got.Scan("card_abc"))
assert.Equal(t, want, got)
})

t.Run("an unknown prefix still fails", func(t *testing.T) {
_, err := trogonobjectid.Parse[paymentMethodType]("user_abc")
require.ErrorIs(t, err, trogonobjectid.ErrInvalidFormat)
})

t.Run("a union matches an alias", func(t *testing.T) {
u, err := trogonobjectid.ParseUnion[paymentUnion]("card_abc")
require.NoError(t, err)
got, ok := trogonobjectid.UnionAs[paymentMethodType](u)
require.True(t, ok)
assert.Equal(t, want, got)
assert.Equal(t, "pm_abc", u.String())
})

t.Run("Check rejects an alias equal to the object type", func(t *testing.T) {
require.ErrorIs(t, trogonobjectid.Check[selfAliasType](), trogonobjectid.ErrInvalidDefinition)
})

t.Run("Check rejects an invalid alias", func(t *testing.T) {
require.ErrorIs(t, trogonobjectid.Check[badAliasType](), trogonobjectid.ErrInvalidDefinition)
})

t.Run("CheckUnion rejects an alias that is another member's prefix", func(t *testing.T) {
require.ErrorIs(t, trogonobjectid.CheckUnion[clashUnion](), trogonobjectid.ErrInvalidDefinition)
})
}
47 changes: 32 additions & 15 deletions objectid.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ type Definition struct {
Validate func(id string) error
// Generate returns a new bare value for Generate. Nil makes Generate fail.
Generate func() (string, error)
// Aliases are earlier object types that parsing still accepts. Every encoder emits
// ObjectType, so stored and sent values move to the new prefix as they are rewritten.
Aliases []ObjectType
}

// Definer is implemented by the marker type that names an object ID type.
Expand Down Expand Up @@ -99,7 +102,8 @@ type ID[T Definer] struct {

type spec struct {
Definition
prefix string
// prefixes holds the ObjectType's prefix first, then each alias's.
prefixes []Prefix
// objectTypes is the one-element list that every ValidationError carries.
objectTypes []ObjectType
}
Expand All @@ -121,9 +125,21 @@ func newSpec[T Definer]() (spec, error) {
if def.Separator == "" {
def.Separator = DefaultSeparator
}
prefixes := []Prefix{Prefix(string(def.ObjectType) + string(def.Separator))}
seen := map[ObjectType]bool{def.ObjectType: true}
for _, alias := range def.Aliases {
if err := alias.validate(); err != nil {
return spec{}, fmt.Errorf("%w: %T alias: %w", ErrInvalidDefinition, marker, err)
}
if seen[alias] {
return spec{}, fmt.Errorf("%w: %T repeats object type %q in its aliases", ErrInvalidDefinition, marker, alias)
}
seen[alias] = true
prefixes = append(prefixes, Prefix(string(alias)+string(def.Separator)))
}
return spec{
Definition: def,
prefix: string(def.ObjectType) + string(def.Separator),
prefixes: prefixes,
objectTypes: []ObjectType{def.ObjectType},
}, nil
}
Expand All @@ -141,17 +157,18 @@ func (s *spec) check(id string) error {
}

func (s *spec) parse(full string) (string, error) {
bare, ok := strings.CutPrefix(full, s.prefix)
if !ok {
return "", &ValidationError{ObjectTypes: s.objectTypes, Value: full, Err: &WrongPrefixError{Want: []Prefix{Prefix(s.prefix)}}}
}
if bare == "" {
return "", &ValidationError{ObjectTypes: s.objectTypes, Value: full, Err: ErrInvalidFormat}
}
if err := s.check(bare); err != nil {
return "", err
for _, p := range s.prefixes {
if bare, ok := strings.CutPrefix(full, string(p)); ok {
if bare == "" {
return "", &ValidationError{ObjectTypes: s.objectTypes, Value: full, Err: ErrInvalidFormat}
}
if err := s.check(bare); err != nil {
return "", err
}
return bare, nil
}
}
return bare, nil
return "", &ValidationError{ObjectTypes: s.objectTypes, Value: full, Err: &WrongPrefixError{Want: s.prefixes}}
}

func (s *spec) decode(format Format, value string) (string, error) {
Expand All @@ -168,7 +185,7 @@ func (s *spec) encode(format Format, id string) string {
if format == FormatDropPrefix {
return id
}
return s.prefix + id
return string(s.prefixes[0]) + id
}

// New returns an ID for the bare value id, after validation.
Expand Down Expand Up @@ -283,7 +300,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]() Prefix { return Prefix(mustSpec[T]().prefix) }
func PrefixOf[T Definer]() Prefix { return mustSpec[T]().prefixes[0] }

// IsZero reports whether id is the zero (absent) ID.
func (id ID[T]) IsZero() bool { return id.id == "" }
Expand All @@ -302,7 +319,7 @@ func (id ID[T]) String() string {
if id.IsZero() {
return ""
}
return mustSpec[T]().prefix + id.id
return string(mustSpec[T]().prefixes[0]) + id.id
}

// Storage returns the value in the type's StorageFormat, or "" for the zero ID.
Expand Down
40 changes: 25 additions & 15 deletions union.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type Member interface {
// MemberType describes one ID type that a Union accepts. Make it with MemberOf.
type MemberType struct {
objectType ObjectType
prefix string
prefixes []Prefix
err error
parse func(s string) (Member, error)
holds func(m Member) bool
Expand All @@ -32,7 +32,7 @@ func MemberOf[T Definer]() MemberType {
s, err := specFor[T]()
return MemberType{
objectType: s.ObjectType,
prefix: s.prefix,
prefixes: s.prefixes,
err: err,
parse: func(str string) (Member, error) { return Parse[T](str) },
holds: func(m Member) bool { _, ok := m.(ID[T]); return ok },
Expand Down Expand Up @@ -67,6 +67,13 @@ type Union[U UnionDefiner] struct {
type unionSpec struct {
objectTypes []ObjectType
members []MemberType
// entries pairs every member prefix, aliases included, with its member, longest first.
entries []unionEntry
}

type unionEntry struct {
prefix Prefix
member MemberType
}

var unionSpecs sync.Map
Expand All @@ -83,22 +90,25 @@ func newUnionSpec[U UnionDefiner]() (unionSpec, error) {
}

objectTypes := make([]ObjectType, 0, len(members))
seen := make(map[string]bool, len(members))
seen := make(map[Prefix]bool, len(members))
var entries []unionEntry
for _, m := range members {
if m.err != nil {
return unionSpec{}, fmt.Errorf("%T: %w", marker, m.err)
}
if seen[m.prefix] {
return unionSpec{}, fmt.Errorf("%w: %T has duplicate prefix %q", ErrInvalidDefinition, marker, m.prefix)
for _, p := range m.prefixes {
if seen[p] {
return unionSpec{}, fmt.Errorf("%w: %T has duplicate prefix %q", ErrInvalidDefinition, marker, p)
}
seen[p] = true
entries = append(entries, unionEntry{prefix: p, member: m})
}
seen[m.prefix] = true
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{objectTypes: objectTypes, members: sorted}, nil
sort.SliceStable(entries, func(i, j int) bool { return len(entries[i].prefix) > len(entries[j].prefix) })
return unionSpec{objectTypes: objectTypes, members: members, entries: entries}, nil
}

// CheckUnion reports whether U's member list is usable: at least one member, valid
Expand Down Expand Up @@ -141,18 +151,18 @@ func ParseUnion[U UnionDefiner](str string) (Union[U], error) {
if err != nil {
return Union[U]{}, err
}
for _, mt := range s.members {
if strings.HasPrefix(str, mt.prefix) {
m, err := mt.parse(str)
for _, e := range s.entries {
if strings.HasPrefix(str, string(e.prefix)) {
m, err := e.member.parse(str)
if err != nil {
return Union[U]{}, err
}
return Union[U]{member: m}, nil
}
}
prefixes := make([]Prefix, len(s.members))
for i, mt := range s.members {
prefixes[i] = Prefix(mt.prefix)
prefixes := make([]Prefix, len(s.entries))
for i, e := range s.entries {
prefixes[i] = e.prefix
}
return Union[U]{}, &ValidationError{ObjectTypes: s.objectTypes, Value: str, Err: &WrongPrefixError{Want: prefixes}}
}
Expand Down
Loading