diff --git a/internal/concat.go b/internal/concat.go index fd9b8abc5..5a461fcfd 100644 --- a/internal/concat.go +++ b/internal/concat.go @@ -137,18 +137,30 @@ func concatMaps(ms reflect.Value) (reflect.Value, error) { vals := rms.MapIndex(key) anyVals := vals.Interface().([]any) - if len(anyVals) == 1 { - ele := anyVals[0] - if ele == nil { // we cannot SetMapIndex with nil because it will delete the key - ret.SetMapIndex(key, reflect.Zero(typ.Elem())) - continue + + // nil represents an absent/empty value for this key in a chunk. + // Filter nils out so concat is order-independent and toSliceValue + // never receives a nil element (reflect.TypeOf(nil) returns a nil + // reflect.Type, which makes reflect.SliceOf(nil) panic). + nonNil := make([]any, 0, len(anyVals)) + for _, v := range anyVals { + if v != nil { + nonNil = append(nonNil, v) } + } - ret.SetMapIndex(key, reflect.ValueOf(ele)) + switch len(nonNil) { + case 0: + // all chunks had a nil value for this key; preserve the key with a zero value. + // we cannot SetMapIndex with nil because it will delete the key. + ret.SetMapIndex(key, reflect.Zero(typ.Elem())) + continue + case 1: + ret.SetMapIndex(key, reflect.ValueOf(nonNil[0])) continue } - v, err := toSliceValue(anyVals) + v, err := toSliceValue(nonNil) if err != nil { return reflect.Value{}, err } diff --git a/internal/concat_test.go b/internal/concat_test.go index 12b4837bc..408821779 100644 --- a/internal/concat_test.go +++ b/internal/concat_test.go @@ -49,4 +49,62 @@ func TestConcat(t *testing.T) { }, }, m) }) + + // nil and non-nil for the same key across chunks must concat order- + // independently and must never panic. See issue #1181. + t.Run("concat same key: nil then value does not panic", func(t *testing.T) { + m, err := ConcatItems([]map[string]any{ + {"a": nil}, + {"a": "str"}, + }) + assert.Nil(t, err) + assert.Equal(t, map[string]any{"a": "str"}, m) + }) + + t.Run("concat same key: value then nil is order-independent", func(t *testing.T) { + m, err := ConcatItems([]map[string]any{ + {"a": "str"}, + {"a": nil}, + }) + assert.Nil(t, err) + assert.Equal(t, map[string]any{"a": "str"}, m) + }) + + t.Run("concat same key: all nil preserves nil", func(t *testing.T) { + m, err := ConcatItems([]map[string]any{ + {"a": nil}, + {"a": nil}, + }) + assert.Nil(t, err) + assert.Equal(t, map[string]any{"a": nil}, m) + }) + + t.Run("concat same key: nil mixed with multiple non-nil strings", func(t *testing.T) { + m, err := ConcatItems([]map[string]any{ + {"a": nil}, + {"a": "foo"}, + {"a": "bar"}, + {"a": nil}, + }) + assert.Nil(t, err) + assert.Equal(t, map[string]any{"a": "foobar"}, m) + }) + + t.Run("concat same key: nested map with nil then value", func(t *testing.T) { + m, err := ConcatItems([]map[string]any{ + {"a": map[string]any{"x": nil}}, + {"a": map[string]any{"x": "v"}}, + }) + assert.Nil(t, err) + assert.Equal(t, map[string]any{"a": map[string]any{"x": "v"}}, m) + }) + + t.Run("concat same key: nested map all nil preserves nil", func(t *testing.T) { + m, err := ConcatItems([]map[string]any{ + {"a": map[string]any{"x": nil}}, + {"a": map[string]any{"x": nil}}, + }) + assert.Nil(t, err) + assert.Equal(t, map[string]any{"a": map[string]any{"x": nil}}, m) + }) }