Skip to content
Open
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
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,29 @@ fmt.Println(userID) // --> user_01hf98sp99fs2b4qf2jm11hse4

ID types in this package can be used with [database/sql](https://pkg.go.dev/database/sql) and [github.com/jackc/pgx](https://pkg.go.dev/github.com/jackc/pgx/v5).

When using the standard library SQL, IDs will be stored as their string representation and can be scanned and valued accordingly. When using pgx, both TEXT and UUID columns can be used directly. However, note that the type information is lost when using UUID columns, unless you take additional steps at the database layer. Be mindful of your identifier semantics, especially in complex JOIN queries.
When using the standard library SQL, IDs will be stored as their string representation and can be scanned and valued accordingly. When using pgx, TEXT and UUID columns can be used directly. With UUID columns the type prefix is not stored in the database unless you take additional steps at the database layer.

To keep both the type prefix and UUID in PostgreSQL, define a composite type and register it on your pgx connections (for example in `AfterConnect`). This package only implements the composite field accessors; type loading and OID registration stay in the integrating application:

```sql
CREATE TYPE typeid AS (
"type" varchar(63),
"uuid" UUID
);
```

```go
config.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
t, err := conn.LoadType(ctx, "typeid")
if err != nil {
return err
}
conn.TypeMap().RegisterType(t)
return nil
}
```

`typeid.Sortable` and `typeid.Random` implement pgx's `CompositeIndexGetter` and `CompositeIndexScanner` interfaces, so pgx's CompositeCodec can encode and scan composite typeid columns.

If using `pgx` with PostgreSQL, you can generate UUIDv4 (for usage with `typeid.Random`) as the default value for your primary key:

Expand Down
25 changes: 23 additions & 2 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,29 @@
// ID types in this package can be used with [database/sql] and [github.com/jackc/pgx].
//
// When using the standard library sql, IDs will be stored as their string representation and can be scanned and valued accordingly.
// When using pgx, both TEXT and UUID columns can be used directly. However, note that the type information is lost when using UUID columns, unless you take additional steps
// at the database layer. Be mindful of your identifier semantics, especially in complex JOIN queries.
// When using pgx, TEXT and UUID columns can be used directly. With UUID columns the type prefix is not stored in the database
// unless you take additional steps at the database layer.
//
// To keep both the type prefix and UUID in PostgreSQL, define a composite type and register it on your pgx
// connections (for example in AfterConnect). This package only implements the composite field accessors;
// type loading and OID registration stay in the integrating application:
//
// CREATE TYPE typeid AS (
// "type" varchar(63),
// "uuid" UUID
// );
//
// config.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
// t, err := conn.LoadType(ctx, "typeid")
// if err != nil {
// return err
// }
// conn.TypeMap().RegisterType(t)
// return nil
// }
//
// [Sortable] and [Random] implement pgx's CompositeIndexGetter and CompositeIndexScanner interfaces, so pgx's
// CompositeCodec can encode and scan composite typeid columns.
//
// # Usage
//
Expand Down
61 changes: 61 additions & 0 deletions encoding.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,64 @@ func scanUUID[T idImplementation[P], P Prefix](dst *T, v pgtype.UUID) error {

return nil
}

type prefixScanner[P Prefix] struct{}

func (prefixScanner[P]) ScanText(v pgtype.Text) error {
if !v.Valid {
return fmt.Errorf("cannot scan NULL prefix")
}

var p P
if v.String != p.Prefix() {
return fmt.Errorf(
"scan composite typeid: prefix mismatch: got %q, expected %q",
v.String,
p.Prefix(),
)
}

return nil
}

// compositeUUIDScanner sets the UUID field of a PostgreSQL typeid composite.
type compositeUUIDScanner[T idImplementation[P], P Prefix] struct {
dst *T
}

func (s compositeUUIDScanner[T, P]) ScanUUID(v pgtype.UUID) error {
return scanUUID(s.dst, v)
}

func compositeIsNull() bool {
return false
}

func compositeIndex[T idImplementation[P], P Prefix](id T, i int) any {
switch i {
case 0:
return getPrefix[P]()
case 1:
return pgtype.UUID{
Bytes: id.UUID(),
Valid: true,
}
default:
panic(fmt.Errorf("illegal composite index %d", i))
}
}

func compositeScanNull[T idImplementation[P], P Prefix](dst *T) error {
return fmt.Errorf("cannot scan NULL into %T", dst)
}

func compositeScanIndex[T idImplementation[P], P Prefix](dst *T, i int) any {
switch i {
case 0:
return new(prefixScanner[P])
case 1:
return compositeUUIDScanner[T, P]{dst: dst}
default:
panic(fmt.Errorf("illegal composite scan index %d", i))
}
}
123 changes: 123 additions & 0 deletions encoding_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,126 @@ func TestJSON(t *testing.T) {
t.Errorf("json decoding should return the original uuid string: expected %s, got %s", str, decoded.String())
}
}

const typeIDCompositeOID = uint32(999001)

func newTypeIDCompositeMap(t *testing.T) (*pgtype.Map, *pgtype.CompositeCodec) {
t.Helper()

m := pgtype.NewMap()
varcharType, ok := m.TypeForOID(pgtype.VarcharOID)
if !ok {
t.Fatal("varchar type not found in pgtype map")
}
uuidType, ok := m.TypeForOID(pgtype.UUIDOID)
if !ok {
t.Fatal("uuid type not found in pgtype map")
}

codec := &pgtype.CompositeCodec{
Fields: []pgtype.CompositeCodecField{
{Name: "type", Type: varcharType},
{Name: "uuid", Type: uuidType},
},
}
m.RegisterType(&pgtype.Type{Name: "typeid", OID: typeIDCompositeOID, Codec: codec})
return m, codec
}

