Skip to content

Commit 9ab25f6

Browse files
authored
[confmap] Return nil map if original map was nil (#13161)
<!--Ex. Fixing a bug - Describe the bug and how this fixes the issue. Ex. Adding a feature - Explain what this achieves.--> #### Description Amends `ToStringMap` so that it returns `map[string]any(nil)` if the map used to create this `Conf` was nil. Currently it returns an empty map. I consider this a bug since, while not explicitly undocumented, I would expect the following two properties to be true, and they were not before this change: 1. For any map `m` without `expandedValue`s, `NewFromStringMap(m).ToStringMap() == m` 2. For any map `m` without `expandedValue`s and any `path` referencing an existing key in `m` with a `map[string]any` value, `NewFromStringMap(m).Sub(path) == m[path[0]][path[1]][...][path[N]]` <!-- Issue number if applicable --> #### Link to tracking issue I need this to be able to distinguish between ``` foo: bar: ``` and ``` foo: bar: {} ``` which currently have different behaviors when mapping to pointers. The goal is to be able to do #13168
1 parent d800ad3 commit 9ab25f6

File tree

9 files changed

+373
-5
lines changed

9 files changed

+373
-5
lines changed

.chloggen/mx-psi_confmap-isnil.yaml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Use this changelog template to create an entry for release notes.
2+
3+
# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
4+
change_type: bug_fix
5+
6+
# The name of the component, or a single word describing the area of concern, (e.g. otlpreceiver)
7+
component: confmap
8+
9+
# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
10+
note: Correctly distinguish between `nil` and empty map values on the `ToStringMap` method
11+
12+
# One or more tracking issues or pull requests related to the change
13+
issues: [13161]
14+
15+
# (Optional) One or more lines of additional information to render under the primary note.
16+
# These lines will be padded with 2 spaces and then inserted directly into the document.
17+
# Use pipe (|) for multiline entries.
18+
subtext: |
19+
This means that `ToStringMap()` method can now return a nil map if the original value was `nil`.
20+
If you were not doing so already, make sure to check for `nil` before writing to the map to avoid panics.
21+
22+
# Optional: The change log or logs in which this entry should be included.
23+
# e.g. '[user]' or '[user, api]'
24+
# Include 'user' if the change is relevant to end users.
25+
# Include 'api' if there is a change to a library API.
26+
# Default: '[user]'
27+
change_logs: [api]

confmap/confmap.go

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,18 @@ const (
3535

3636
// New creates a new empty confmap.Conf instance.
3737
func New() *Conf {
38-
return &Conf{k: koanf.New(KeyDelimiter)}
38+
return &Conf{k: koanf.New(KeyDelimiter), isNil: false}
3939
}
4040

4141
// NewFromStringMap creates a confmap.Conf from a map[string]any.
4242
func NewFromStringMap(data map[string]any) *Conf {
4343
p := New()
44-
// Cannot return error because the koanf instance is empty.
45-
_ = p.k.Load(confmap.Provider(data, KeyDelimiter), nil)
44+
if data == nil {
45+
p.isNil = true
46+
} else {
47+
// Cannot return error because the koanf instance is empty.
48+
_ = p.k.Load(confmap.Provider(data, KeyDelimiter), nil)
49+
}
4650
return p
4751
}
4852

@@ -55,6 +59,9 @@ type Conf struct {
5559
// This avoids running into an infinite recursion where Unmarshaler.Unmarshal and
5660
// Conf.Unmarshal would call each other.
5761
skipTopLevelUnmarshaler bool
62+
// isNil is true if this Conf was created from a nil field, as opposed to an empty map.
63+
// AllKeys must return an empty slice if this is true.
64+
isNil bool
5865
}
5966

6067
// AllKeys returns all keys holding a value, regardless of where they are set.
@@ -178,6 +185,7 @@ func (l *Conf) Merge(in *Conf) error {
178185
// only use MergeAppend when enableMergeAppendOption featuregate is enabled.
179186
return l.mergeAppend(in)
180187
}
188+
l.isNil = l.isNil && in.isNil
181189
return l.k.Merge(in.k)
182190
}
183191

@@ -196,7 +204,12 @@ func (l *Conf) Delete(key string) bool {
196204
// For example, if listA = [extension1, extension2] and listB = [extension1, extension3],
197205
// the resulting list will be [extension1, extension2, extension3].
198206
func (l *Conf) mergeAppend(in *Conf) error {
199-
return l.k.Load(confmap.Provider(in.ToStringMap(), ""), nil, koanf.WithMergeFunc(mergeAppend))
207+
err := l.k.Load(confmap.Provider(in.ToStringMap(), ""), nil, koanf.WithMergeFunc(mergeAppend))
208+
if err != nil {
209+
return err
210+
}
211+
l.isNil = l.isNil && in.isNil
212+
return nil
200213
}
201214

202215
// Sub returns new Conf instance representing a sub-config of this instance.
@@ -205,7 +218,9 @@ func (l *Conf) Sub(key string) (*Conf, error) {
205218
// Code inspired by the koanf "Cut" func, but returns an error instead of empty map for unsupported sub-config type.
206219
data := l.unsanitizedGet(key)
207220
if data == nil {
208-
return New(), nil
221+
c := New()
222+
c.isNil = true
223+
return c, nil
209224
}
210225

211226
switch v := data.(type) {
@@ -214,13 +229,23 @@ func (l *Conf) Sub(key string) (*Conf, error) {
214229
case expandedValue:
215230
if m, ok := v.Value.(map[string]any); ok {
216231
return NewFromStringMap(m), nil
232+
} else if v.Value == nil {
233+
// If the value is nil, return a new empty Conf.
234+
c := New()
235+
c.isNil = true
236+
return c, nil
217237
}
238+
// override data with the original value to make the error message more informative.
239+
data = v.Value
218240
}
219241

220242
return nil, fmt.Errorf("unexpected sub-config value kind for key:%s value:%v kind:%v", key, data, reflect.TypeOf(data).Kind())
221243
}
222244

223245
func (l *Conf) toStringMapWithExpand() map[string]any {
246+
if l.isNil {
247+
return nil
248+
}
224249
m := maps.Unflatten(l.k.All(), KeyDelimiter)
225250
return m
226251
}
@@ -231,6 +256,10 @@ func (l *Conf) toStringMapWithExpand() map[string]any {
231256
//
232257
// For example, for a Conf created from `foo: ${env:FOO}` and `FOO=123`
233258
// ToStringMap will return `map[string]any{"foo": 123}`.
259+
//
260+
// For any map `m`, `NewFromStringMap(m).ToStringMap() == m`.
261+
// In particular, if the Conf was created from a nil value,
262+
// ToStringMap will return map[string]any(nil).
234263
func (l *Conf) ToStringMap() map[string]any {
235264
return sanitize(l.toStringMapWithExpand()).(map[string]any)
236265
}
@@ -481,6 +510,9 @@ func unmarshalerEmbeddedStructsHookFunc() mapstructure.DecodeHookFuncValue {
481510
return nil, err
482511
}
483512
resultMap := conf.ToStringMap()
513+
if fromAsMap == nil && len(resultMap) > 0 {
514+
fromAsMap = make(map[string]any, len(resultMap))
515+
}
484516
for k, v := range resultMap {
485517
fromAsMap[k] = v
486518
}

confmap/confmap_test.go

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,28 @@ func TestEmbeddedMarshalerError(t *testing.T) {
562562
assert.EqualError(t, cfgMap.Unmarshal(tc), "error running encode hook: marshaling error")
563563
}
564564

565+
type B struct {
566+
String string `mapstructure:"string"`
567+
}
568+
569+
func (b *B) Unmarshal(conf *Conf) error {
570+
return conf.Unmarshal(b)
571+
}
572+
573+
type A struct {
574+
B `mapstructure:",squash"`
575+
}
576+
577+
func (a *A) Unmarshal(conf *Conf) error {
578+
return conf.Unmarshal(a)
579+
}
580+
581+
func TestUnmarshalerEmbeddedNilMap(t *testing.T) {
582+
cfg := A{}
583+
nilConf := NewFromStringMap(nil)
584+
require.NoError(t, nilConf.Unmarshal(&cfg))
585+
}
586+
565587
func TestUnmarshalerKeepAlreadyInitialized(t *testing.T) {
566588
cfgMap := NewFromStringMap(map[string]any{
567589
"next": map[string]any{
@@ -1151,3 +1173,159 @@ func TestMapMerge(t *testing.T) {
11511173
})
11521174
}
11531175
}
1176+
1177+
func TestConfIsNil(t *testing.T) {
1178+
const subKey = "foo"
1179+
testCases := []struct {
1180+
name string
1181+
input map[string]any
1182+
expectIsNil bool
1183+
subExpectNil bool
1184+
subExpectErr string
1185+
}{
1186+
{
1187+
name: "nil input",
1188+
input: nil,
1189+
expectIsNil: true,
1190+
subExpectNil: true,
1191+
},
1192+
{
1193+
name: "empty map",
1194+
input: map[string]any{},
1195+
expectIsNil: false,
1196+
subExpectNil: true,
1197+
},
1198+
{
1199+
name: "nil subkey",
1200+
input: map[string]any{subKey: nil},
1201+
expectIsNil: false,
1202+
subExpectNil: true,
1203+
},
1204+
{
1205+
name: "empty subkey",
1206+
input: map[string]any{subKey: map[string]any{}},
1207+
expectIsNil: false,
1208+
subExpectNil: false,
1209+
},
1210+
{
1211+
name: "non-empty map",
1212+
input: map[string]any{subKey: map[string]any{"bar": 42}},
1213+
expectIsNil: false,
1214+
subExpectNil: false,
1215+
},
1216+
{
1217+
name: "non-map subkey",
1218+
input: map[string]any{subKey: 123},
1219+
expectIsNil: false,
1220+
subExpectErr: "unexpected sub-config value kind",
1221+
},
1222+
}
1223+
1224+
for _, tc := range testCases {
1225+
t.Run(tc.name, func(t *testing.T) {
1226+
conf := NewFromStringMap(tc.input)
1227+
if tc.expectIsNil {
1228+
assert.Empty(t, conf.AllKeys())
1229+
assert.Equal(t, map[string]any(nil), conf.ToStringMap())
1230+
} else {
1231+
assert.NotEqual(t, map[string]any(nil), conf.ToStringMap())
1232+
}
1233+
1234+
sub, err := conf.Sub(subKey)
1235+
if tc.subExpectErr != "" {
1236+
assert.ErrorContains(t, err, tc.subExpectErr)
1237+
} else {
1238+
assert.NoError(t, err)
1239+
if tc.subExpectNil {
1240+
assert.Empty(t, sub.AllKeys())
1241+
assert.Equal(t, map[string]any(nil), sub.ToStringMap())
1242+
} else {
1243+
assert.NotEqual(t, map[string]any(nil), sub.ToStringMap())
1244+
}
1245+
}
1246+
})
1247+
}
1248+
}
1249+
1250+
func TestConfmapNilMerge(t *testing.T) {
1251+
tests := []struct {
1252+
name string
1253+
left map[string]any
1254+
right map[string]any
1255+
expected map[string]any
1256+
}{
1257+
{
1258+
name: "both nil",
1259+
left: nil,
1260+
right: nil,
1261+
expected: nil,
1262+
},
1263+
{
1264+
name: "left nil",
1265+
left: nil,
1266+
right: map[string]any{"key": "value"},
1267+
expected: map[string]any{"key": "value"},
1268+
},
1269+
{
1270+
name: "right nil",
1271+
left: map[string]any{"key": "value"},
1272+
right: nil,
1273+
expected: map[string]any{"key": "value"},
1274+
},
1275+
{
1276+
name: "both non-nil",
1277+
left: map[string]any{"key1": "value1"},
1278+
right: map[string]any{"key2": "value2"},
1279+
expected: map[string]any{"key1": "value1", "key2": "value2"},
1280+
},
1281+
{
1282+
name: "left empty, right non-empty",
1283+
left: map[string]any{},
1284+
right: map[string]any{"key": "value"},
1285+
expected: map[string]any{"key": "value"},
1286+
},
1287+
{
1288+
name: "left non-empty, right empty",
1289+
left: map[string]any{"key": "value"},
1290+
right: map[string]any{},
1291+
expected: map[string]any{"key": "value"},
1292+
},
1293+
{
1294+
name: "left nil, right empty",
1295+
left: nil,
1296+
right: map[string]any{},
1297+
expected: map[string]any{},
1298+
},
1299+
{
1300+
name: "left empty, right nil",
1301+
left: map[string]any{},
1302+
right: nil,
1303+
expected: map[string]any{},
1304+
},
1305+
}
1306+
for _, test := range tests {
1307+
t.Run(test.name, func(t *testing.T) {
1308+
leftConf := NewFromStringMap(test.left)
1309+
assert.Equal(t, test.left, leftConf.ToStringMap())
1310+
rightConf := NewFromStringMap(test.right)
1311+
assert.Equal(t, test.right, rightConf.ToStringMap())
1312+
1313+
err := leftConf.Merge(rightConf)
1314+
require.NoError(t, err)
1315+
1316+
assert.Equal(t, test.expected, leftConf.ToStringMap())
1317+
})
1318+
1319+
t.Run(test.name+"merge append", func(t *testing.T) {
1320+
leftConf := NewFromStringMap(test.left)
1321+
assert.Equal(t, test.left, leftConf.ToStringMap())
1322+
rightConf := NewFromStringMap(test.right)
1323+
assert.Equal(t, test.right, rightConf.ToStringMap())
1324+
1325+
err := leftConf.mergeAppend(rightConf)
1326+
require.NoError(t, err)
1327+
1328+
assert.Equal(t, test.expected, leftConf.ToStringMap())
1329+
})
1330+
}
1331+
}

0 commit comments

Comments
 (0)