From 6886bd77963bcb033d7d67ebbda30917a7d42bbf Mon Sep 17 00:00:00 2001 From: Inhere Date: Thu, 18 May 2023 22:13:06 +0800 Subject: [PATCH] :sparkles: feat: structs add new util func ToSMap(), TryToSMap(), TryToSMap() - add new map option MergeAnonymous, ExportPrivate --- structs/convert.go | 98 ++++++++++++++++++++++------- structs/convert_test.go | 134 +++++++++++++++++++++++++++++++++++----- structs/structs.go | 7 +-- 3 files changed, 197 insertions(+), 42 deletions(-) diff --git a/structs/convert.go b/structs/convert.go index 3b585883e..8a379fe02 100644 --- a/structs/convert.go +++ b/structs/convert.go @@ -6,6 +6,7 @@ import ( "reflect" "github.com/gookit/goutil/maputil" + "github.com/gookit/goutil/reflects" ) // ToMap quickly convert structs to map by reflect @@ -28,7 +29,31 @@ func TryToMap(st any, optFns ...MapOptFunc) (map[string]any, error) { return StructToMap(st, optFns...) } -// ToString format +// ToSMap quickly and safe convert structs to map[string]string by reflect +func ToSMap(st any, optFns ...MapOptFunc) map[string]string { + mp, _ := StructToMap(st, optFns...) + return maputil.ToStringMap(mp) +} + +// TryToSMap quickly convert structs to map[string]string by reflect +func TryToSMap(st any, optFns ...MapOptFunc) (map[string]string, error) { + mp, err := StructToMap(st, optFns...) + if err != nil { + return nil, err + } + return maputil.ToStringMap(mp), nil +} + +// MustToSMap alias of ToStringMap(), but will panic on error +func MustToSMap(st any, optFns ...MapOptFunc) map[string]string { + mp, err := StructToMap(st, optFns...) + if err != nil { + panic(err) + } + return maputil.ToStringMap(mp) +} + +// ToString quickly format struct to string func ToString(st any, optFns ...MapOptFunc) string { mp, err := StructToMap(st, optFns...) if err == nil { @@ -39,14 +64,38 @@ func ToString(st any, optFns ...MapOptFunc) string { const defaultFieldTag = "json" -// MapOptions struct +// MapOptions for convert struct to map type MapOptions struct { + // TagName for map filed. default is "json" TagName string + // ParseDepth for parse. TODO support depth + ParseDepth int + // MergeAnonymous struct fields to parent map. default is true + MergeAnonymous bool + // ExportPrivate export private fields. default is false + ExportPrivate bool } // MapOptFunc define type MapOptFunc func(opt *MapOptions) +// WithMapTagName set tag name for map field +func WithMapTagName(tagName string) MapOptFunc { + return func(opt *MapOptions) { + opt.TagName = tagName + } +} + +// MergeAnonymous merge anonymous struct fields to parent map +func MergeAnonymous(opt *MapOptions) { + opt.MergeAnonymous = true +} + +// ExportPrivate merge anonymous struct fields to parent map +func ExportPrivate(opt *MapOptions) { + opt.ExportPrivate = true +} + // StructToMap quickly convert structs to map[string]any by reflect. // Can custom export field name by tag `json` or custom tag func StructToMap(st any, optFns ...MapOptFunc) (map[string]any, error) { @@ -55,11 +104,7 @@ func StructToMap(st any, optFns ...MapOptFunc) (map[string]any, error) { return mp, nil } - obj := reflect.ValueOf(st) - if obj.Kind() == reflect.Ptr { - obj = obj.Elem() - } - + obj := reflect.Indirect(reflect.ValueOf(st)) if obj.Kind() != reflect.Struct { return mp, errors.New("must be an struct value") } @@ -69,23 +114,25 @@ func StructToMap(st any, optFns ...MapOptFunc) (map[string]any, error) { fn(opt) } - mp, err := structToMap(obj, opt.TagName) + _, err := structToMap(obj, opt, mp) return mp, err } -func structToMap(obj reflect.Value, tagName string) (map[string]any, error) { - refType := obj.Type() - mp := make(map[string]any) +func structToMap(obj reflect.Value, opt *MapOptions, mp map[string]any) (map[string]any, error) { + if mp == nil { + mp = make(map[string]any) + } + refType := obj.Type() for i := 0; i < obj.NumField(); i++ { ft := refType.Field(i) name := ft.Name - // skip don't exported field - if name[0] >= 'a' && name[0] <= 'z' { + // skip un-exported field + if !opt.ExportPrivate && IsUnexported(name) { continue } - tagVal, ok := ft.Tag.Lookup(tagName) + tagVal, ok := ft.Tag.Lookup(opt.TagName) if ok && tagVal != "" { sMap, err := ParseTagValueDefault(name, tagVal) if err != nil { @@ -93,24 +140,33 @@ func structToMap(obj reflect.Value, tagName string) (map[string]any, error) { } name = sMap.Default("name", name) - // un-exported field - if name == "" { + if name == "" { // un-exported field continue } } - field := obj.Field(i) + field := reflect.Indirect(obj.Field(i)) if field.Kind() == reflect.Struct { - sub, err := structToMap(field, tagName) - if err != nil { - return nil, err + // collect anonymous struct values to parent. + if ft.Anonymous && opt.MergeAnonymous { + _, err := structToMap(field, opt, mp) + if err != nil { + return nil, err + } + } else { // collect struct values to submap + sub, err := structToMap(field, opt, nil) + if err != nil { + return nil, err + } + mp[name] = sub } - mp[name] = sub continue } if field.CanInterface() { mp[name] = field.Interface() + } else if field.CanAddr() { // for unexported field + mp[name] = reflects.UnexportedValue(field) } } diff --git a/structs/convert_test.go b/structs/convert_test.go index 5c38583f0..fa6e8dc51 100644 --- a/structs/convert_test.go +++ b/structs/convert_test.go @@ -41,10 +41,32 @@ func TestTryToMap(t *testing.T) { assert.NotEmpty(t, mp) // dump.P(mp) + // test to string map + smp := structs.MustToSMap(&u) + assert.NotEmpty(t, smp) + assert.ContainsKeys(t, smp, []string{"Name", "Age"}) + + smp = structs.ToSMap(&u) + assert.NotEmpty(t, smp) + assert.ContainsKeys(t, smp, []string{"Name", "Age"}) + + _, err = structs.TryToSMap("invalid") + assert.Err(t, err) + + smp, err = structs.TryToSMap(&u) + assert.NoErr(t, err) + assert.NotEmpty(t, smp) + assert.ContainsKeys(t, smp, []string{"Name", "Age"}) + + assert.NotEmpty(t, structs.ToString(&u)) + + // test error assert.Panics(t, func() { structs.MustToMap("abc") }) - + assert.Panics(t, func() { + structs.MustToSMap("abc") + }) } func TestToMap_useTag(t *testing.T) { @@ -64,20 +86,84 @@ func TestToMap_useTag(t *testing.T) { dump.P(mp) assert.ContainsKeys(t, mp, []string{"name", "age"}) assert.NotContains(t, mp, "city") + + // export unexported field + mp = structs.MustToMap(u1, structs.ExportPrivate) + dump.P(mp) + assert.ContainsKeys(t, mp, []string{"name", "age", "city"}) +} + +type Extra struct { + City string `json:"city"` + Github string `json:"github"` +} + +type Extra1 struct { + ExtraSub + City string `json:"city"` + Github string `json:"github"` +} + +type ExtraSub struct { + ESubKey string `json:"e_sub_key"` +} + +type User struct { + Extra `json:"extra"` + Name string `json:"name"` + Age int `json:"age"` +} + +type User1 struct { + Extra + Name string `json:"name"` + Age int `json:"age"` +} + +type User2 struct { + Extra1 + Name string `json:"name"` + Age int `json:"age"` } func TestToMap_nestStruct(t *testing.T) { - type Extra struct { - City string `json:"city"` - Github string `json:"github"` - } - type User struct { - Name string `json:"name"` - Age int `json:"age"` - Extra Extra `json:"extra"` + e := Extra{ + City: "chengdu", + Github: "https://github.com/inhere", } u := &User{ + Name: "inhere", + Age: 30, + Extra: e, + } + + mp := structs.MustToMap(u) + dump.P(mp) + assert.ContainsKeys(t, mp, []string{"name", "age", "extra"}) + assert.ContainsKeys(t, mp["extra"], []string{"city", "github"}) + + // use pointer + type UserPtrSub struct { + *Extra `json:"extra"` + Name string `json:"name"` + Age int `json:"age"` + } + + u2 := &UserPtrSub{ + Name: "inhere", + Age: 30, + Extra: &e, + } + + mp = structs.MustToMap(u2) + dump.P(mp) + assert.ContainsKeys(t, mp, []string{"name", "age", "extra"}) + assert.ContainsKeys(t, mp["extra"], []string{"city", "github"}) +} + +func TestToMap_anonymousStruct(t *testing.T) { + u := &User1{ Name: "inhere", Age: 30, Extra: Extra{ @@ -86,10 +172,30 @@ func TestToMap_nestStruct(t *testing.T) { }, } - mp := structs.MustToMap(u) + mp := structs.MustToMap(u, structs.MergeAnonymous) dump.P(mp) - assert.ContainsKeys(t, mp, []string{"name", "age", "extra"}) - assert.ContainsKeys(t, mp["extra"], []string{"city", "github"}) + + assert.ContainsKeys(t, mp, []string{"name", "age", "city", "github"}) + assert.NotContainsKey(t, mp, "extra") + assert.NotContainsKeys(t, mp, []string{"extra"}) + + u2 := &User2{ + Name: "inhere", + Age: 30, + Extra1: Extra1{ + ExtraSub: ExtraSub{ + ESubKey: "sub key", + }, + City: "chengdu", + Github: "https://github.com/inhere", + }, + } + + mp = structs.MustToMap(u2, structs.MergeAnonymous) + dump.P(mp) + + assert.ContainsKeys(t, mp, []string{"name", "age", "city", "github", "e_sub_key"}) + assert.NotContainsKey(t, mp, "extra") } func TestTryToMap_customTag(t *testing.T) { @@ -105,9 +211,7 @@ func TestTryToMap_customTag(t *testing.T) { FullName: "inhere xyz", } - mp, err := structs.TryToMap(u1, func(opt *structs.MapOptions) { - opt.TagName = "export" - }) + mp, err := structs.TryToMap(u1, structs.WithMapTagName("export")) assert.NoErr(t, err) assert.NotEmpty(t, mp) diff --git a/structs/structs.go b/structs/structs.go index e5cf212aa..f6ed6f646 100644 --- a/structs/structs.go +++ b/structs/structs.go @@ -1,11 +1,6 @@ // Package structs Provide some extends util functions for struct. eg: tag parse, struct init, value set package structs -// MapStruct simple copy src struct value to dst struct -// func MapStruct(srcSt, dstSt any) { -// // TODO -// } - // IsExported field name on struct func IsExported(fieldName string) bool { return fieldName[0] >= 'A' && fieldName[0] <= 'Z' @@ -13,5 +8,5 @@ func IsExported(fieldName string) bool { // IsUnexported field name on struct func IsUnexported(fieldName string) bool { - return !IsExported(fieldName) + return fieldName[0] >= 'a' && fieldName[0] <= 'z' }