lib/toolbox/utils/map.ex

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