From 834f085aae2ebd4393895d29a7705866c65f4375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Sat, 3 May 2025 17:21:32 +0200 Subject: [PATCH 1/3] feat: support multi-word indexes Allow prefixes to contain multiple words separated by underscores (`_`). --- encoding_test.go | 2 +- generate.go | 14 +++++++++----- typeid.go | 32 ++++---------------------------- typeid_test.go | 4 ++-- 4 files changed, 16 insertions(+), 36 deletions(-) diff --git a/encoding_test.go b/encoding_test.go index 39cbb5a..1556a33 100644 --- a/encoding_test.go +++ b/encoding_test.go @@ -205,7 +205,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..78d8783 100644 --- a/generate.go +++ b/generate.go @@ -2,6 +2,7 @@ package typeid import ( "fmt" + "regexp" "unsafe" "github.com/gofrs/uuid/v5" @@ -57,21 +58,24 @@ func nilID[P Prefix]() typedID[P] { } } +var ( + prefixRegexp = regexp.MustCompile("^[a-z](?:[a-z_]*[a-z])?$") +) + func validatePrefix(prefix string) error { if prefix == "" { return nil } 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 - 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 !prefixRegexp.MatchString(prefix) { + return fmt.Errorf("invalid prefix: '%s', prefix must match %q", prefix, prefixRegexp.String()) } + return nil } diff --git a/typeid.go b/typeid.go index 61f8672..2a486f6 100644 --- a/typeid.go +++ b/typeid.go @@ -1,7 +1,6 @@ package typeid import ( - "errors" "fmt" "strings" @@ -81,35 +80,12 @@ 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) - } - - 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]()) + prefix := getPrefix[P]() + "_" + if !strings.HasPrefix(s, prefix) { + return Nil[T](), fmt.Errorf("invalid prefix for typeid %T, expected %s", 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) diff --git a/typeid_test.go b/typeid_test.go index 7af39ae..4b6b2f2 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()) } From 51a3ffbb4c015c1491739373ad62bdee235a75f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Mon, 5 May 2025 09:59:07 +0200 Subject: [PATCH 2/3] chore: adjust based on code review --- generate.go | 13 +++++-------- typeid.go | 11 ++++++----- typeid_test.go | 3 +++ 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/generate.go b/generate.go index 78d8783..240ca8f 100644 --- a/generate.go +++ b/generate.go @@ -2,7 +2,6 @@ package typeid import ( "fmt" - "regexp" "unsafe" "github.com/gofrs/uuid/v5" @@ -58,10 +57,6 @@ func nilID[P Prefix]() typedID[P] { } } -var ( - prefixRegexp = regexp.MustCompile("^[a-z](?:[a-z_]*[a-z])?$") -) - func validatePrefix(prefix string) error { if prefix == "" { return nil @@ -71,9 +66,11 @@ func validatePrefix(prefix string) error { 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 - if !prefixRegexp.MatchString(prefix) { - return fmt.Errorf("invalid prefix: '%s', prefix must match %q", prefix, prefixRegexp.String()) + // Ensure that the prefix has only lowercase ASCII characters and underscores + for _, c := range prefix { + 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 2a486f6..8dd8680 100644 --- a/typeid.go +++ b/typeid.go @@ -80,15 +80,16 @@ func MustNew[T instance[P], P Prefix]() T { } func FromString[T instance[P], P Prefix](s string) (T, error) { - prefix := getPrefix[P]() + "_" - if !strings.HasPrefix(s, prefix) { - return Nil[T](), fmt.Errorf("invalid prefix for typeid %T, expected %s", T{}, getPrefix[P]()) + prefix := getPrefix[P]() + if prefix != "" && !strings.HasPrefix(s, prefix+"_") { + return Nil[T](), fmt.Errorf("invalid prefix for typeid %T, expected %q", T{}, prefix) } - suffix := strings.TrimPrefix(s, prefix) + 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("parse typeid suffix %q: %w", suffix, err) } return T{tid}, nil } diff --git a/typeid_test.go b/typeid_test.go index 4b6b2f2..0ccb070 100644 --- a/typeid_test.go +++ b/typeid_test.go @@ -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) { From 38bafc08fc92f1d7054d059c45a5627e93cfdf15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Mon, 5 May 2025 10:10:01 +0200 Subject: [PATCH 3/3] chore: improve errors --- encoding.go | 9 ++------- encoding_test.go | 5 +++-- typeid.go | 13 +++++++++---- 3 files changed, 14 insertions(+), 13 deletions(-) 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 1556a33..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()) } }) } diff --git a/typeid.go b/typeid.go index 8dd8680..cac9dff 100644 --- a/typeid.go +++ b/typeid.go @@ -1,12 +1,17 @@ package typeid import ( + "errors" "fmt" "strings" "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 @@ -82,14 +87,14 @@ func MustNew[T instance[P], P Prefix]() T { func FromString[T instance[P], P Prefix](s string) (T, error) { prefix := getPrefix[P]() if prefix != "" && !strings.HasPrefix(s, prefix+"_") { - return Nil[T](), fmt.Errorf("invalid prefix for typeid %T, expected %q", T{}, prefix) + return Nil[T](), fmt.Errorf("%w: invalid prefix for %T, expected %q", ErrParse, T{}, prefix) } suffix := strings.TrimPrefix(s, prefix+"_") tid, err := from[P](suffix, T{}.processor()) if err != nil { - return Nil[T](), fmt.Errorf("parse typeid suffix %q: %w", suffix, err) + return Nil[T](), fmt.Errorf("%w: invalid suffix %q: %s", ErrParse, suffix, err.Error()) } return T{tid}, nil } @@ -105,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) } @@ -113,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) }