From ede6c1c04f017696d88265f31111062d23ff35b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Gr=C3=A4ger?= Date: Thu, 6 Aug 2026 13:26:45 +0200 Subject: [PATCH] feat: add pgx composite type support --- README.md | 24 ++++++++- doc.go | 25 +++++++++- encoding.go | 61 +++++++++++++++++++++++ encoding_test.go | 123 +++++++++++++++++++++++++++++++++++++++++++++++ random.go | 22 +++++++++ sortable.go | 22 +++++++++ 6 files changed, 274 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4a47ac5..6234a15 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/doc.go b/doc.go index 00a1d58..490009b 100644 --- a/doc.go +++ b/doc.go @@ -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 // diff --git a/encoding.go b/encoding.go index 9808866..b7bf5ed 100644 --- a/encoding.go +++ b/encoding.go @@ -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)) + } +} diff --git a/encoding_test.go b/encoding_test.go index 0bff8b0..3456ac3 100644 --- a/encoding_test.go +++ b/encoding_test.go @@ -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) + } + }) +} diff --git a/random.go b/random.go index 16744ec..3d32a8e 100644 --- a/random.go +++ b/random.go @@ -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) +} diff --git a/sortable.go b/sortable.go index d80bdee..79f68c6 100644 --- a/sortable.go +++ b/sortable.go @@ -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) +}