Skip to content

Commit dc94db6

Browse files
evanjjackc
authored andcommitted
pgtype.Hstore: add a round-trip test for binary and text codecs
This ensures the output of Encode can pass through Scan and produce the same input. This found two two minor problems with the text codec. These are not bugs: These situations do not happen when using pgx with Postgres. However, I think it is worth fixing to ensure the code is internally consistent. The problems with the text codec are: * It did not correctly distinguish between nil and empty. This is not a problem with Postgres, since NULL values are marked separately, but the binary codec distinguishes between them, so it seems like the text codec should as well. * It did not output spaces between keys. Postgres produces output in this format, and the parser now only strictly parses the Postgres format. This is not a bug, but seems like a good idea.
1 parent b68e7b2 commit dc94db6

2 files changed

Lines changed: 67 additions & 15 deletions

File tree

pgtype/hstore.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,15 @@ func (encodePlanHstoreCodecText) Encode(value any, buf []byte) (newBuf []byte, e
121121
return nil, err
122122
}
123123

124-
if hstore == nil {
125-
return nil, nil
124+
if len(hstore) == 0 {
125+
// distinguish between empty and nil: Not strictly required by Postgres, since its protocol
126+
// explicitly marks NULL column values separately. However, the Binary codec does this, and
127+
// this means we can "round trip" Encode and Scan without data loss.
128+
// nil: []byte(nil); empty: []byte{}
129+
if hstore == nil {
130+
return nil, nil
131+
}
132+
return []byte{}, nil
126133
}
127134

128135
firstPair := true
@@ -131,7 +138,7 @@ func (encodePlanHstoreCodecText) Encode(value any, buf []byte) (newBuf []byte, e
131138
if firstPair {
132139
firstPair = false
133140
} else {
134-
buf = append(buf, ',')
141+
buf = append(buf, ',', ' ')
135142
}
136143

137144
// unconditionally quote hstore keys/values like Postgres does

pgtype/hstore_test.go

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package pgtype_test
22

33
import (
44
"context"
5+
"fmt"
56
"reflect"
67
"testing"
78
"time"
@@ -53,6 +54,11 @@ func isExpectedEqMapStringPointerString(a any) func(any) bool {
5354
}
5455
}
5556

57+
// stringPtr returns a pointer to s.
58+
func stringPtr(s string) *string {
59+
return &s
60+
}
61+
5662
func TestHstoreCodec(t *testing.T) {
5763
ctr := defaultConnTestRunner
5864
ctr.AfterConnect = func(ctx context.Context, t testing.TB, conn *pgx.Conn) {
@@ -65,10 +71,6 @@ func TestHstoreCodec(t *testing.T) {
6571
conn.TypeMap().RegisterType(&pgtype.Type{Name: "hstore", OID: hstoreOID, Codec: pgtype.HstoreCodec{}})
6672
}
6773

68-
fs := func(s string) *string {
69-
return &s
70-
}
71-
7274
tests := []pgxtest.ValueRoundTripTest{
7375
{
7476
map[string]string{},
@@ -101,14 +103,14 @@ func TestHstoreCodec(t *testing.T) {
101103
isExpectedEqMapStringPointerString(map[string]*string{}),
102104
},
103105
{
104-
map[string]*string{"foo": fs("bar"), "baq": fs("quz")},
106+
map[string]*string{"foo": stringPtr("bar"), "baq": stringPtr("quz")},
105107
new(map[string]*string),
106-
isExpectedEqMapStringPointerString(map[string]*string{"foo": fs("bar"), "baq": fs("quz")}),
108+
isExpectedEqMapStringPointerString(map[string]*string{"foo": stringPtr("bar"), "baq": stringPtr("quz")}),
107109
},
108110
{
109-
map[string]*string{"foo": nil, "baq": fs("quz")},
111+
map[string]*string{"foo": nil, "baq": stringPtr("quz")},
110112
new(map[string]*string),
111-
isExpectedEqMapStringPointerString(map[string]*string{"foo": nil, "baq": fs("quz")}),
113+
isExpectedEqMapStringPointerString(map[string]*string{"foo": nil, "baq": stringPtr("quz")}),
112114
},
113115
{nil, new(*map[string]string), isExpectedEq((*map[string]string)(nil))},
114116
{nil, new(*map[string]*string), isExpectedEq((*map[string]*string)(nil))},
@@ -201,7 +203,7 @@ func TestHstoreCodec(t *testing.T) {
201203
if typedParam != nil {
202204
h = pgtype.Hstore{}
203205
for k, v := range typedParam {
204-
h[k] = fs(v)
206+
h[k] = stringPtr(v)
205207
}
206208
}
207209
}
@@ -261,10 +263,53 @@ func TestParseInvalidInputs(t *testing.T) {
261263
}
262264
}
263265

264-
func BenchmarkHstoreEncode(b *testing.B) {
265-
stringPtr := func(s string) *string {
266-
return &s
266+
func TestRoundTrip(t *testing.T) {
267+
codecs := []struct {
268+
name string
269+
encodePlan pgtype.EncodePlan
270+
scanPlan pgtype.ScanPlan
271+
}{
272+
{
273+
"text",
274+
pgtype.HstoreCodec{}.PlanEncode(nil, 0, pgtype.TextFormatCode, pgtype.Hstore(nil)),
275+
pgtype.HstoreCodec{}.PlanScan(nil, 0, pgtype.TextFormatCode, (*pgtype.Hstore)(nil)),
276+
},
277+
{
278+
"binary",
279+
pgtype.HstoreCodec{}.PlanEncode(nil, 0, pgtype.BinaryFormatCode, pgtype.Hstore(nil)),
280+
pgtype.HstoreCodec{}.PlanScan(nil, 0, pgtype.BinaryFormatCode, (*pgtype.Hstore)(nil)),
281+
},
267282
}
283+
284+
inputs := []pgtype.Hstore{
285+
nil,
286+
{},
287+
{"": stringPtr("")},
288+
{"k1": stringPtr("v1")},
289+
{"k1": stringPtr("v1"), "k2": stringPtr("v2")},
290+
}
291+
for _, codec := range codecs {
292+
for i, input := range inputs {
293+
t.Run(fmt.Sprintf("%s/%d", codec.name, i), func(t *testing.T) {
294+
serialized, err := codec.encodePlan.Encode(input, nil)
295+
if err != nil {
296+
t.Fatal(err)
297+
}
298+
var output pgtype.Hstore
299+
err = codec.scanPlan.Scan(serialized, &output)
300+
if err != nil {
301+
t.Fatal(err)
302+
}
303+
if !reflect.DeepEqual(output, input) {
304+
t.Errorf("output=%#v does not match input=%#v", output, input)
305+
}
306+
})
307+
}
308+
}
309+
310+
}
311+
312+
func BenchmarkHstoreEncode(b *testing.B) {
268313
h := pgtype.Hstore{"a x": stringPtr("100"), "b": stringPtr("200"), "c": stringPtr("300"),
269314
"d": stringPtr("400"), "e": stringPtr("500")}
270315

0 commit comments

Comments
 (0)