func TestTypeID_Pgx_Composite(t *testing.T) {
t.Parallel()

original := MustNew[UserID]()

formats := []struct {
name string
code int16
}{
{name: "binary", code: pgtype.BinaryFormatCode},
{name: "text", code: pgtype.TextFormatCode},
}

for _, format := range formats {
tc := format
t.Run("round trip "+tc.name, func(t *testing.T) {
t.Parallel()

m, codec := newTypeIDCompositeMap(t)
encodePlan := codec.PlanEncode(m, typeIDCompositeOID, tc.code, original)
if encodePlan == nil {
t.Fatalf("PlanEncode returned nil for %s format", tc.name)
}
buf, err := encodePlan.Encode(original, nil)
if err != nil {
t.Fatalf("encode: unexpected error:\n%+v", err)
}

var target UserID
scanPlan := codec.PlanScan(m, typeIDCompositeOID, tc.code, &target)
if scanPlan == nil {
t.Fatalf("PlanScan returned nil for %s format", tc.name)
}
if err := scanPlan.Scan(buf, &target); err != nil {
t.Fatalf("scan: unexpected error:\n%+v", err)
}
if original != target {
t.Errorf("round trip: expected %v, got %v", original, target)
}
})
}

t.Run("prefix mismatch", func(t *testing.T) {
t.Parallel()

m, codec := newTypeIDCompositeMap(t)
encodePlan := codec.PlanEncode(m, typeIDCompositeOID, pgtype.BinaryFormatCode, original)
buf, err := encodePlan.Encode(original, nil)
if err != nil {
t.Fatalf("encode: unexpected error:\n%+v", err)
}

var target AccountID
scanPlan := codec.PlanScan(m, typeIDCompositeOID, pgtype.BinaryFormatCode, &target)
err = scanPlan.Scan(buf, &target)
if err == nil {
t.Fatal("expected prefix mismatch error")
}
})

t.Run("null composite", func(t *testing.T) {
t.Parallel()

m, codec := newTypeIDCompositeMap(t)
var target UserID
scanPlan := codec.PlanScan(m, typeIDCompositeOID, pgtype.BinaryFormatCode, &target)
err := scanPlan.Scan(nil, &target)
if err == nil {
t.Fatal("must error on a nil scan")
}
expect := "cannot scan NULL into *typeid.Random[github.com/sumup/typeid.userPrefix]"
if !strings.Contains(err.Error(), expect) {
t.Errorf("error must contain %q, was %q", expect, err.Error())
}
})

t.Run("sortable round trip", func(t *testing.T) {
t.Parallel()

m, codec := newTypeIDCompositeMap(t)
sortable := MustNew[AccountID]()
encodePlan := codec.PlanEncode(m, typeIDCompositeOID, pgtype.BinaryFormatCode, sortable)
buf, err := encodePlan.Encode(sortable, nil)
if err != nil {
t.Fatalf("encode: unexpected error:\n%+v", err)
}

var target AccountID
scanPlan := codec.PlanScan(m, typeIDCompositeOID, pgtype.BinaryFormatCode, &target)
if err := scanPlan.Scan(buf, &target); err != nil {
t.Fatalf("scan: unexpected error:\n%+v", err)
}
if sortable != target {
t.Errorf("round trip: expected %v, got %v", sortable, target)
}
})
}
22 changes: 22 additions & 0 deletions random.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,25 @@ func (r Random[P]) UUIDValue() (pgtype.UUID, error) {
func (r *Random[P]) ScanUUID(v pgtype.UUID) error {
return scanUUID(r, v)
}

// IsNull implements [pgtype.CompositeIndexGetter] for PostgreSQL composite typeid columns.
func (Random[P]) IsNull() bool {
return compositeIsNull()
}

// Index implements [pgtype.CompositeIndexGetter] for PostgreSQL composite typeid columns.
// Index 0 is the type prefix; index 1 is the UUID.
func (r Random[P]) Index(i int) any {
return compositeIndex(r, i)
}

// ScanNull implements [pgtype.CompositeIndexScanner] for PostgreSQL composite typeid columns.
func (r *Random[P]) ScanNull() error {
return compositeScanNull(r)
}

// ScanIndex implements [pgtype.CompositeIndexScanner] for PostgreSQL composite typeid columns.
// Index 0 is the type prefix; index 1 is the UUID.
func (r *Random[P]) ScanIndex(i int) any {
return compositeScanIndex(r, i)
}
22 changes: 22 additions & 0 deletions sortable.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,25 @@ func (s Sortable[P]) UUIDValue() (pgtype.UUID, error) {
func (s *Sortable[P]) ScanUUID(v pgtype.UUID) error {
return scanUUID(s, v)
}

// IsNull implements [pgtype.CompositeIndexGetter] for PostgreSQL composite typeid columns.
func (Sortable[P]) IsNull() bool {
return compositeIsNull()
}

// Index implements [pgtype.CompositeIndexGetter] for PostgreSQL composite typeid columns.
// Index 0 is the type prefix; index 1 is the UUID.
func (s Sortable[P]) Index(i int) any {
return compositeIndex(s, i)
}

// ScanNull implements [pgtype.CompositeIndexScanner] for PostgreSQL composite typeid columns.
func (s *Sortable[P]) ScanNull() error {
return compositeScanNull(s)
}

// ScanIndex implements [pgtype.CompositeIndexScanner] for PostgreSQL composite typeid columns.
// Index 0 is the type prefix; index 1 is the UUID.
func (s *Sortable[P]) ScanIndex(i int) any {
return compositeScanIndex(s, i)
}