diff --git a/resp/resp3/flatten.go b/resp/resp3/flatten.go index 50263dd..7c693d2 100644 --- a/resp/resp3/flatten.go +++ b/resp/resp3/flatten.go @@ -25,6 +25,25 @@ func cleanFloatStr(str string) string { return str } +// Source: https://cs.opensource.google/go/go/+/refs/tags/go1.19.2:src/encoding/json/encode.go;drc=d0b0b10b5cbb28d53403c2bd6af343581327e946;l=339 +func isEmptyValue(v reflect.Value) bool { + switch v.Kind() { + case reflect.Array, reflect.Map, reflect.Slice, reflect.String: + return v.Len() == 0 + case reflect.Bool: + return !v.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return v.Uint() == 0 + case reflect.Float32, reflect.Float64: + return v.Float() == 0 + case reflect.Interface, reflect.Pointer: + return v.IsNil() + } + return false +} + // Flatten accepts any type accepted by Marshal, except a resp.Marshaler, and // converts it into a flattened array of strings. For example: // @@ -33,7 +52,6 @@ func cleanFloatStr(str string) string { // Flatten([]string{"a","b"}) -> {"a", "b"} // Flatten(map[string]int{"a":5,"b":10}) -> {"a","5","b","10"} // Flatten([]map[int]float64{{1:2, 3:4},{5:6},{}}) -> {"1","2","3","4","5","6"}) -// func Flatten(i interface{}, o *resp.Opts) ([]string, error) { f := flattener{ opts: o, @@ -165,7 +183,7 @@ func (f *flattener) flatten(i interface{}) error { l := vv.NumField() for i := 0; i < l; i++ { ft, fv := tt.Field(i), vv.Field(i) - tag := ft.Tag.Get("redis") + tag, tagOpts := parseTag(ft.Tag.Get("redis")) if ft.Anonymous { if fv = reflect.Indirect(fv); !fv.IsValid() { // fv is nil continue @@ -177,12 +195,22 @@ func (f *flattener) flatten(i interface{}) error { continue // unexported } + isEmpty := isEmptyValue(fv) + if isEmpty && tagOpts.Contains("omitempty") { + continue + } + keyName := ft.Name if tag != "" { keyName = tag } _ = f.emit(keyName) + if isEmpty { + // Return "", setting empty value + return f.emit("") + } + if err := f.flatten(fv.Interface()); err != nil { return err } diff --git a/resp/resp3/flatten_test.go b/resp/resp3/flatten_test.go new file mode 100644 index 0000000..b8f24db --- /dev/null +++ b/resp/resp3/flatten_test.go @@ -0,0 +1,41 @@ +package resp3 + +import ( + "testing" + + "github.com/mediocregopher/radix/v4/resp" + "github.com/stretchr/testify/suite" +) + +type FlattenTestSuite struct { + suite.Suite +} + +// Test whether by default, an empty slice in a hashmap should be flattened to an empty value. +func (s *FlattenTestSuite) TestEmptyNestedSlice() { + testInst := struct { + Nested []string + }{} + + flat, err := Flatten(testInst, resp.NewOpts()) + s.NoError(err) + + s.Equal([]string{"Nested", ""}, flat) +} + +// Test with omitempty; empty slices should be left off altogether. +func (s *FlattenTestSuite) TestOmitEmptyNestedSlice() { + testInst := struct { + Other string // We need at least one non-empty value for commands like HMSET. + NestedOmitEmpty []string `redis:",omitempty"` + }{} + + flat, err := Flatten(testInst, resp.NewOpts()) + s.NoError(err) + + s.Equal([]string{"Other", ""}, flat) +} + +func TestFlattenTestSuite(t *testing.T) { + suite.Run(t, new(FlattenTestSuite)) +} diff --git a/resp/resp3/resp.go b/resp/resp3/resp.go index 0f96761..d81cc9e 100644 --- a/resp/resp3/resp.go +++ b/resp/resp3/resp.go @@ -2255,7 +2255,7 @@ func getStructFields(t reflect.Type) map[string]structField { } key, fromTag := ft.Name, false - if tag := ft.Tag.Get("redis"); tag != "" && tag != "-" { + if tag, _ := parseTag(ft.Tag.Get("redis")); tag != "" && tag != "-" { key, fromTag = tag, true } if m[key].fromTag { diff --git a/resp/resp3/tags.go b/resp/resp3/tags.go new file mode 100644 index 0000000..a5da458 --- /dev/null +++ b/resp/resp3/tags.go @@ -0,0 +1,38 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package resp3 + +import ( + "strings" +) + +// tagOptions is the string following a comma in a struct field's "json" +// tag, or the empty string. It does not include the leading comma. +type tagOptions string + +// parseTag splits a struct field's json tag into its name and +// comma-separated options. +func parseTag(tag string) (string, tagOptions) { + tag, opt, _ := strings.Cut(tag, ",") + return tag, tagOptions(opt) +} + +// Contains reports whether a comma-separated list of options +// contains a particular substr flag. substr must be surrounded by a +// string boundary or commas. +func (o tagOptions) Contains(optionName string) bool { + if len(o) == 0 { + return false + } + s := string(o) + for s != "" { + var name string + name, s, _ = strings.Cut(s, ",") + if name == optionName { + return true + } + } + return false +} diff --git a/resp/resp3/tags_test.go b/resp/resp3/tags_test.go new file mode 100644 index 0000000..774b2af --- /dev/null +++ b/resp/resp3/tags_test.go @@ -0,0 +1,28 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package resp3 + +import ( + "testing" +) + +func TestTagParsing(t *testing.T) { + name, opts := parseTag("field,foobar,foo") + if name != "field" { + t.Fatalf("name = %q, want field", name) + } + for _, tt := range []struct { + opt string + want bool + }{ + {"foobar", true}, + {"foo", true}, + {"bar", false}, + } { + if opts.Contains(tt.opt) != tt.want { + t.Errorf("Contains(%q) = %v", tt.opt, !tt.want) + } + } +}