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