Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func (invoiceType) Definition() trogonobjectid.Definition {
ObjectType: "inv",
StorageFormat: trogonobjectid.FormatDropPrefix,
JSONFormat: trogonobjectid.FormatDropPrefix,
Normalize: trogonobjectid.NormalizeUUID,
Validate: trogonobjectid.ValidateUUID,
Generate: trogonobjectid.GenerateUUIDv7,
}
}
Expand Down Expand Up @@ -95,8 +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`. `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 |
| `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 |

## Emitted forms
Expand All @@ -113,6 +112,6 @@ Keep the result in a package-level variable, because `Definition` methods run on

`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, 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.
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.
7 changes: 0 additions & 7 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,6 @@ func ExampleParse() {
// 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)
Expand Down
38 changes: 16 additions & 22 deletions objectid.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,6 @@ 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.
Expand Down Expand Up @@ -81,36 +78,35 @@ func specFor[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) check(id string) error {
if id == "" {
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
return &ValidationError{ObjectType: s.ObjectType, 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{ObjectType: s.ObjectType, Value: id, Err: err}
}
}
return id, nil
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}
}
return s.build(bare)
if err := s.check(bare); err != nil {
return "", err
}
return bare, nil
}

func (s spec) decode(format Format, value string) (string, error) {
if format == FormatDropPrefix {
return s.build(value)
if err := s.check(value); err != nil {
return "", err
}
return value, nil
}
return s.parse(value)
}
Expand All @@ -128,11 +124,10 @@ func New[T Definer](id string) (ID[T], error) {
if err != nil {
return ID[T]{}, err
}
bare, err := s.build(id)
if err != nil {
if err := s.check(id); err != nil {
return ID[T]{}, err
}
return ID[T]{id: bare}, nil
return ID[T]{id: id}, nil
}

// MustNew is like New but panics on error. Use it for constants and tests.
Expand Down Expand Up @@ -179,11 +174,10 @@ func Generate[T Definer]() (ID[T], error) {
if err != nil {
return ID[T]{}, fmt.Errorf("trogonobjectid: generate %s: %w", s.ObjectType, err)
}
bare, err := s.build(id)
if err != nil {
if err := s.check(id); err != nil {
return ID[T]{}, err
}
return ID[T]{id: bare}, nil
return ID[T]{id: id}, nil
}

// FromStorage returns the ID for a value in the type's StorageFormat.
Expand Down
69 changes: 14 additions & 55 deletions objectid_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -353,61 +353,20 @@ 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 {
return trogonobjectid.Definition{
ObjectType: "inv",
StorageFormat: trogonobjectid.FormatDropPrefix,
JSONFormat: trogonobjectid.FormatDropPrefix,
Normalize: trogonobjectid.NormalizeUUID,
Validate: func(id string) error {
if id != invoiceUUID {
return errors.New("not canonical")
}
return nil
},
}
}

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)
},
"URN": func() (trogonobjectid.ID[canonicalType], error) {
return trogonobjectid.New[canonicalType]("urn:uuid:" + invoiceUUID)
},
"Parse": func() (trogonobjectid.ID[canonicalType], error) {
return trogonobjectid.Parse[canonicalType]("inv_" + 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)
func TestValidateUUID(t *testing.T) {
require.NoError(t, trogonobjectid.ValidateUUID(invoiceUUID))

// Only one form per UUID is valid, so two spellings can never be two IDs.
for name, input := range map[string]string{
"upper case": "0199E3A1-7C2B-7D4E-9F10-2A3B4C5D6E7F",
"URN": "urn:uuid:" + invoiceUUID,
"braces": "{" + invoiceUUID + "}",
"no hyphens": "0199e3a17c2b7d4e9f102a3b4c5d6e7f",
} {
t.Run("rejects "+name, func(t *testing.T) {
require.ErrorIs(t, trogonobjectid.ValidateUUID(input), trogonobjectid.ErrInvalidUUID)
})
}

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)
})
}

func TestSpanner(t *testing.T) {
Expand Down Expand Up @@ -438,9 +397,9 @@ func TestSpanner(t *testing.T) {
})
}

func BenchmarkNormalizeUUID(b *testing.B) {
func BenchmarkValidateUUID(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
_, _ = trogonobjectid.NormalizeUUID(invoiceUUID)
_ = trogonobjectid.ValidateUUID(invoiceUUID)
}
}
53 changes: 14 additions & 39 deletions validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,22 @@ package trogonobjectid

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.
// ValidateUUID accepts only the canonical form: 36 characters, lowercase, hyphenated.
// It rejects upper case, URN and brace forms, so one UUID cannot be spelled as two IDs.
func ValidateUUID(id string) error {
_, err := parseUUID(id)
return err
}

func parseUUID(id string) (uuid.UUID, error) {
u, err := uuid.Parse(id)
if err != nil {
return uuid.Nil, ErrInvalidUUID
if len(id) != 36 {
return ErrInvalidUUID
}
return u, nil
// A byte scan instead of comparing with uuid.UUID.String keeps decoding allocation-free.
for i := 0; i < len(id); i++ {
if id[i] >= 'A' && id[i] <= 'F' {
return ErrInvalidUUID
}
}
if uuid.Validate(id) != nil {
return ErrInvalidUUID
}
return nil
}

// ValidateInteger accepts an optional sign followed by decimal digits, with no size limit,
Expand Down Expand Up @@ -52,31 +55,3 @@ 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. 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 := parseUUID(id)
if err != nil {
return "", err
}
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
}
Loading