diff --git a/README.md b/README.md index 1024864..1c69349 100644 --- a/README.md +++ b/README.md @@ -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, } } @@ -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 @@ -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. diff --git a/example_test.go b/example_test.go index 2725d70..fe6d2b4 100644 --- a/example_test.go +++ b/example_test.go @@ -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) diff --git a/objectid.go b/objectid.go index 5b0f944..0a3663b 100644 --- a/objectid.go +++ b/objectid.go @@ -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. @@ -81,23 +78,16 @@ 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) { @@ -105,12 +95,18 @@ func (s spec) parse(full string) (string, error) { 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) } @@ -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. @@ -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. diff --git a/objectid_test.go b/objectid_test.go index da962c3..d62c357 100644 --- a/objectid_test.go +++ b/objectid_test.go @@ -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) { @@ -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) } } diff --git a/validate.go b/validate.go index 1fe3b0c..e549333 100644 --- a/validate.go +++ b/validate.go @@ -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, @@ -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 -}