Current section

Files

Jump to
iteraptor lib iteraptor.ex
Raw

lib/iteraptor.ex

defmodule Iteraptor do
@moduledoc """
`Iteraptor` makes complicated nested structures (currently `map`s, `list`s
and somehow `Keyword`s) iteration easier.
## Usage
### `to_flatmap`:
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}
### `from_flatmap`:
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]}}
### `each`:
%{a: %{b: %{c: 42, d: [nil, 42]}, e: [:f, 42]}}
|> Iteraptor.each(fn({k, v}) ->
IO.puts(k <> " ⇒ " <> inspect(v))
end)
Returning:
a.b.c ⇒ 42
a.b.d.0 ⇒ nil
a.b.d.1 ⇒ 42
a.e.0 ⇒ :f
a.e.1 ⇒ 42
"""
@joiner "."
@struct_joiner "%"
@doc """
Build a flatmap out of nested structure, concatenating the names of keys.
%{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.
## Parameters
- `input`: nested map/list/keyword/struct to be flattened.
- `joiner`: the character to be used to join keys while flattening,
_optional_, default value is `"."`;
e.g. `%{a: {b: 42}}` will be flattened to `%{"a.b" => 42}`.
## 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: 42] |> Iteraptor.to_flatmap
%{"a.b.c" => 42, d: 42}
iex> [a: [[:b], 42], d: 42] |> Iteraptor.to_flatmap
%{"a.0.0" => :b, "a.1" => 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}
iex> %Struct1{field1: %Struct2{field2: [%{a: 42}, :b]}} |> Iteraptor.to_flatmap
%{"Struct1%field1.Struct2%field2.0.a" => 42, "Struct1%field1.Struct2%field2.1" => :b}
"""
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.
%{"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]}}
## Parameters
- `input`: flat map to be “expanded” to nested maps/lists.
- `joiner`: the character to be used to “un-join” keys while flattening,
_optional_, default value is `"."`;
e.g. `%{"a.b" => 42}` will be unveiled to `%{a: {b: 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.0.0" => :b, "a.1" => 42, d: 42} |> Iteraptor.from_flatmap
%{a: [[:b], 42], d: 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]}}
iex> %{"Struct1%field1.Struct2%field2.0.a" => 42, "Struct1%field1.Struct2%field2.1" => :b}
...> |> Iteraptor.from_flatmap
%Struct1{field1: %Struct2{field2: [%{a: 42}, :b]}}
"""
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.)
The return value is the result of call to `to_flatmap`.
## Parameters
- `input`: nested map/list/keyword to be walked through.
- `joiner`: the character to be used to join keys while flattening,
is returned to the callback as iterated key name;
_optional_, default value is `"."`;
- `fun`: callback to be called on each _value_;
e.g. on `%{a: {b: 42}}` will be called once, with tuple `{"a.b", 42}`.
## 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, nil, "", %{}, fun)
end
##############################################################################
defp process(input, joiner, type \\ nil, prefix \\ "", acc \\ %{}, fun \\ nil)
### -----------------------------------------------------------------------###
defp process(input, joiner, nil, prefix, acc, fun) do
type = case Enumerable.impl_for(input) do
Enumerable.List -> if Keyword.keyword?(input), do: :keyword, else: :list
Enumerable.Map -> :map
_ -> if is_map(input), do: :struct, else: :unknown
end
process(input, joiner, type, prefix, acc, fun)
end
### -----------------------------------------------------------------------###
defp process(_, _, :unknown, prefix, _, _) do
raise ArgumentError, message: "Unsupported data type found at prefix: #{prefix}"
end
defp process(input, joiner, :map, prefix, acc, fun) 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, nil, prefix, memo, fun)
else
unless is_nil(fun), do: fun.({prefix, v})
Map.put memo, prefix, v
end
end)
end
defp process(input, joiner, :keyword, prefix, acc, fun) do
input |> Enum.into(%{}) |> process(joiner, nil, prefix, acc, fun)
end
defp process(input, joiner, :list, prefix, acc, fun) do
input
|> Enum.with_index
|> Enum.map(fn({k, v}) -> {v, k} end)
|> Enum.into(%{})
|> process(joiner, nil, prefix, acc, fun)
end
defp process(input, joiner, :struct, prefix, acc, fun) do
struct_name = input.__struct__ |> inspect |> String.replace(".", @struct_joiner)
input
|> Map.keys
|> Enum.filter(fn e -> e != :__struct__ end)
|> Enum.map(fn e ->
{ "#{struct_name}%#{e}", get_in(input, [Access.key!(e)]) }
end)
|> Enum.into(%{})
|> process(joiner, nil, 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, "", joiner) do
s = to_string l
if s |> String.contains?(joiner) do
s
else
String.to_atom s
end
end
defp join("", r, joiner) do
join(r, "", joiner)
end
defp join(l, r, joiner) do
to_string 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 is_struct(input) when is_map(input) do
input |> Enum.reduce(nil, fn {k, _}, acc ->
case k |> to_string |> String.split(~r{#{@struct_joiner}(?=[^#{@struct_joiner}]*$)}) do
[^acc, _] -> acc
[struct_name, _] -> if acc == nil, do: struct_name, else: false
_ -> false
end
end)
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
target = case is_struct(input) do
false -> %{}
name -> struct(Module.concat(Elixir, name |> to_string |> String.replace(@struct_joiner, ".")))
end
input |> Enum.reduce(target, fn {k, v}, acc ->
key = join(~r{.*#{@struct_joiner}} |> Regex.replace(to_string(k), ""))
acc |> Map.put(join(key), (if is_map(v), do: imply_lists(v, joiner), else: v))
end)
end
end
end