diff --git a/pgtype/qchar.go b/pgtype/qchar.go index fc40a5b2c..3de0b01fc 100644 --- a/pgtype/qchar.go +++ b/pgtype/qchar.go @@ -64,6 +64,8 @@ func (QCharCodec) PlanScan(m *Map, oid uint32, format int16, target any) ScanPla return scanPlanQcharCodecByte{} case *rune: return scanPlanQcharCodecRune{} + case *string: + return scanPlanQcharCodecString{} } } @@ -114,6 +116,26 @@ func (scanPlanQcharCodecRune) Scan(src []byte, dst any) error { return nil } +type scanPlanQcharCodecString struct{} + +func (scanPlanQcharCodecString) Scan(src []byte, dst any) error { + if src == nil { + return fmt.Errorf("cannot scan NULL into %T", dst) + } + + if len(src) > 1 { + return fmt.Errorf(`invalid length for "char": %v`, len(src)) + } + + p := dst.(*string) + // Copy the raw byte so the result matches the text-format *string scan path + // (string(src)) byte-for-byte. string(src[0]) would instead UTF-8-encode the + // byte as a code point, diverging for byte values >= 128. + *p = string(src) + + return nil +} + func (c QCharCodec) DecodeDatabaseSQLValue(m *Map, oid uint32, format int16, src []byte) (driver.Value, error) { if src == nil { return nil, nil diff --git a/pgtype/qchar_test.go b/pgtype/qchar_test.go index 9de7d5544..1d294074c 100644 --- a/pgtype/qchar_test.go +++ b/pgtype/qchar_test.go @@ -5,6 +5,7 @@ import ( "math" "testing" + "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxtest" ) @@ -22,3 +23,71 @@ func TestQcharTranscode(t *testing.T) { // Can only test with known OIDs as rune and byte would be considered numbers. pgxtest.RunValueRoundTripTests(context.Background(), t, defaultConnTestRunner, pgxtest.KnownOIDQueryExecModes, `"char"`, tests) } + +// TestQcharCodecPlanScanString is a regression test for https://github.com/jackc/pgx/issues/2314. +// +// Scanning a "char" column (OID 18) into *string used to succeed in TextFormat but +// fail in BinaryFormat with "cannot scan char (OID 18) in binary format into *string". +// Both formats must now produce the same result. +func TestQcharCodecPlanScanString(t *testing.T) { + m := pgtype.NewMap() + + for _, tt := range []struct { + name string + format int16 + }{ + {"text", pgtype.TextFormatCode}, + {"binary", pgtype.BinaryFormatCode}, + } { + t.Run(tt.name, func(t *testing.T) { + var s string + plan := m.PlanScan(pgtype.QCharOID, tt.format, &s) + if plan == nil { + t.Fatalf("PlanScan returned nil plan for *string in %s format", tt.name) + } + if err := plan.Scan([]byte{'a'}, &s); err != nil { + t.Fatalf("Scan failed in %s format: %v", tt.name, err) + } + if s != "a" { + t.Fatalf("Scan result mismatch in %s format: got %q want %q", tt.name, s, "a") + } + }) + } + + // Empty src must produce empty string (mirrors *byte / *rune zero-value behavior). + t.Run("empty-binary", func(t *testing.T) { + var s string = "x" + plan := m.PlanScan(pgtype.QCharOID, pgtype.BinaryFormatCode, &s) + if err := plan.Scan([]byte{}, &s); err != nil { + t.Fatalf("Scan failed: %v", err) + } + if s != "" { + t.Fatalf("empty src: got %q want %q", s, "") + } + }) + + // 0xC8 (200): a byte >= 128. Both formats must yield the raw 1-byte string + // "\xc8", not the 2-byte UTF-8 encoding "\xc3\x88". This is the case that + // catches string(src[0]) UTF-8-encoding the byte instead of copying it. + t.Run("non-ascii-byte", func(t *testing.T) { + for _, format := range []int16{pgtype.TextFormatCode, pgtype.BinaryFormatCode} { + var s string + plan := m.PlanScan(pgtype.QCharOID, format, &s) + if err := plan.Scan([]byte{0xC8}, &s); err != nil { + t.Fatalf("format %d: scan failed: %v", format, err) + } + if s != "\xc8" { + t.Fatalf("format %d: got %q (% x) want %q", format, s, s, "\xc8") + } + } + }) + + // Multi-byte src is an invalid "char" payload and must error. + t.Run("too-long", func(t *testing.T) { + var s string + plan := m.PlanScan(pgtype.QCharOID, pgtype.BinaryFormatCode, &s) + if err := plan.Scan([]byte("ab"), &s); err == nil { + t.Fatalf("expected error for 2-byte src, got nil (s=%q)", s) + } + }) +}