Skip to content

Deep (recursive) merge for maps, keywords and others in Elixir

License

Notifications You must be signed in to change notification settings

PragTob/deep_merge

Repository files navigation

DeepMerge Hex Version docs CI Coverage Status Total Download License

Provides functionality for "deep merging" maps and keyword lists in elixir, which is if during merging both values at the same key are maps/keyword lists merge them recursively. This is done via a protocol so can be extended for your own structs/data types if needbe.

iex> DeepMerge.deep_merge(%{a: 1, b: [x: 10, y: 9]}, %{b: [y: 20, z: 30], c: 4})
%{a: 1, b: [x: 10, y: 20, z: 30], c: 4}

This functionality can be useful for instance when merging a default configuration with a user supplied custom configuration:

DeepMerge.deep_merge(default_config, custom_config) # => merged configuration

Further features include:

  • It handles both maps and keyword lists
  • It does not merge structs or maps with structs…
  • …but you can implement the simple DeepMerge.Resolver protocol for types/structs of your choice to also make them deep mergable (there is also a default implementation)
  • a deep_merge/3 variant that gets a function similar to Map.merge/3 to modify the merging behavior, for instance in case you don't want keyword lists to be merged or you want all lists to be appended

I wanted this to be a feature of Elixir itself, however the proposal was rejected hence this library exists :)

Installation

Add :deep_merge to your list of dependencies in mix.exs:

def deps do
  [
    {:deep_merge, "~> 1.0"}
  ]
end

General Usage - deep_merge/2

Using this library is quite simple (and you might also want to look at the hexdocs) - just pass two structures to be deep merged into DeepMerge.deep_merge/2:

iex> DeepMerge.deep_merge(%{a: 1, b: %{x: 10, y: 9}}, %{b: %{y: 20, z: 30}, c: 4})
%{a: 1, b: %{x: 10, y: 20, z: 30}, c: 4}

iex> DeepMerge.deep_merge([a: 1, b: [x: 10, y: 9]], [b: [y: 20, z: 30], c: 4])
[a: 1, b: [x: 10, y: 20, z: 30], c: 4]

It is worth noting that structs are not deeply merged - not with each other and not with normal maps. This is because structs, while internally a map, are more like their own data types and therefore should not be deeply merged... unless you implement the protocol provided by this library for them.

Customization via protocols

What is merged and how is defined by implementing the DeepMerge.Resolver protocol. This library implements it for Map, List and falls back to Any (where the right hand side value/override is taken).

If you want your own struct to be deeply merged you can simply @derive the protocol:

defmodule Derived do
  @derive [DeepMerge.Resolver]
  defstruct [:attrs]
end

If you want to change the deep merge for a custom struct you can do so. An example implementation might look like this if you want to deeply merge your struct but only against non nil values (because all keys are always there) if you merge against the same struct (but still merge with maps):

defimpl DeepMerge.Resolver, for: MyStruct do
  def resolve(original, override = %MyStruct{}, resolver) do
    cleaned_override =
      override
      |> Map.from_struct()
      |> Enum.reject(fn {_key, value} -> is_nil(value) end)
      |> Map.new()

    Map.merge(original, cleaned_override, resolver)
  end

  def resolve(original, override, resolver) when is_map(override) do
    Map.merge(original, override, resolver)
  end
end

In this implementation, MyStruct structs are merged with other MyStruct structs, omitting nil values. The arguments passed to resolve are the original value (left hand side) and the override value (right hand side, which would normally replace the original). The third parameter is a resolver function which you can pass to Map.merge/3/Keyword.merge/3 to continue the deep merge.

Customization via deep_merge/3

There is another deep merge variant that is a bit like Map.merge/3 as it takes an additional function which you can use to alter the deep merge behavior:

iex> resolver = fn
...> (_, original, override) when is_list(original) and is_list(override) ->
...>   override
...> (_, _original, _override) ->
...>   DeepMerge.continue_deep_merge
...> end
iex> DeepMerge.deep_merge(%{a: %{b: 1}, c: [d: 1]},
...> %{a: %{z: 5}, c: [x: 0]}, resolver)
%{a: %{b: 1, z: 5}, c: [x: 0]}

This function is called for a given merge conflict with the key where it occurred and the two conflicting values. Whatever value is returned in this function is inserted at that point in the structure - unless DeepMerge.continue_deep_merge is returned in which case the deep merge continues as normal.

When would you want to use this versus a protocol? The best use case I can think of is when you want to alter behavior for which a protocol is already implemented or if you care about specific keys.

In the example above the behavior is changed so that keyword lists are not deep_merged (if they were the result would contain c: [d: 1, x:0]), but maps still are if that's what you are looking for.

Do I really need a library for this?

Well not necessarily, no. There are very simple implementations for maps that use Map.merge/3.

There are subtle things that can be missed there though (and I missed the first time around):

  • the most simple implementation also merges structs which is not always what you want
  • for keyword lists on the other hand you gotta be careful that you don't accidentally merge keyword lists with lists as that's currently possible
  • you might want to further adopt the implementation, in benchee we have 2 custom implementations of the protocol due to our needs

This library takes care of those problems and will take care of further problems/edge cases should they appear so you can focus on your business logic.

At the same time it offers extension mechanisms through protocols and a function in deep_merge/3. So, it should be adjustable to your use case and if not please open an issue :)

Performance

You can check out a benchmark and its results.

The TLDR; is this: In the sample it is about 30 times slower than Map.merge/2 - however, less than twice as slow as calling Map.merge/3 with simple overriding behaviour (same behaviour as Map.merge/2). This is because Map.merge/2 is highly optimized, but we need to do much more than the Map.merge/3 sample in the benchmark so I think it's a very passable result. We're still talking about a couple of μs.

Considered feature-complete

Unless you come with great feature ideas of course ;) So if you come here and there are no recent commits, don't worry - there are no known bugs or whatever. It's a small little library that does its job.

Copyright and License

Copyright (c) 2016 Tobias Pfeiffer

This library is MIT licensed. See the LICENSE for details.