Skip to content

Commit

Permalink
Add mapping.Merge(other, opts...) (#8)
Browse files Browse the repository at this point in the history
* Add merge feature

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>

* Add slice append

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>

---------

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
  • Loading branch information
kke authored Jan 3, 2025
1 parent b607000 commit 2d4197b
Show file tree
Hide file tree
Showing 2 changed files with 231 additions and 0 deletions.
110 changes: 110 additions & 0 deletions mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,116 @@ func (m *Mapping) Dup() Mapping {
return newMap
}

// HasKey checks if the key exists in the Mapping.
func (m *Mapping) HasKey(key string) bool {
_, ok := (*m)[key]
return ok
}

// HasMapping checks if the key exists in the Mapping and is a Mapping.
func (m *Mapping) HasMapping(key string) bool {
v, ok := (*m)[key]
if !ok {
return false
}
_, ok = v.(Mapping)
return ok
}

// MergeOptions are used to configure the Merge function.
type MergeOptions struct {
// Overwrite existing values in the target map
Overwrite bool
// Nillify removes keys from the target map if the value is nil in the source map
Nillify bool
// Append slices instead of overwriting them
Append bool
}

type MergeOption func(*MergeOptions)

// WithOverwrite sets the Overwrite option to true.
func WithOverwrite() MergeOption {
return func(o *MergeOptions) {
o.Overwrite = true
}
}

// WithNillify sets the Nillify option to true.
func WithNillify() MergeOption {
return func(o *MergeOptions) {
o.Nillify = true
}
}

// WithAppend sets the Append option to true.
func WithAppend() MergeOption {
return func(o *MergeOptions) {
o.Append = true
}
}

func sliceMerge(target any, source any) (any, error) {
targetVal := reflect.ValueOf(target)
sourceVal := reflect.ValueOf(source)

if targetVal.Kind() != reflect.Slice || sourceVal.Kind() != reflect.Slice {
return nil, fmt.Errorf("both target and source must be slices")
}

targetElemType := targetVal.Type().Elem()
sourceElemType := sourceVal.Type().Elem()

if !sourceElemType.AssignableTo(targetElemType) &&
!(targetElemType.Kind() == reflect.Interface && sourceElemType.ConvertibleTo(targetElemType)) {
return nil, fmt.Errorf("incompatible slice element types: %s and %s", targetElemType, sourceElemType)
}

// Combine slices
combined := reflect.MakeSlice(targetVal.Type(), 0, targetVal.Len()+sourceVal.Len())
combined = reflect.AppendSlice(combined, targetVal)
combined = reflect.AppendSlice(combined, sourceVal)

return combined.Interface(), nil
}

// Merge deep merges the source map into the target map. Regardless of options, Mappings will be merged recursively.
func (m Mapping) Merge(source Mapping, opts ...MergeOption) {
options := MergeOptions{}
for _, opt := range opts {
opt(&options)
}
for k, v := range source {
switch v := v.(type) {
case Mapping:
if !m.HasKey(k) {
m[k] = v.Dup()
} else if m.HasMapping(k) {
m.DigMapping(k).Merge(v, opts...)
} else if options.Overwrite {
m[k] = v.Dup()
}
case nil:
if options.Nillify {
m[k] = nil
}
default:
if m.HasKey(k) && options.Append {
if newSlice, err := sliceMerge(m[k], v); err == nil {
m[k] = newSlice
continue
}
if options.Overwrite {
m[k] = deepCopy(v)
}
}
if !m.HasKey(k) || options.Overwrite {
m[k] = deepCopy(v)
}
}
}
}

// deepCopy performs a deep copy of the value using reflection
func deepCopy(value any) any {
if value == nil {
Expand Down
121 changes: 121 additions & 0 deletions mapping_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,21 @@ import (
)

func mustEqualString(t *testing.T, expected, actual string) {
t.Helper()
if expected != actual {
t.Errorf("Expected %v, got %v", expected, actual)
}
}

func mustBeNil(t *testing.T, actual any) {
t.Helper()
if actual != nil {
t.Errorf("Expected nil, got %v", actual)
}
}

func mustEqual(t *testing.T, expected, actual any) {
t.Helper()
if expected != actual {
t.Errorf("Expected %v, got %v", expected, actual)
}
Expand Down Expand Up @@ -168,6 +171,124 @@ func TestUnmarshalJSONWithSliceOfMaps(t *testing.T) {
mustEqual(t, "baz", obj["bar"])
}

func TestMerge(t *testing.T) {
t.Run("default", func(t *testing.T) {
m := dig.Mapping{
"foo": "bar",
"bar": "baz",
"nested": dig.Mapping{
"foo": "bar",
},
}
other := dig.Mapping{
"foo": "baz",
"bar": nil,
"nested": dig.Mapping{
"foo": "baz",
"bar": "foo",
},
}
m.Merge(other)
mustEqualString(t, "bar", m.DigString("foo"))
mustEqualString(t, "bar", m.DigString("nested", "foo"))
mustEqualString(t, "foo", m.DigString("nested", "bar"))
mustEqualString(t, "baz", m.DigString("bar"))
})
t.Run("overwrite", func(t *testing.T) {
m := dig.Mapping{
"foo": "bar",
"bar": "baz",
"nested": dig.Mapping{
"foo": "bar",
},
}
other := dig.Mapping{
"foo": "baz",
"bar": nil,
"nested": dig.Mapping{
"foo": "baz",
"bar": "foo",
},
}
m.Merge(other, dig.WithOverwrite())
mustEqualString(t, "baz", m.DigString("foo"))
mustEqualString(t, "baz", m.DigString("bar"))
mustEqualString(t, "baz", m.DigString("nested", "foo"))
mustEqualString(t, "foo", m.DigString("nested", "bar"))
})
t.Run("nillify", func(t *testing.T) {
m := dig.Mapping{
"foo": "bar",
"bar": "baz",
"nested": dig.Mapping{
"foo": "bar",
},
}
other := dig.Mapping{
"foo": "baz",
"bar": nil,
"nested": dig.Mapping{
"foo": nil,
"bar": "foo",
},
}
m.Merge(other, dig.WithNillify())
mustEqualString(t, "bar", m.DigString("foo"))
mustBeNil(t, m.Dig("bar"))
mustBeNil(t, m.Dig("nested", "foo"))
mustEqualString(t, "foo", m.DigString("nested", "bar"))
})
t.Run("overwrite+nillify", func(t *testing.T) {
m := dig.Mapping{
"foo": "bar",
"bar": "baz",
"nested": dig.Mapping{
"foo": "bar",
},
}
other := dig.Mapping{
"foo": "baz",
"bar": nil,
"nested": dig.Mapping{
"foo": nil,
"bar": "foo",
},
}
m.Merge(other, dig.WithOverwrite(), dig.WithNillify())
mustEqualString(t, "baz", m.DigString("foo"))
mustBeNil(t, m.Dig("bar"))
mustBeNil(t, m.Dig("nested", "foo"))
mustEqualString(t, "foo", m.DigString("nested", "bar"))
})
t.Run("slice append", func(t *testing.T) {
m := dig.Mapping{
"foo": []string{"bar"},
"bar": []string{"baz"},
"map": []dig.Mapping{
{"foo": "bar"},
},
}
other := dig.Mapping{
"foo": []string{"baz"},
"bar": nil,
"map": []dig.Mapping{
{"foo": "baz"},
},
}
m.Merge(other, dig.WithAppend())
foo := m.Dig("foo").([]string)
mustEqual(t, 2, len(foo))
mustEqual(t, "bar", foo[0])
mustEqual(t, "baz", foo[1])
mustEqual(t, "baz", m.Dig("bar").([]string)[0])
mustEqual(t, 1, len(m.Dig("bar").([]string)))
mapping := m.Dig("map").([]dig.Mapping)
mustEqual(t, 2, len(mapping))
mustEqual(t, "bar", mapping[0]["foo"])
mustEqual(t, "baz", mapping[1]["foo"])
})
}

func ExampleMapping_Dig() {
h := dig.Mapping{
"greeting": dig.Mapping{
Expand Down

0 comments on commit 2d4197b

Please sign in to comment.