defmodule Iteraptor do @moduledoc """ `Iteraptor` makes complicated nested structures (currently `map`s and `list`s) iteration easier. """ @joiner "." @doc """ Build a flatmap out of nested structure, concatenating the names of keys. Example: %{a: %{b: %{c: 42, d: [nil, 42]}, e: [:f, 42]}} |> Iteraptor.to_flatmap %{"a.b.c": 42, "a.b.d.0": nil, "a.b.d.1": 42, "a.e.0": :f, "a.e.1": 42} Lists are handled gracefully, index is used as a key in resulting map. ## Examples iex> [:a, 42] |> Iteraptor.to_flatmap %{"0": :a, "1": 42} iex> %{a: 42} |> Iteraptor.to_flatmap %{a: 42} iex> %{a: 42, b: 42} |> Iteraptor.to_flatmap %{a: 42, b: 42} iex> %{a: %{b: 42}, d: 42} |> Iteraptor.to_flatmap %{"a.b": 42, d: 42} iex> %{a: [:b, 42], d: 42} |> Iteraptor.to_flatmap %{"a.0": :b, "a.1": 42, d: 42} iex> %{a: %{b: [:c, 42]}, d: 42} |> Iteraptor.to_flatmap %{"a.b.0": :c, "a.b.1": 42, d: 42} iex> %{a: %{b: 42}} |> Iteraptor.to_flatmap %{"a.b": 42} iex> %{a: %{b: %{c: 42}}} |> Iteraptor.to_flatmap %{"a.b.c": 42} iex> %{a: %{b: %{c: 42}}, d: 42} |> Iteraptor.to_flatmap %{"a.b.c": 42, d: 42} iex> %{a: %{b: %{c: 42, d: [nil, 42]}, e: [:f, 42]}} |> Iteraptor.to_flatmap %{"a.b.c": 42, "a.b.d.0": nil, "a.b.d.1": 42, "a.e.0": :f, "a.e.1": 42} """ def to_flatmap(input, joiner \\ @joiner) when is_map(input) or is_list(input) do process(input, joiner) end @doc """ Build a nested structure out of a flatmap given, decomposing the names of keys and handling lists carefully. Example: %{"a.b.c": 42, "a.b.d.0": nil, "a.b.d.1": 42, "a.e.0": :f, "a.e.1": 42} |> Iteraptor.from_flatmap %{a: %{b: %{c: 42, d: [nil, 42]}, e: [:f, 42]}} ## Examples iex> %{"a.b.c": 42} |> Iteraptor.from_flatmap %{a: %{b: %{c: 42}}} iex> %{"a.b.c": 42, "a.b.d": 42} |> Iteraptor.from_flatmap %{a: %{b: %{c: 42, d: 42}}} iex> %{"a.b.c": 42, "a.b.d": 42, "a.e": 42} |> Iteraptor.from_flatmap %{a: %{b: %{c: 42, d: 42}, e: 42}} iex> %{"0": 42, "1": 42} |> Iteraptor.from_flatmap [42, 42] iex> %{"1": :a1, "0": :a0, "2": :a2, "3": :a3, "4": :a4, "5": :a5, "6": :a6, "7": :a7, "8": :a8, "9": :a9, "10": :a10, "11": :a11} |> Iteraptor.from_flatmap [:a0, :a1, :a2, :a3, :a4, :a5, :a6, :a7, :a8, :a9, :a10, :a11] iex> %{"0.a": 42, "0.b": 42} |> Iteraptor.from_flatmap [%{a: 42, b: 42}] iex> %{"a.b.c": 42, "a.b.d.0": nil, "a.b.d.1": 42, "a.e.0": :f, "a.e.1": 42} |> Iteraptor.from_flatmap %{a: %{b: %{c: 42, d: [nil, 42]}, e: [:f, 42]}} """ def from_flatmap(input, joiner \\ @joiner) when is_map(input) do unprocess(input, joiner) end @doc """ Iterates the given nested structure, calling the callback provided on each value. The key returned is a concatenated names of all the parent keys (and or indices in a case of an array.) Example: %{a: %{b: %{c: 42}}} |> Iteraptor.each(fn {k, v} -> IO.inspect({k, v}) end) #⇒ %{"a.b.c": 42} The return value is the result of call to `to_flatmap`. ## Examples iex> %{a: %{b: %{c: 42}}} |> Iteraptor.each(fn {k, v} -> IO.inspect({k, v}) end) %{"a.b.c": 42} """ def each(input, joiner \\ @joiner, fun) do unless is_function(fun, 1), do: raise "Function or arity fun/1 is required" process(input, joiner, "", %{}, fun) end ############################################################################## defp process(input, joiner, prefix \\ "", acc \\ %{}, fun \\ nil) ### -----------------------------------------------------------------------### defp process(input, joiner, prefix, acc, fun) when is_map(input) do input |> Enum.reduce(acc, fn({k, v}, memo) -> prefix = join(prefix, k, joiner) if is_map(v) or is_list(v) do process(v, joiner, prefix, memo, fun) else unless is_nil(fun), do: fun.({prefix, v}) Map.put memo, prefix, v end end) end defp process(input, joiner, prefix, acc, fun) when is_list(input) do input |> Enum.with_index |> Enum.map(fn({k, v}) -> {v, k} end) |> Enum.into(%{}) |> process(joiner, prefix, acc, fun) end ############################################################################## defp unprocess(input, joiner, fun \\ nil) ### -----------------------------------------------------------------------### defp unprocess(input, joiner, fun) when is_map(input) do input |> Enum.reduce(%{}, fn({key, value}, acc) -> put_or_update(acc, joiner, key, value, fun) end) |> imply_lists(joiner) end ############################################################################## defp join(l, r \\ "", joiner \\ @joiner) defp join(l, "", _) do String.to_atom(to_string(l)) end defp join("", r, _) do String.to_atom(to_string(r)) end defp join(l, r, joiner) do String.to_atom(to_string(l) <> joiner <> to_string(r)) end ############################################################################## @lint [{Credo.Check.Refactor.Nesting, false}, {Credo.Check.Refactor.ABCSize, false}] defp put_or_update(input, joiner, prefix, value, fun, path \\ "") when is_map(input) do case prefix |> to_string |> String.split(joiner, parts: 2) do [key, rest] -> {_, target} = input |> Map.get_and_update(join(key), fn current -> old = case current do nil -> %{} _ -> current end {current, old |> put_or_update(joiner, rest, value, fun, join(path, key, joiner))} end) target [key] -> unless is_nil(fun), do: fun.({path, value}) cond do is_map(input) -> input |> Map.put(join(key), value) is_list(input) -> input ++ [value] true -> input # FIXME raise ?? end end end ############################################################################## defp parse_key(key, joiner, prefix) do k = key |> to_string |> String.split(joiner) |> Enum.at((prefix |> String.split(joiner) |> Enum.count) - 1) try do k |> String.to_integer rescue ArgumentError -> k end end defp filter_keys(input, prefix) do case prefix do "" -> input _ -> input |> Enum.filter(fn e -> e |> to_string |> String.starts_with?(to_string(prefix)) end) end end defp quacks_as_list(input, joiner, prefix \\ "") do input = input |> Map.keys |> filter_keys(prefix) (input |> Enum.map(fn k -> k |> parse_key(joiner, prefix) end) |> Enum.sort) == (0..Enum.count(input) - 1 |> Enum.to_list) end defp imply_lists(input, joiner) when is_map(input) do if quacks_as_list(input, joiner) do sorted = input |> Enum.sort(fn ({k1, _}, {k2, _}) -> String.to_integer(to_string(k1)) < String.to_integer(to_string(k2)) end) for {_, v} <- sorted do if is_map(v), do: imply_lists(v, joiner), else: v end else Enum.into(for {k, v} <- input do {k, (if is_map(v), do: imply_lists(v, joiner), else: v)} end, %{}) end end end