Skip to content

Commit b8830dd

Browse files
authored
[confmap] Maintain nil slice values when marshaling and unmarshaling (open-telemetry#11882)
Expands on open-telemetry#11755 to show how we could test for a 1:1 mapping between map[string]any <-> `confmap.Conf` <-> Config structs for `[]any(nil)` and `[]any{}` slices.
1 parent b741c48 commit b8830dd

File tree

8 files changed

+90
-37
lines changed

8 files changed

+90
-37
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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: Maintain nil values when marshaling or unmarshaling nil slices
11+
12+
# One or more tracking issues or pull requests related to the change
13+
issues: [11882]
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+
Previously, nil slices were converted to empty lists, which are semantically different
20+
than a nil slice. This change makes this conversion more consistent when encoding
21+
or decoding config, and these values are now maintained.
22+
23+
# Optional: The change log or logs in which this entry should be included.
24+
# e.g. '[user]' or '[user, api]'
25+
# Include 'user' if the change is relevant to end users.
26+
# Include 'api' if there is a change to a library API.
27+
# Default: '[user]'
28+
change_logs: []

confmap/confmap.go

Lines changed: 4 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@ func sanitizeExpanded(a any, useOriginal bool) any {
141141
return c
142142
case []any:
143143
var newSlice []any
144+
if m == nil {
145+
return newSlice
146+
}
147+
newSlice = []any{}
144148
for _, e := range m {
145149
newSlice = append(newSlice, sanitizeExpanded(e, useOriginal))
146150
}
@@ -236,7 +240,6 @@ func decodeConfig(m *Conf, result any, errorUnused bool, skipTopLevelUnmarshaler
236240
// after the main unmarshaler hook is called,
237241
// we unmarshal the embedded structs if present to merge with the result:
238242
unmarshalerEmbeddedStructsHookFunc(),
239-
zeroSliceHookFunc(),
240243
),
241244
}
242245
decoder, err := mapstructure.NewDecoder(dc)
@@ -531,37 +534,6 @@ type Marshaler interface {
531534
Marshal(component *Conf) error
532535
}
533536

534-
// This hook is used to solve the issue: https://github.com/open-telemetry/opentelemetry-collector/issues/4001
535-
// We adopt the suggestion provided in this issue: https://github.com/mitchellh/mapstructure/issues/74#issuecomment-279886492
536-
// We should empty every slice before unmarshalling unless user provided slice is nil.
537-
// Assume that we had a struct with a field of type slice called `keys`, which has default values of ["a", "b"]
538-
//
539-
// type Config struct {
540-
// Keys []string `mapstructure:"keys"`
541-
// }
542-
//
543-
// The configuration provided by users may have following cases
544-
// 1. configuration have `keys` field and have a non-nil values for this key, the output should be overridden
545-
// - for example, input is {"keys", ["c"]}, then output is Config{ Keys: ["c"]}
546-
//
547-
// 2. configuration have `keys` field and have an empty slice for this key, the output should be overridden by empty slices
548-
// - for example, input is {"keys", []}, then output is Config{ Keys: []}
549-
//
550-
// 3. configuration have `keys` field and have nil value for this key, the output should be default config
551-
// - for example, input is {"keys": nil}, then output is Config{ Keys: ["a", "b"]}
552-
//
553-
// 4. configuration have no `keys` field specified, the output should be default config
554-
// - for example, input is {}, then output is Config{ Keys: ["a", "b"]}
555-
func zeroSliceHookFunc() mapstructure.DecodeHookFuncValue {
556-
return func(from reflect.Value, to reflect.Value) (any, error) {
557-
if to.CanSet() && to.Kind() == reflect.Slice && from.Kind() == reflect.Slice {
558-
to.Set(reflect.MakeSlice(to.Type(), from.Len(), from.Cap()))
559-
}
560-
561-
return from.Interface(), nil
562-
}
563-
}
564-
565537
type moduleFactory[T any, S any] interface {
566538
Create(s S) T
567539
}

confmap/confmap_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,50 @@ func TestZeroSliceHookFunc(t *testing.T) {
689689
}
690690
}
691691

