diff --git a/encoding.go b/encoding.go index 4d806ce..9808866 100644 --- a/encoding.go +++ b/encoding.go @@ -1,7 +1,6 @@ package typeid import ( - "errors" "fmt" "github.com/gofrs/uuid/v5" @@ -48,10 +47,6 @@ func scan[T idImplementation[P], P Prefix](dst *T, src any) error { return nil } -var ( - errNilScan = errors.New("cannot scan NULL into *typeid.TypeID") -) - func textValue[T idImplementation[P], P Prefix](id T) (pgtype.Text, error) { return pgtype.Text{ String: id.String(), @@ -63,7 +58,7 @@ func scanText[T idImplementation[P], P Prefix](dst *T, v pgtype.Text) error { var err error if !v.Valid { - return errNilScan + return fmt.Errorf("cannot scan NULL into %T", dst) } *dst, err = FromString[T](v.String) @@ -85,7 +80,7 @@ func scanUUID[T idImplementation[P], P Prefix](dst *T, v pgtype.UUID) error { var err error if !v.Valid { - return errNilScan + return fmt.Errorf("cannot scan NULL into %T", dst) } *dst, err = FromUUIDBytes[T](v.Bytes[:]) diff --git a/encoding_test.go b/encoding_test.go index 39cbb5a..0bff8b0 100644 --- a/encoding_test.go +++ b/encoding_test.go @@ -78,8 +78,9 @@ func TestTypeID_Pgx_Scan(t *testing.T) { if err == nil { t.Error("must error on a nil scan") } - if !strings.Contains(err.Error(), "cannot scan NULL into *typeid.TypeID") { - t.Error("error must be cannot scan NULL into *typeid.TypeID") + expect := "cannot scan NULL into *typeid.Random[github.com/sumup/typeid.userPrefix]" + if !strings.Contains(err.Error(), expect) { + t.Errorf("error must be %q, was %q", expect, err.Error()) } }) } @@ -205,7 +206,7 @@ func TestTypeID_SQL_Value(t *testing.T) { } func TestJSON(t *testing.T) { - str := "account_00041061050r3gg28a1c60t3gf" + str := "system_account_00041061050r3gg28a1c60t3gf" tid := Must(FromString[AccountID](str)) encoded, err := json.Marshal(tid) diff --git a/generate.go b/generate.go index 6cf9811..240ca8f 100644 --- a/generate.go +++ b/generate.go @@ -63,15 +63,16 @@ func validatePrefix(prefix string) error { } if len(prefix) > MaxPrefixLen { - return fmt.Errorf("invalid prefix: %s. Prefix length is %d, expected <= %d", prefix, len(prefix), MaxPrefixLen) + return fmt.Errorf("invalid prefix: %s, prefix length is %d, expected <= %d", prefix, len(prefix), MaxPrefixLen) } - // Ensure that the prefix has only lowercase ASCII characters + // Ensure that the prefix has only lowercase ASCII characters and underscores for _, c := range prefix { - if c < 'a' || c > 'z' { - return fmt.Errorf("invalid prefix: '%s'. Prefix should match [a-z]{0,%d}", prefix, MaxPrefixLen) + if c != '_' && (c < 'a' || c > 'z') { + return fmt.Errorf("invalid prefix: '%s', prefix should match [a-z_]{0,%d}", prefix, MaxPrefixLen) } } + return nil } diff --git a/typeid.go b/typeid.go index 61f8672..cac9dff 100644 --- a/typeid.go +++ b/typeid.go @@ -8,6 +8,10 @@ import ( "github.com/gofrs/uuid/v5" ) +var ( + ErrParse = errors.New("parse typeid") +) + const ( // MaxPrefixLen is the maximum string length of a [Prefix]. Any generation or parsing of an ID type with a longer prefix will fail. MaxPrefixLen = 63 @@ -81,38 +85,16 @@ func MustNew[T instance[P], P Prefix]() T { } func FromString[T instance[P], P Prefix](s string) (T, error) { - prefix, suffix, ok := strings.Cut(s, "_") - if !ok { - // If there is no prefix, the first string part is the suffix. - return fromUnprefixString[T](prefix) + prefix := getPrefix[P]() + if prefix != "" && !strings.HasPrefix(s, prefix+"_") { + return Nil[T](), fmt.Errorf("%w: invalid prefix for %T, expected %q", ErrParse, T{}, prefix) } - return fromPrefixedString[T](prefix, suffix) -} - -func fromPrefixedString[T instance[P], P Prefix](prefix, suffix string) (T, error) { - if prefix == "" { - return Nil[T](), errors.New("typeid prefix cannot be empty when there's a separator") - } else if prefix != getPrefix[P]() { - return Nil[T](), fmt.Errorf("invalid prefix `%s` for typeid %T. Expected %s", prefix, T{}, getPrefix[P]()) - } - - tid, err := from[P](suffix, T{}.processor()) - if err != nil { - return Nil[T](), fmt.Errorf("parse typeid suffix `%s`: %w", suffix, err) - } - return T{tid}, nil -} - -func fromUnprefixString[T instance[P], P Prefix](suffix string) (T, error) { - // Unprefixed ID strings are only valid, if the type ids prefix is the empty string - if getPrefix[P]() != "" { - return Nil[T](), fmt.Errorf("no prefix in id string %s for type %T. Expected %s", suffix, T{}, getPrefix[P]()) - } + suffix := strings.TrimPrefix(s, prefix+"_") tid, err := from[P](suffix, T{}.processor()) if err != nil { - return Nil[T](), fmt.Errorf("parse typeid suffix `%s`: %w", suffix, err) + return Nil[T](), fmt.Errorf("%w: invalid suffix %q: %s", ErrParse, suffix, err.Error()) } return T{tid}, nil } @@ -128,7 +110,7 @@ func FromUUID[T instance[P], P Prefix](u uuid.UUID) (T, error) { func FromUUIDStr[T instance[P], P Prefix](uuidStr string) (T, error) { u, err := uuid.FromString(uuidStr) if err != nil { - return Nil[T](), fmt.Errorf("typeid from uuid string: %w", err) + return Nil[T](), fmt.Errorf("%w: uuid from string: %s", ErrParse, err.Error()) } return FromUUID[T](u) } @@ -136,7 +118,7 @@ func FromUUIDStr[T instance[P], P Prefix](uuidStr string) (T, error) { func FromUUIDBytes[T instance[P], P Prefix](bytes []byte) (T, error) { u, err := uuid.FromBytes(bytes) if err != nil { - return Nil[T](), fmt.Errorf("typeid from uuid: %w", err) + return Nil[T](), fmt.Errorf("%w: uuid from bytes: %s", ErrParse, err.Error()) } return FromUUID[T](u) } diff --git a/typeid_test.go b/typeid_test.go index 7af39ae..0ccb070 100644 --- a/typeid_test.go +++ b/typeid_test.go @@ -11,7 +11,7 @@ import ( const ( userIDPrefix = "user" - accountIDPrefix = "account" + accountIDPrefix = "system_account" emptyID = "00000000000000000000000000" // empty is suffix is 26 zeros ) @@ -70,7 +70,7 @@ func TestTypeID_Nil(t *testing.T) { } nilAccountID := Nil[AccountID]() - if "account_"+emptyID != nilAccountID.String() { + if "system_account_"+emptyID != nilAccountID.String() { t.Errorf("expected nil account id, got: %s", nilAccountID.String()) } @@ -88,6 +88,9 @@ func TestTypeID_ToFrom(t *testing.T) { type SortableID = AccountID t.Run("typeid.Sortable", runToFromQuickTests[SortableID]) + + type EmptyPrefixID = NilID + t.Run("empty prefix", runToFromQuickTests[EmptyPrefixID]) } func runToFromQuickTests[T idImplementation[P], P Prefix](t *testing.T) {