Skip to content

Commit 6ad56fe

Browse files
committed
Fix key collission on 'Unflatten' by making insertions deterministic. Closes #440.
1 parent eb15bf7 commit 6ad56fe

2 files changed

Lines changed: 55 additions & 8 deletions

File tree

maps/maps.go

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package maps
66
import (
77
"fmt"
88
"reflect"
9+
"sort"
910
"strings"
1011

1112
"github.com/mitchellh/copystructure"
@@ -65,14 +66,22 @@ func flatten(m map[string]any, keys []string, delim string, out map[string]any,
6566
// and returns a nested map where the keys are split into hierarchies by the given
6667
// delimiter. For instance, `parent.child.key: 1` to `{parent: {child: {key: 1}}}`
6768
//
69+
// Keys are sorted by length to make merge-order deterministic so that nested keys
70+
// don't end up overwriting their parent keys. eg: `parent.child` -> `parent`.
71+
//
6872
// It's important to note that all nested maps should be
6973
// map[string]any and not map[any]any.
7074
// Use IntfaceKeysToStrings() to convert if necessary.
7175
func Unflatten(m map[string]any, delim string) map[string]any {
7276
out := make(map[string]any)
7377

74-
// Iterate through the flat conf map.
75-
for k, v := range m {
78+
ks := make([]string, 0, len(m))
79+
for k := range m {
80+
ks = append(ks, k)
81+
}
82+
sort.Strings(ks)
83+
84+
for _, k := range ks {
7685
var (
7786
keys []string
7887
next = out
@@ -87,19 +96,17 @@ func Unflatten(m map[string]any, delim string) map[string]any {
8796
// Iterate through key parts, for eg:, parent.child.key
8897
// will be ["parent", "child", "key"]
8998
for _, k := range keys[:len(keys)-1] {
90-
sub, ok := next[k]
99+
// Create the key if it doesn't exist.
100+
sub, ok := next[k].(map[string]any)
91101
if !ok {
92-
// If the key does not exist in the map, create it.
93102
sub = make(map[string]any)
94103
next[k] = sub
95104
}
96-
if n, ok := sub.(map[string]any); ok {
97-
next = n
98-
}
105+
next = sub
99106
}
100107

101108
// Assign the value.
102-
next[keys[len(keys)-1]] = v
109+
next[keys[len(keys)-1]] = m[k]
103110
}
104111
return out
105112
}

tests/maps_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,46 @@ func TestUnflatten(t *testing.T) {
132132
assert.Equal(t, um, testMap2)
133133
}
134134

135+
func TestUnflattenNestedOrder(t *testing.T) {
136+
for _, c := range []struct {
137+
name string
138+
in map[string]any
139+
want map[string]any
140+
}{
141+
{
142+
"scalar and nested",
143+
map[string]any{"parent": 1, "parent.child": 2},
144+
map[string]any{"parent": map[string]any{"child": 2}},
145+
},
146+
{
147+
"prefix skips one level",
148+
map[string]any{"parent": 1, "parent.child.key": 2},
149+
map[string]any{"parent": map[string]any{"child": map[string]any{"key": 2}}},
150+
},
151+
{
152+
"three keys in one path",
153+
map[string]any{"parent": 1, "parent.child": 2, "parent.child.key": 3},
154+
map[string]any{"parent": map[string]any{"child": map[string]any{"key": 3}}},
155+
},
156+
{
157+
"colliding key already has a map",
158+
map[string]any{"parent": map[string]any{"other": 1}, "parent.child": 2},
159+
map[string]any{"parent": map[string]any{"other": 1, "child": 2}},
160+
},
161+
} {
162+
t.Run(c.name, func(t *testing.T) {
163+
for range 100 {
164+
assert.Equal(t, c.want, maps.Unflatten(c.in, delim))
165+
}
166+
})
167+
}
168+
}
169+
170+
func TestUnflattenNoDelim(t *testing.T) {
171+
in := map[string]any{"parent": 1, "parent.child": 2}
172+
assert.Equal(t, in, maps.Unflatten(in, ""))
173+
}
174+
135175
func TestIntfaceKeysToStrings(t *testing.T) {
136176
m := map[string]any{
137177
"list": []any{

0 commit comments

Comments
 (0)