692+
func TestNilValuesUnchanged(t *testing.T) {
693+
type structWithSlices struct {
694+
Strings []string `mapstructure:"strings"`
695+
}
696+
697+
slicesStruct := &structWithSlices{}
698+
699+
nilCfg := map[string]any{
700+
"strings": []any(nil),
701+
}
702+
nilConf := NewFromStringMap(nilCfg)
703+
err := nilConf.Unmarshal(slicesStruct)
704+
require.NoError(t, err)
705+
706+
confFromStruct := New()
707+
err = confFromStruct.Marshal(slicesStruct)
708+
require.NoError(t, err)
709+
710+
require.Equal(t, nilCfg, nilConf.ToStringMap())
711+
require.EqualValues(t, nilConf.ToStringMap(), confFromStruct.ToStringMap())
712+
}
713+
714+
func TestEmptySliceUnchanged(t *testing.T) {
715+
type structWithSlices struct {
716+
Strings []string `mapstructure:"strings"`
717+
}
718+
719+
slicesStruct := &structWithSlices{}
720+
721+
nilCfg := map[string]any{
722+
"strings": []any{},
723+
}
724+
nilConf := NewFromStringMap(nilCfg)
725+
err := nilConf.Unmarshal(slicesStruct)
726+
require.NoError(t, err)
727+
728+
confFromStruct := New()
729+
err = confFromStruct.Marshal(slicesStruct)
730+
require.NoError(t, err)
731+
732+
require.Equal(t, nilCfg, nilConf.ToStringMap())
733+
require.EqualValues(t, nilConf.ToStringMap(), confFromStruct.ToStringMap())
734+
}
735+
692736
type C struct {
693737
Modifiers []string `mapstructure:"modifiers"`
694738
}

confmap/confmaptest/configtest_test.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,16 @@ func TestLoadConf(t *testing.T) {
3030
assert.Equal(t, map[string]any{"floating": 3.14}, cfg.ToStringMap())
3131
}
3232

33-
func TestToStringMapSanitizeEmptySlice(t *testing.T) {
33+
func TestToStringMapSanitizeNil(t *testing.T) {
34+
cfg, err := LoadConf(filepath.Join("testdata", "nil.yaml"))
35+
require.NoError(t, err)
36+
assert.Equal(t, map[string]any{"slice": nil}, cfg.ToStringMap())
37+
}
38+
39+
func TestToStringMapEmptySlice(t *testing.T) {
3440
cfg, err := LoadConf(filepath.Join("testdata", "empty-slice.yaml"))
3541
require.NoError(t, err)
36-
var nilSlice []any
37-
assert.Equal(t, map[string]any{"slice": nilSlice}, cfg.ToStringMap())
42+
assert.Equal(t, map[string]any{"slice": []any{}}, cfg.ToStringMap())
3843
}
3944

4045
func TestValidateProviderScheme(t *testing.T) {
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
slice: [] # empty slices are sanitized to nil in ToStringMap
1+
slice: []

confmap/confmaptest/testdata/nil.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
slice:

confmap/internal/mapstructure/encoder.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ func (e *Encoder) encodeSlice(value reflect.Value) (any, error) {
141141
Kind: value.Kind(),
142142
}
143143
}
144+
if value.IsNil() {
145+
return []any(nil), nil
146+
}
144147
result := make([]any, value.Len())
145148
for i := 0; i < value.Len(); i++ {
146149
var err error

service/config_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ func TestConfmapMarshalConfig(t *testing.T) {
124124
"host": "localhost",
125125
"port": 8888,
126126
"with_resource_constant_labels": map[string]any{
127-
"included": []any(nil),
127+
"included": []any{},
128128
},
129129
"without_scope_info": true,
130130
"without_type_suffix": true,

0 commit comments

Comments
 (0)