diff --git a/README.md b/README.md index 209fd1d..5782f08 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/alias_test.go b/alias_test.go new file mode 100644 index 0000000..d049f7d --- /dev/null +++ b/alias_test.go @@ -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) + }) +} diff --git a/objectid.go b/objectid.go index cd92e06..43608cf 100644 --- a/objectid.go +++ b/objectid.go @@ -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. @@ -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 } @@ -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 } @@ -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) { @@ -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. @@ -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 == "" } @@ -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. diff --git a/union.go b/union.go index e85d516..5126c0f 100644 --- a/union.go +++ b/union.go @@ -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 @@ -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 }, @@ -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 @@ -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 @@ -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}} }