diff --git a/README.md b/README.md index a3fe85a..a52a179 100644 --- a/README.md +++ b/README.md @@ -90,13 +90,28 @@ 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` | | `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 | +### 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 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 | Method | Emits | Controlled by | diff --git a/check_test.go b/check_test.go new file mode 100644 index 0000000..60e3b8f --- /dev/null +++ b/check_test.go @@ -0,0 +1,59 @@ +package trogonobjectid_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/TrogonStack/trogonobjectid" +) + +type upperCaseType struct{} + +func (upperCaseType) Definition() trogonobjectid.Definition { + return trogonobjectid.Definition{ObjectType: "User"} +} + +func TestCheck(t *testing.T) { + 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]() + 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[upperCaseType]("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) { + 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, []trogonobjectid.Prefix{"inv_"}, wrong.Want) + require.ErrorIs(t, err, trogonobjectid.ErrInvalidFormat) + }) + } + + 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, []trogonobjectid.Prefix{"user_", "system_", "inv_"}, wrong.Want) + }) +} diff --git a/errors.go b/errors.go index 7d935f9..c9fed0e 100644 --- a/errors.go +++ b/errors.go @@ -3,6 +3,7 @@ package trogonobjectid import ( "errors" "fmt" + "strings" ) var ( @@ -22,16 +23,34 @@ 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 } + +// 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 { + Want []Prefix +} + +func (e *WrongPrefixError) Error() string { + 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 fe6d2b4..60ca10b 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: want prefix ["user_"] } func ExampleID_EncodeSpanner() { diff --git a/objectid.go b/objectid.go index e81f664..ea7e39c 100644 --- a/objectid.go +++ b/objectid.go @@ -3,6 +3,7 @@ package trogonobjectid import ( "database/sql/driver" "encoding/json" + "errors" "fmt" "log/slog" "reflect" @@ -10,8 +11,38 @@ import ( "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 + +// 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 = "_" +const DefaultSeparator Separator = "_" + +// 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 @@ -26,9 +57,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. @@ -67,6 +98,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 @@ -80,22 +113,26 @@ 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 err := def.ObjectType.validate(); err != nil { + return spec{}, fmt.Errorf("%w: %T: %w", ErrInvalidDefinition, marker, err) } 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), + objectTypes: []ObjectType{def.ObjectType}, + }, nil } func (s *spec) check(id string) error { if id == "" { - return &ValidationError{ObjectType: s.ObjectType, Value: id, Err: ErrEmptyValue} + return &ValidationError{ObjectTypes: s.objectTypes, Value: id, Err: ErrEmptyValue} } if s.Validate != nil { if err := s.Validate(id); err != nil { - return &ValidationError{ObjectType: s.ObjectType, Value: id, Err: err} + return &ValidationError{ObjectTypes: s.objectTypes, Value: id, Err: err} } } return nil @@ -103,8 +140,11 @@ func (s *spec) check(id 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} + 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 @@ -216,11 +256,32 @@ 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 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.check(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. -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 == "" } @@ -229,10 +290,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 ObjectTypeOf[T]() } +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 0412196..86dfc81 100644 --- a/objectid_test.go +++ b/objectid_test.go @@ -68,8 +68,8 @@ 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, "user_", id.Prefix()) + assert.Equal(t, trogonobjectid.ObjectType("user"), id.ObjectType()) + 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`) }) @@ -398,9 +398,9 @@ func TestSpanner(t *testing.T) { } func TestTypeLevelAccessors(t *testing.T) { - assert.Equal(t, "inv", trogonobjectid.ObjectTypeOf[invoiceType]()) - assert.Equal(t, "inv_", trogonobjectid.PrefixOf[invoiceType]()) - assert.Equal(t, "order#", trogonobjectid.PrefixOf[orderType]()) + assert.Equal(t, trogonobjectid.ObjectType("inv"), trogonobjectid.ObjectTypeOf[invoiceType]()) + 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/objectidproto/objectidproto.go b/objectidproto/objectidproto.go index ee78369..4d4bfc9 100644 --- a/objectidproto/objectidproto.go +++ b/objectidproto/objectidproto.go @@ -56,9 +56,9 @@ func Definition(value protoreflect.Enum, base trogonobjectid.Definition) (trogon separator := trogonobjectid.DefaultSeparator if ext.Separator != nil { - separator = ext.GetSeparator() + separator = trogonobjectid.Separator(ext.GetSeparator()) } - base.ObjectType = ext.GetObjectType() + base.ObjectType = trogonobjectid.ObjectType(ext.GetObjectType()) base.Separator = separator return base, nil } 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 c2f9c25..0c071db 100644 --- a/union.go +++ b/union.go @@ -14,13 +14,13 @@ 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 } // 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,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,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) + 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 +// 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. @@ -108,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. @@ -143,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([]Prefix, len(s.members)) + for i, mt := range s.members { + prefixes[i] = Prefix(mt.prefix) + } + 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.