diff --git a/go.mod b/go.mod index 6af8b0b9..24edf09b 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/matoous/go-nanoid v1.5.1 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/silas/dag v0.0.0-20220518035006-a7e85ada93c5 + github.com/stretchr/testify v1.10.0 go.uber.org/automaxprocs v1.6.0 go.unistack.org/micro-proto/v3 v3.4.1 golang.org/x/sync v0.10.0 @@ -33,7 +34,6 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/sirupsen/logrus v1.9.3 // indirect - github.com/stretchr/testify v1.10.0 // indirect go.uber.org/goleak v1.3.0 // indirect golang.org/x/exp v0.0.0-20241210194714-1829a127f884 // indirect golang.org/x/net v0.33.0 // indirect diff --git a/util/reflect/struct.go b/util/reflect/struct.go index 10266261..d9fd29dd 100644 --- a/util/reflect/struct.go +++ b/util/reflect/struct.go @@ -489,35 +489,74 @@ func URLMap(query string) (map[string]interface{}, error) { return mp.(map[string]interface{}), nil } -// FlattenMap expand key.subkey to nested map -func FlattenMap(a map[string]interface{}) map[string]interface{} { - // preprocess map - nb := make(map[string]interface{}, len(a)) - for k, v := range a { - ps := strings.Split(k, ".") - if len(ps) == 1 { - nb[k] = v +// FlattenMap flattens a nested map into a single-level map using dot notation for nested keys. +// In case of key conflicts, all nested levels will be discarded in favor of the first-level key. +// +// Example #1: +// +// Input: +// { +// "user.name": "alex", +// "user.document.id": "document_id" +// "user.document.number": "document_number" +// } +// Output: +// { +// "user": { +// "name": "alex", +// "document": { +// "id": "document_id" +// "number": "document_number" +// } +// } +// } +// +// Example #2 (with conflicts): +// +// Input: +// { +// "user": "alex", +// "user.document.id": "document_id" +// "user.document.number": "document_number" +// } +// Output: +// { +// "user": "alex" +// } +func FlattenMap(input map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}) + + for k, v := range input { + parts := strings.Split(k, ".") + + if len(parts) == 1 { + result[k] = v continue } - em := make(map[string]interface{}) - em[ps[len(ps)-1]] = v - for i := len(ps) - 2; i > 0; i-- { - nm := make(map[string]interface{}) - nm[ps[i]] = em - em = nm - } - if vm, ok := nb[ps[0]]; ok { - // nested map - nm := vm.(map[string]interface{}) - for vk, vv := range em { - nm[vk] = vv + + current := result + + for i, part := range parts { + // last element in the path + if i == len(parts)-1 { + current[part] = v + break + } + + // initialize map for current level if not exist + if _, ok := current[part]; !ok { + current[part] = make(map[string]interface{}) + } + + if nested, ok := current[part].(map[string]interface{}); ok { + current = nested // continue to the nested map + } else { + break // if current element is not a map, ignore it } - nb[ps[0]] = nm - } else { - nb[ps[0]] = em } } - return nb + + return result } /* diff --git a/util/reflect/struct_test.go b/util/reflect/struct_test.go index 89be6de3..22d660ef 100644 --- a/util/reflect/struct_test.go +++ b/util/reflect/struct_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" rutil "go.unistack.org/micro/v3/util/reflect" ) @@ -319,3 +320,140 @@ func TestIsZero(t *testing.T) { // t.Logf("XX %#+v\n", ok) } + +func TestFlattenMap(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + expected map[string]interface{} + }{ + { + name: "empty map", + input: map[string]interface{}{}, + expected: map[string]interface{}{}, + }, + { + name: "nil map", + input: nil, + expected: map[string]interface{}{}, + }, + { + name: "single level", + input: map[string]interface{}{ + "username": "username", + "password": "password", + }, + expected: map[string]interface{}{ + "username": "username", + "password": "password", + }, + }, + { + name: "two level", + input: map[string]interface{}{ + "order_id": "order_id", + "user.name": "username", + "user.password": "password", + }, + expected: map[string]interface{}{ + "order_id": "order_id", + "user": map[string]interface{}{ + "name": "username", + "password": "password", + }, + }, + }, + { + name: "three level", + input: map[string]interface{}{ + "order_id": "order_id", + "user.name": "username", + "user.password": "password", + "user.document.id": "document_id", + "user.document.number": "document_number", + }, + expected: map[string]interface{}{ + "order_id": "order_id", + "user": map[string]interface{}{ + "name": "username", + "password": "password", + "document": map[string]interface{}{ + "id": "document_id", + "number": "document_number", + }, + }, + }, + }, + { + name: "four level", + input: map[string]interface{}{ + "order_id": "order_id", + "user.name": "username", + "user.password": "password", + "user.document.id": "document_id", + "user.document.number": "document_number", + "user.info.permissions.read": "available", + "user.info.permissions.write": "available", + }, + expected: map[string]interface{}{ + "order_id": "order_id", + "user": map[string]interface{}{ + "name": "username", + "password": "password", + "document": map[string]interface{}{ + "id": "document_id", + "number": "document_number", + }, + "info": map[string]interface{}{ + "permissions": map[string]interface{}{ + "read": "available", + "write": "available", + }, + }, + }, + }, + }, + { + name: "key conflicts", + input: map[string]interface{}{ + "user": "user", + "user.name": "username", + "user.password": "password", + }, + expected: map[string]interface{}{ + "user": "user", + }, + }, + { + name: "overwriting conflicts", + input: map[string]interface{}{ + "order_id": "order_id", + "user.document.id": "document_id", + "user.document.number": "document_number", + "user.info.address": "address", + "user.info.phone": "phone", + }, + expected: map[string]interface{}{ + "order_id": "order_id", + "user": map[string]interface{}{ + "document": map[string]interface{}{ + "id": "document_id", + "number": "document_number", + }, + "info": map[string]interface{}{ + "address": "address", + "phone": "phone", + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for range 100 { // need to exclude the impact of key order in the map on the test. + require.Equal(t, tt.expected, rutil.FlattenMap(tt.input)) + } + }) + } +}