defmodule Toolbox.Utils.Map do
@moduledoc """
A set of utility functions for maps.
"""
@doc """
Follows the `path` in the (deep) map, defaulting to `default` if some of the
objects on the path is missing.
## Example
iex> object = %{"some" => %{"deep" => %{"object" => "data"}}}
...> Toolbox.Utils.Map.get_path(object, ["some", "deep", "object"])
"data"
iex> Toolbox.Utils.Map.get_path(object, ["non-existent"], "default")
"default"
"""
@spec get_path(map(), [binary() | atom()], any()) :: any()
def get_path(map, path, default \\ nil)
def get_path(map, [_ | _], default) when not is_map(map), do: default
def get_path(map, [el], default) do
Map.get(map, el, default)
end
def get_path(obj, [], default) do
obj || default
end
def get_path(map, [el | rest], default) do
get_path(Map.get(map, el, %{}), rest, default)
end
@doc """
Deeply updates `left` map by `right` map.
Existing keys from `left` map not contained in `right` map are unchanged. New keys from `right`
map are added. If the key is present in both maps and both values are maps, maps are deeply
merges. If the key is present in both maps and value of one of them is not a map, value of key
in `left` map is replaced by value in `right` map.
## Example
iex> Toolbox.Utils.Map.deep_merge(%{a: 1}, %{b: 2})
%{a: 1, b: 2}
iex> Toolbox.Utils.Map.deep_merge(%{a: %{b: 1}}, %{a: %{c: 3}})
%{a: %{b: 1, c: 3}}
iex> Toolbox.Utils.Map.deep_merge(%{a: %{b: %{c: 1}}}, %{a: %{b: %{d: 2}}})
%{a: %{b: %{c: 1, d: 2}}}
iex> Toolbox.Utils.Map.deep_merge(%{a: 1}, %{a: %{b: 2}})
%{a: %{b: 2}}
iex> Toolbox.Utils.Map.deep_merge(%{a: %{b: 1}}, %{a: 2})
%{a: 2}
iex> Toolbox.Utils.Map.deep_merge(%{a: 1}, %{a: 2})
%{a: 2}
"""
@spec deep_merge(map, map) :: map
def deep_merge(left, right) do
Map.merge(left, right, &deep_resolve/3)
end
@doc """
Removes key represented as path (list of keys) deeply in map if it exists.
## Example
iex> Toolbox.Utils.Map.deep_remove(%{a: 1, b: 2}, [])
%{a: 1, b: 2}
iex> Toolbox.Utils.Map.deep_remove(%{a: 1, b: 2}, [:a])
%{b: 2}
iex> Toolbox.Utils.Map.deep_remove(%{a: 1, b: 2}, [:a, :aa])
%{a: 1, b: 2}
iex> Toolbox.Utils.Map.deep_remove(%{a: %{aa: 1, ab: 2}, b: 2}, [:a, :aa])
%{a: %{ab: 2}, b: 2}
iex> Toolbox.Utils.Map.deep_remove(%{a: %{aa: 1, ab: 2}, b: 2}, [:a, :aa, :aaa])
%{a: %{aa: 1, ab: 2}, b: 2}
"""
@spec deep_remove(map, list) :: map
def deep_remove(map, []) do
map
end
def deep_remove(map, path) do
if deep_exists(map, path) do
{_, result} = pop_in(map, path)
result
else
map
end
end
@doc """
Creates deep map with `value` on key represented by `path`. If `path` is empty,
it returns given `value`.
## Examples
iex> Toolbox.Utils.Map.deep_create([], :some_value)
:some_value
iex> Toolbox.Utils.Map.deep_create(["a", :b], :other_value)
%{"a" => %{:b => :other_value}}
"""
@spec deep_create(path :: list, value :: any) :: map | any
def deep_create([], value) do
value
end
def deep_create([key | keys], value) do
%{key => deep_create(keys, value)}
end
@doc """
Subtracts map from another map, the leaf-paths from the second map are
removed from the first.
## Examples
iex> Toolbox.Utils.Map.subtract(%{a: 1, b: 2}, %{a: true})
%{b: 2}
iex> Toolbox.Utils.Map.subtract(%{a: 1}, %{a: %{b: %{c: true}}})
%{a: 1}
iex> Toolbox.Utils.Map.subtract(
...> %{a: %{b: %{c: "1"}, d: "2"}, e: "3"},
...> %{a: %{b: %{c: true}}, e: true}
...> )
%{a: %{b: %{}, d: "2"}}
iex> Toolbox.Utils.Map.subtract(
...> %{"a" => 1, "b" => %{"c" => true}},
...> %{"b" => %{"c" => true}}
...> )
%{"a" => 1, "b" => %{}}
iex> Toolbox.Utils.Map.subtract(%{a: 1, b: 2}, %{c: 2, d: 3})
%{a: 1, b: 2}
iex> Toolbox.Utils.Map.subtract(%{a: %{b: 1}}, %{a: true})
%{}
"""
def subtract(map, to_delete) when is_map(map) and is_map(to_delete) do
# get the keys which are leaves - they should be removed
delete_keys =
to_delete
|> Map.keys()
|> Enum.reject(&is_map(to_delete[&1]))
# get keys that are not leaves - they should be recursively checked
follow_keys =
to_delete
|> Map.keys()
|> Enum.reject(&(&1 in delete_keys))
map =
Enum.reduce(delete_keys, map, fn key, data ->
Map.delete(data, key)
end)
Enum.reduce(follow_keys, map, fn key, data ->
Map.put(data, key, subtract(data[key], to_delete[key]))
end)
end
def subtract(other, _), do: other
@doc """
Converts map from flat to nested map.
The input map must be flat with array keys, which represent the path to the respective values.
The corresponding nested map is then constructed.
Also see `to_flat_map/1` which performs the reverse operation.
## Examples
iex> Toolbox.Utils.Map.from_flat_map(%{
...> ["contact", "name"] => "Jored",
...> ["contact", "address", "city"] => "Lrno",
...> ["active", "ui"] => true,
...> ["active", "backend"] => false
...> })
%{
"contact" => %{
"name" => "Jored",
"address" => %{
"city" => "Lrno"
}
},
"active" => %{
"ui" => true,
"backend" => false
}
}
iex> Toolbox.Utils.Map.from_flat_map(%{
...> [:very, :very, :very, :very, :very, :very, :deep] => "object",
...> [:very, :very, :very, :very, :very, :very, :nested] => "map"
...> })
%{
very: %{
very: %{
very: %{
very: %{
very: %{
very: %{
deep: "object",
nested: "map"
}
}
}
}
}
}
}
iex> Toolbox.Utils.Map.from_flat_map(%{})
%{}
iex> Toolbox.Utils.Map.from_flat_map(Toolbox.Utils.Map.to_flat_map(%{
...> "contact" => %{
...> "name" => "Jonas",
...> "address" => %{"city" => "Brnp"}},
...> "favorites" => %{
...> "food" => %{
...> 1 => ["cornflakes", "corn"],
...> 5 => ["pizza"],
...> 8 => ["pasta"]
...> }
...> }
...> }
...> ))
%{
"contact" => %{
"name" => "Jonas",
"address" => %{"city" => "Brnp"}},
"favorites" => %{
"food" => %{
1 => ["cornflakes", "corn"],
5 => ["pizza"],
8 => ["pasta"]
}
}
}
"""
def from_flat_map(flat_map) do
flat_map
|> Enum.group_by(
fn {[first_key | _], _} -> first_key end,
fn {[_first | rest_key], value} -> {rest_key, value} end
)
|> Enum.into(%{}, fn {first_key, values} ->
finishing_value =
values
|> Enum.find(fn
{[], _value} -> true
_ -> false
end)
case finishing_value do
{[], value} ->
{first_key, value}
_ ->
{first_key, from_flat_map(values)}
end
end)
end
@doc """
Converts regular nested map to flat map.
The input map can be any map - arbitrarily nested, the output is a flat map where keys are arrays
representing the path to the respective value.
Also see `from_flat_map/1` which performs the reverse operation.
## Examples
iex> Toolbox.Utils.Map.to_flat_map(%{
...> "contact" => %{
...> "name" => "Kert",
...> "address" => %{"city" => "Krno"}
...> },
...> "status" => %{"ui" => %{"active" => true},
...> "backend" => %{"fluent" => false}}
...> })
%{
["contact", "name"] => "Kert",
["contact", "address", "city"] => "Krno",
["status", "ui", "active"] => true,
["status", "backend", "fluent"] => false
}
iex> Toolbox.Utils.Map.to_flat_map(%{good: %{1 => :nice}, very: %{deep: %{%{ish: :map} => :askey}}})
%{
[:good, 1] => :nice,
[:very, :deep, %{ish: :map}] => :askey
}
iex> Toolbox.Utils.Map.to_flat_map(%{"regular" => "map"})
%{
["regular"] => "map"
}
iex> Toolbox.Utils.Map.to_flat_map(%{})
%{}
"""
def to_flat_map(map) when is_map(map) do
map
|> json_paths()
|> Enum.into(%{}, &{&1, get_in(map, &1)})
end
def to_flat_map(_), do: %{}
@doc """
Extracts all (json-like) paths in the map.
Given any artitrarily nested map, the function returns all paths the map contains.
The output is **not** sorted in a predictable way.
## Examples
iex> Toolbox.Utils.Map.json_paths(%{
...> "contact" => %{
...> "name" => "Trest",
...> "phone" => 605554171,
...> "address" => %{"city" => "Brno"}
...> },
...> "active" => false
...> }) |> Enum.sort()
[
["active"],
["contact", "address", "city"],
["contact", "name"],
["contact", "phone"],
]
iex> Toolbox.Utils.Map.json_paths(%{"a" => %{1 => %{%{"cool" => "stuff"} => %{atom: :work_too}}}})
[
["a", 1, %{"cool" => "stuff"}, :atom]
]
iex> Toolbox.Utils.Map.json_paths(%{})
[]
"""
def json_paths(map) when is_map(map) do
map
|> Enum.map(fn {key, value} ->
case json_paths(value) do
[] -> [key]
paths -> Enum.map(paths, &[key | &1])
end
end)
|> path_process()
end
def json_paths(_other), do: []
defp path_process(list) when is_list(list) do
list
|> Enum.reduce([], fn x, acc ->
if Enum.all?(x, &is_list/1) do
x ++ acc
else
[x | acc]
end
end)
end
defp deep_exists(_, []) do
true
end
defp deep_exists(map, [key | path]) when is_map(map) do
case Map.fetch(map, key) do
{:ok, value} -> deep_exists(value, path)
:error -> false
end
end
defp deep_exists(_, _) do
false
end
# Key exists in both maps, and both values are maps as well.
# These can be merged recursively.
defp deep_resolve(_key, %{} = left, %{} = right) do
deep_merge(left, right)
end
# Key exists in both maps, but at least one of the values is
# NOT a map. We fall back to standard merge behavior, preferring
# the value on the right.
defp deep_resolve(_key, _left, right) do
right
end
end