From 2852bf4d35a0bc5739ef4876fb18ad525d428cf8 Mon Sep 17 00:00:00 2001 From: shimonp21 Date: Thu, 24 Nov 2022 13:26:37 +0200 Subject: [PATCH] feat: Handle resolving of map[string]string to JSON In fields like 'Tags', 'Annotations' - it is more consistent if empty/null dictionaries show up as an empty JSON object `{}`, rather than being a postgres "null" or a JSON "null". Same for slices. Note that this PR will not handle the case of nested fields - but it should already be an improvement on current behavior. --- schema/json.go | 42 ++++++++++++++++++++++++++++++++++++++++++ schema/json_test.go | 21 +++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/schema/json.go b/schema/json.go index 555aaa28e7..c88545fc72 100644 --- a/schema/json.go +++ b/schema/json.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "reflect" ) type JSONTransformer interface { @@ -63,6 +64,7 @@ func (dst *JSON) Set(src interface{}) error { } else { *dst = JSON{Bytes: value, Status: Present} } + // Encode* methods are defined on *JSON. If JSON is passed directly then the // struct itself would be encoded instead of Bytes. This is clearly a footgun // so detect and return an error. See https://github.com/jackc/pgx/issues/350. @@ -74,6 +76,20 @@ func (dst *JSON) Set(src interface{}) error { if err != nil { return err } + + // For map and slice jsons, it is easier for users to work with '[]' or '{}' instead of JSON's 'null'. + if bytes.Equal(buf, []byte(`null`)) { + if isEmptyStringMap(value) { + *dst = JSON{Bytes: []byte("{}"), Status: Present} + return nil + } + + if isEmptySlice(value) { + *dst = JSON{Bytes: []byte("[]"), Status: Present} + return nil + } + } + *dst = JSON{Bytes: buf, Status: Present} } @@ -95,3 +111,29 @@ func (dst JSON) Get() interface{} { return dst.Status } } + +// isEmptyStringMap returns true if the value is a map from string to any (i.e. map[string]interface{}). +// We need to use reflection for this, because it impossible to type-assert a map[string]string into a +// map[string]interface{}. See https://go.dev/doc/faq#convert_slice_of_interface. +func isEmptyStringMap(value interface{}) bool { + if reflect.TypeOf(value).Kind() != reflect.Map { + return false + } + + if reflect.TypeOf(value).Key().Kind() != reflect.String { + return false + } + + return reflect.ValueOf(value).Len() == 0 +} + +// isEmptySlice returns true if the value is a slice (i.e. []interface{}). +// We need to use reflection for this, because it impossible to type-assert a map[string]string into a +// map[string]interface{}. See https://go.dev/doc/faq#convert_slice_of_interface. +func isEmptySlice(value interface{}) bool { + if reflect.TypeOf(value).Kind() != reflect.Slice { + return false + } + + return reflect.ValueOf(value).Len() == 0 +} diff --git a/schema/json_test.go b/schema/json_test.go index dd5a26c117..239bd9ae32 100644 --- a/schema/json_test.go +++ b/schema/json_test.go @@ -4,6 +4,10 @@ import ( "testing" ) +type Foo struct { + Num int +} + func TestJSONSet(t *testing.T) { successfulTests := []struct { source interface{} @@ -13,8 +17,25 @@ func TestJSONSet(t *testing.T) { {source: []byte("{}"), result: JSON{Bytes: []byte("{}"), Status: Present}}, {source: ([]byte)(nil), result: JSON{Status: Null}}, {source: (*string)(nil), result: JSON{Status: Null}}, + {source: []int{1, 2, 3}, result: JSON{Bytes: []byte("[1,2,3]"), Status: Present}}, + {source: []int(nil), result: JSON{Bytes: []byte(`[]`), Status: Present}}, + {source: []int{}, result: JSON{Bytes: []byte(`[]`), Status: Present}}, + {source: []Foo(nil), result: JSON{Bytes: []byte(`[]`), Status: Present}}, + {source: []Foo{}, result: JSON{Bytes: []byte(`[]`), Status: Present}}, + {source: []Foo{{1}}, result: JSON{Bytes: []byte(`[{"Num":1}]`), Status: Present}}, + {source: map[string]interface{}{"foo": "bar"}, result: JSON{Bytes: []byte(`{"foo":"bar"}`), Status: Present}}, + {source: map[string]interface{}(nil), result: JSON{Bytes: []byte(`{}`), Status: Present}}, + {source: map[string]interface{}{}, result: JSON{Bytes: []byte(`{}`), Status: Present}}, + {source: map[string]string{"foo": "bar"}, result: JSON{Bytes: []byte(`{"foo":"bar"}`), Status: Present}}, + {source: map[string]string(nil), result: JSON{Bytes: []byte(`{}`), Status: Present}}, + {source: map[string]string{}, result: JSON{Bytes: []byte(`{}`), Status: Present}}, + {source: map[string]Foo{"foo": {1}}, result: JSON{Bytes: []byte(`{"foo":{"Num":1}}`), Status: Present}}, + {source: map[string]Foo(nil), result: JSON{Bytes: []byte(`{}`), Status: Present}}, + {source: map[string]Foo{}, result: JSON{Bytes: []byte(`{}`), Status: Present}}, + + {source: nil, result: JSON{Status: Null}}, } for i, tt := range successfulTests {