Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 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
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
7dc80af
Merge remote-tracking branch 'origin/main' into feat/proto-definition…
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
334f239
Merge remote-tracking branch 'origin/main' into feat/named-types-and-…
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
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
59 changes: 59 additions & 0 deletions check_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
31 changes: 25 additions & 6 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package trogonobjectid
import (
"errors"
"fmt"
"strings"
)

var (
Expand All @@ -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 }
2 changes: 1 addition & 1 deletion example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
89 changes: 75 additions & 14 deletions objectid.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,46 @@ package trogonobjectid
import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"log/slog"
"reflect"
"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

// 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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -80,31 +113,38 @@ 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
}

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
Expand Down Expand Up @@ -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 == "" }
Expand All @@ -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 {
Expand Down
12 changes: 6 additions & 6 deletions objectid_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
})

Expand Down Expand Up @@ -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`)
})
Expand Down Expand Up @@ -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]() })
}

Expand Down
4 changes: 2 additions & 2 deletions objectidproto/objectidproto.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
20 changes: 20 additions & 0 deletions objecttype_internal_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading