From a303e025b277abdb06d57f2674283665068c933f Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Mon, 29 Jun 2026 17:47:41 -0700 Subject: [PATCH] feat: add StringKSUID and BinaryKSUID types Replace the UUID wrapper with storage-specific KSUID types that share GraphQL string transit but use distinct SQL Scan/Value encodings. Co-authored-by: Cursor --- doc.go | 4 +- ksuid_binary.go | 76 +++++++++++++++++++++++++++ ksuid_gql.go | 37 +++++++++++++ ksuid_string.go | 81 +++++++++++++++++++++++++++++ ksuid_test.go | 134 ++++++++++++++++++++++++++++++++++++++++++++++++ uuid.go | 64 ----------------------- 6 files changed, 330 insertions(+), 66 deletions(-) create mode 100644 ksuid_binary.go create mode 100644 ksuid_gql.go create mode 100644 ksuid_string.go create mode 100644 ksuid_test.go delete mode 100644 uuid.go diff --git a/doc.go b/doc.go index 86a4186..b533d4c 100644 --- a/doc.go +++ b/doc.go @@ -21,8 +21,8 @@ // // # Identifier types // -// [UUID] wraps segmentio/ksuid for GraphQL APIs. [JSONB] provides map-based -// JSON column scanning for Postgres JSONB and MySQL JSON. +// [StringKSUID] and [BinaryKSUID] wrap segmentio/ksuid with storage-specific +// SQL encoding. [JSONB] provides map-based JSON column scanning. // // # Pointer and string utilities // diff --git a/ksuid_binary.go b/ksuid_binary.go new file mode 100644 index 0000000..620eda9 --- /dev/null +++ b/ksuid_binary.go @@ -0,0 +1,76 @@ +package dbx + +import ( + "database/sql/driver" + "fmt" + "io" + + "github.com/segmentio/ksuid" +) + +// BinaryKSUID wraps segmentio/ksuid for binary-column storage (BINARY(20), BYTEA). +// SQL Scan/Value use the raw 20-byte encoding. GraphQL transit uses the shared +// string JSON transport (see ksuid_gql.go). +type BinaryKSUID struct { + ksuid.KSUID +} + +// NewBinaryKSUID generates a new BinaryKSUID. +func NewBinaryKSUID() BinaryKSUID { + return BinaryKSUID{KSUID: ksuid.New()} +} + +// ParseBinaryKSUID parses a BinaryKSUID from its raw 20-byte form. +func ParseBinaryKSUID(b []byte) (BinaryKSUID, error) { + id, err := ksuid.FromBytes(b) + if err != nil { + return BinaryKSUID{}, err + } + return BinaryKSUID{KSUID: id}, nil +} + +// AsStringKSUID returns a StringKSUID view of the same identifier. +func (b BinaryKSUID) AsStringKSUID() StringKSUID { + return StringKSUID{KSUID: b.KSUID} +} + +// Scan implements sql.Scanner for binary-backed KSUID columns. +func (b *BinaryKSUID) Scan(src any) error { + switch v := src.(type) { + case nil: + *b = BinaryKSUID{} + return nil + case []byte: + if len(v) == ksuidStringLength { + return fmt.Errorf("BinaryKSUID.Scan: string-encoded KSUID (%d bytes); use StringKSUID", len(v)) + } + id, err := ksuid.FromBytes(v) + if err != nil { + return fmt.Errorf("BinaryKSUID.Scan: %w", err) + } + *b = BinaryKSUID{KSUID: id} + return nil + case string: + return fmt.Errorf("BinaryKSUID.Scan: string value %q; use StringKSUID or store raw bytes", v) + default: + return fmt.Errorf("BinaryKSUID.Scan: unable to scan type %T", v) + } +} + +// Value implements driver.Valuer for binary-backed KSUID columns. +func (b BinaryKSUID) Value() (driver.Value, error) { + if b.IsNil() { + return nil, nil + } + return b.Bytes(), nil +} + +// UnmarshalGQL implements the graphql.Unmarshaler interface. +func (b *BinaryKSUID) UnmarshalGQL(value any) error { + return unmarshalKSUIDFromGQL(value, b.UnmarshalText) +} + +// MarshalGQL implements the graphql.Marshaler interface. +func (b BinaryKSUID) MarshalGQL(w io.Writer) { + marshalKSUIDToGQL(w, b.KSUID) +} diff --git a/ksuid_gql.go b/ksuid_gql.go new file mode 100644 index 0000000..80d4cfc --- /dev/null +++ b/ksuid_gql.go @@ -0,0 +1,37 @@ +package dbx + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/segmentio/ksuid" +) + +type ksuidGQLTransport struct { + KSUIDStr string `json:"ksuid_str"` +} + +func unmarshalKSUIDFromGQL(value any, unmarshal func([]byte) error) error { + str, ok := value.(string) + if !ok { + return fmt.Errorf("GraphQL failed to unmarshal KSUID value: %v", value) + } + return unmarshal([]byte(str)) +} + +func marshalKSUIDToGQL(w io.Writer, id ksuid.KSUID) { + transport := ksuidGQLTransport{KSUIDStr: id.String()} + data, err := json.Marshal(transport) + if err != nil { + panic(fmt.Errorf("GraphQL failed to JSON-marshal KSUID value: %s", err)) + } + if _, err := w.Write(data); err != nil { + panic(fmt.Errorf("GraphQL failed to write KSUID value: %s", string(data))) + } +} + +const ( + ksuidBinaryLength = 20 + ksuidStringLength = 27 +) diff --git a/ksuid_string.go b/ksuid_string.go new file mode 100644 index 0000000..bc6b87d --- /dev/null +++ b/ksuid_string.go @@ -0,0 +1,81 @@ +package dbx + +import ( + "database/sql/driver" + "fmt" + "io" + + "github.com/segmentio/ksuid" +) + +// StringKSUID wraps segmentio/ksuid for text-column storage (VARCHAR, TEXT). +// SQL Scan/Value use the base62 string encoding. GraphQL transit uses the +// shared string JSON transport (see ksuid_gql.go). +type StringKSUID struct { + ksuid.KSUID +} + +// NewStringKSUID generates a new StringKSUID. +func NewStringKSUID() StringKSUID { + return StringKSUID{KSUID: ksuid.New()} +} + +// ParseStringKSUID parses a StringKSUID from its base62 string form. +func ParseStringKSUID(s string) (StringKSUID, error) { + id, err := ksuid.Parse(s) + if err != nil { + return StringKSUID{}, err + } + return StringKSUID{KSUID: id}, nil +} + +// AsBinaryKSUID returns a BinaryKSUID view of the same identifier. +func (s StringKSUID) AsBinaryKSUID() BinaryKSUID { + return BinaryKSUID{KSUID: s.KSUID} +} + +// Scan implements sql.Scanner for string-backed KSUID columns. +func (s *StringKSUID) Scan(src any) error { + switch v := src.(type) { + case nil: + *s = StringKSUID{} + return nil + case string: + id, err := ksuid.Parse(v) + if err != nil { + return fmt.Errorf("StringKSUID.Scan: %w", err) + } + *s = StringKSUID{KSUID: id} + return nil + case []byte: + if len(v) == ksuidBinaryLength { + return fmt.Errorf("StringKSUID.Scan: binary KSUID payload (%d bytes); use BinaryKSUID", len(v)) + } + id, err := ksuid.Parse(string(v)) + if err != nil { + return fmt.Errorf("StringKSUID.Scan: %w", err) + } + *s = StringKSUID{KSUID: id} + return nil + default: + return fmt.Errorf("StringKSUID.Scan: unable to scan type %T", v) + } +} + +// Value implements driver.Valuer for string-backed KSUID columns. +func (s StringKSUID) Value() (driver.Value, error) { + if s.IsNil() { + return nil, nil + } + return s.String(), nil +} + +// UnmarshalGQL implements the graphql.Unmarshaler interface. +func (s *StringKSUID) UnmarshalGQL(value any) error { + return unmarshalKSUIDFromGQL(value, s.UnmarshalText) +} + +// MarshalGQL implements the graphql.Marshaler interface. +func (s StringKSUID) MarshalGQL(w io.Writer) { + marshalKSUIDToGQL(w, s.KSUID) +} diff --git a/ksuid_test.go b/ksuid_test.go new file mode 100644 index 0000000..a7995cb --- /dev/null +++ b/ksuid_test.go @@ -0,0 +1,134 @@ +package dbx + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/segmentio/ksuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStringKSUID_ScanValue(t *testing.T) { + id := NewStringKSUID() + require.False(t, id.IsNil()) + + val, err := id.Value() + require.NoError(t, err) + assert.Equal(t, id.String(), val) + + var scanned StringKSUID + require.NoError(t, scanned.Scan(id.String())) + assert.Equal(t, id, scanned) + + require.NoError(t, scanned.Scan([]byte(id.String()))) + assert.Equal(t, id, scanned) + + var nilKSUID StringKSUID + val, err = nilKSUID.Value() + require.NoError(t, err) + assert.Nil(t, val) + + require.NoError(t, scanned.Scan(nil)) + assert.True(t, scanned.IsNil()) +} + +func TestStringKSUID_ScanRejectsBinary(t *testing.T) { + var s StringKSUID + err := s.Scan(idBytes(t)) + assert.ErrorContains(t, err, "BinaryKSUID") +} + +func TestStringKSUID_Conversion(t *testing.T) { + s := NewStringKSUID() + b := s.AsBinaryKSUID() + assert.Equal(t, s.String(), b.String()) + assert.Equal(t, s.Bytes(), b.Bytes()) + assert.Equal(t, s, b.AsStringKSUID()) +} + +func TestStringKSUID_GQL(t *testing.T) { + id := NewStringKSUID() + var buf bytes.Buffer + id.MarshalGQL(&buf) + + var transport ksuidGQLTransport + require.NoError(t, json.Unmarshal(buf.Bytes(), &transport)) + assert.Equal(t, id.String(), transport.KSUIDStr) + + var parsed StringKSUID + require.NoError(t, parsed.UnmarshalGQL(id.String())) + assert.Equal(t, id, parsed) +} + +func TestBinaryKSUID_ScanValue(t *testing.T) { + id := NewBinaryKSUID() + require.False(t, id.IsNil()) + + val, err := id.Value() + require.NoError(t, err) + assert.Equal(t, id.Bytes(), val) + + var scanned BinaryKSUID + require.NoError(t, scanned.Scan(id.Bytes())) + assert.Equal(t, id, scanned) + + var nilKSUID BinaryKSUID + val, err = nilKSUID.Value() + require.NoError(t, err) + assert.Nil(t, val) + + require.NoError(t, scanned.Scan(nil)) + assert.True(t, scanned.IsNil()) +} + +func TestBinaryKSUID_ScanRejectsStringEncoding(t *testing.T) { + s := NewStringKSUID() + var b BinaryKSUID + err := b.Scan([]byte(s.String())) + assert.ErrorContains(t, err, "StringKSUID") + + err = b.Scan(s.String()) + assert.ErrorContains(t, err, "StringKSUID") +} + +func TestBinaryKSUID_Conversion(t *testing.T) { + b := NewBinaryKSUID() + s := b.AsStringKSUID() + assert.Equal(t, b.String(), s.String()) + assert.Equal(t, b, s.AsBinaryKSUID()) +} + +func TestBinaryKSUID_GQL(t *testing.T) { + id := NewBinaryKSUID() + var buf bytes.Buffer + id.MarshalGQL(&buf) + + var transport ksuidGQLTransport + require.NoError(t, json.Unmarshal(buf.Bytes(), &transport)) + assert.Equal(t, id.String(), transport.KSUIDStr) + + var parsed BinaryKSUID + require.NoError(t, parsed.UnmarshalGQL(id.String())) + assert.Equal(t, id, parsed) +} + +func TestParseStringKSUID(t *testing.T) { + id := ksuid.New() + parsed, err := ParseStringKSUID(id.String()) + require.NoError(t, err) + assert.Equal(t, id.String(), parsed.String()) +} + +func TestParseBinaryKSUID(t *testing.T) { + id := ksuid.New() + parsed, err := ParseBinaryKSUID(id.Bytes()) + require.NoError(t, err) + assert.Equal(t, id.Bytes(), parsed.Bytes()) +} + +func idBytes(t *testing.T) []byte { + t.Helper() + return ksuid.New().Bytes() +} diff --git a/uuid.go b/uuid.go deleted file mode 100644 index 13386e7..0000000 --- a/uuid.go +++ /dev/null @@ -1,64 +0,0 @@ -package dbx - -import ( - "encoding/json" - "fmt" - "io" - "log/slog" - - "github.com/segmentio/ksuid" -) - -// UUID is a wrapper around ksuid.KSUID that implements the -// graphql.Unmarshaler and graphql.Marshaler interfaces for use in GraphQL APIs. -// It also provides additional convenience functions such as ParseUUID and NewUUID. -type UUID struct { - ksuid.KSUID -} - -type uuidTransport struct { - UUIDStr string `json:"uuid_str"` -} - -// Generates a new, wrapped KSUID. In the strange case that random bytes can't be read, it will panic. -func NewUUID() UUID { - return UUID{KSUID: ksuid.New()} -} - -// ParseUUID parses a UUID from a string. If the string is not a valid UUID, it will return an error. -func ParseUUID(s string) (UUID, error) { - ksuid, err := ksuid.Parse(s) - if err != nil { - return UUID{}, err - } - return UUID{KSUID: ksuid}, nil -} - -// UnmarshalGQL implements the graphql.Unmarshaler interface -func (u *UUID) UnmarshalGQL(value interface{}) error { - slog.Debug("uuid unmarshaling from gql", "val", value) - str, ok := value.(string) - if !ok { - return fmt.Errorf("GraphQL failed to unmarshal UUID value: %v", value) - } - - if err := u.UnmarshalText([]byte(str)); err != nil { - return err - } - - return nil -} - -// MarshalGQL implements the graphql.Marshaler interface -func (u UUID) MarshalGQL(w io.Writer) { - transport := uuidTransport{UUIDStr: u.String()} - json, err := json.Marshal(transport) - if err != nil { - panic(fmt.Errorf("GraphQL failed to JSON-marshal UUID value: %s", err)) - } - slog.Debug("uuid marshaling to gql", "uuid", u.String(), "json", string(json)) - _, err = w.Write(json) - if err != nil { - panic(fmt.Errorf("GraphQL failed to write UUID value: %s", string(json))) - } -}