3

Say we have map like:

%{"a": %{"b": 2, "c":5}, "d": 1}

Is there anything similar to this function(js answer to same question) built in elixr?

End result should be:

%{"a.b": 4, "a.c":5, "d": 1}
Community
  • 1
  • 1
Nema Ga
  • 2,450
  • 4
  • 26
  • 49
  • 2
    Do you want to support arbitrary level nesting (e.g. `MyMaps.flatten %{"a" => %{"b" => %{"c" => %{"d" => 1}}}}` => `%{"a.b.c.d" => 1}`) or just 2 levels like your example? – Dogbert Sep 09 '16 at 02:55

5 Answers5

5

Since I have done this task many times met already, and I need it on my own, I have created the hex package iteraptor for that:

Add it to your list of dependencies in mix.exs:

def deps do
  [{:iteraptor, "~> 1.13"}]
end

And use it like:

iex(1)> %{a: %{b: 2, c: 5}, d: 1} |> Iteraptor.to_flatmap
%{"a.b": 2, "a.c": 5, d: 1}

It supports infinite nesting and both maps and lists.

The respective part of the code follows:

    defmodule Iteraptor do
      @joiner "."
    
      @doc """
        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 """
        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 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
    end
Aleksei Matiushkin
  • 119,336
  • 10
  • 100
  • 160
  • wow the `iteraptor` lib is amazing. thanks! – mlen108 Aug 23 '22 at 08:53
  • @mlen108 make sure you use the latest version, not `~> 0.1.0` as was listed here 6 years ago :) Also, despite I am the author of the library, nowadays I suggest using `Access` and/or `Macro.traverse/4` instead because they are way more idiomatic. – Aleksei Matiushkin Aug 23 '22 at 09:11
  • yes, I grabbed the latest from hex.pm... My use case is to flatten down an output from users - the data could be nested as many as n-levels, and so far `iteraptor` does amazing job. I guess a lot has changed within Elixir in last 6 years since the language itself is only 10 years old :-) – mlen108 Aug 23 '22 at 11:16
  • Yeah, the language amazingly evolves :) – Aleksei Matiushkin Aug 23 '22 at 11:59
2

I don't know of a builtin function, but of course there are ways to execute that transformation:

defmodule MyMaps do
  def flatten(map) when is_map(map) do
    map
    |> to_list_of_tuples
    |> Enum.into(%{})
  end

  defp to_list_of_tuples(m) do
    m
    |> Enum.map(&process/1)
    |> List.flatten
  end

  defp process({key, sub_map}) when is_map(sub_map) do
    for { sub_key, value } <- sub_map do
      { join(key, sub_key), value }
    end
  end

  defp process({key, value}) do
    { key, value }
  end

  defp join(a, b) do
    to_string(a) <> "." <> to_string(b)
  end
end


m = %{ "a" => %{ "b" => 2, "c" => 5 }, "d" => 1 }

MyMaps.flatten m
# %{"a.b" => 2, "a.c" => 5, "d" => 1}

m1 = %{ a: %{ b: 2, c: 5}, d: 1}
MyMaps.flatten m1
# %{:d => 1, "a.b" => 2, "a.c" => 5}
tompave
  • 11,952
  • 7
  • 37
  • 63
0

As of version 1.3, there is no flatten/unflatten as you describe built into Elixir.

However, here is a translation of the javascript implementation you mentioned in your question.

flatten = fn
  (data) when is_list(data) or is_map(data) ->
    recurse = fn
      (recurse, cur, prop, result) when is_list(cur) and length(cur) == 0 ->
        Map.put(result, prop, [])
      (recurse, cur, prop, result) when is_list(cur) ->
        Enum.reduce(Enum.with_index(cur), result, fn ({value, index}, acc) ->
          recurse.(recurse, value, << prop :: binary, ?[, to_string(index) :: binary, ?] >>, acc)
        end)
      (recurse, cur, prop, result) when is_map(cur) and map_size(cur) == 0 ->
        Map.put(result, prop, %{})
      (recurse, cur, prop, result) when is_map(cur) ->
        Enum.reduce(cur, result, fn ({key, value}, acc) ->
          recurse.(recurse, value, if byte_size(prop) == 0 do
            key
          else
            << prop :: binary, ?., to_string(key) :: binary >>
          end, acc)
        end)
      (recurse, cur, prop, result) ->
        Map.put(result, prop, cur)
    end
    result = recurse.(recurse, data, <<>>, %{})
    result
  (data) ->
    data
end

unflatten = fn
  (data) when is_map(data) ->
    regex = ~r/\.?([^.\[\]]+)|\[(\d+)\]/
    array_get = fn
      (array, index, default) when length(array) <= index -> default
      (array, index, _default) -> :lists.nth(index + 1, array)
    end
    array_new = fn (size, array) when is_integer(size) and size >= 0 ->
      fill = fn
        (_, 0, array) -> array
        (fill, n, array) -> fill.(fill, n - 1, [nil | array])
      end
      fill.(fill, size, array)
    end
    array_put = fn (array, index, value) when is_integer(index) and index >= 0 ->
      case length(array) do
        0 when index == 0 -> [value]
        0 -> array_new.(index, [value])
        ^index -> array ++ [value]
        length when length > index -> List.replace_at(array, index, value)
        length -> array ++ array_new.(index - length, [value])
      end
    end
    Enum.reduce(data, nil, fn ({prop, value}, cur) ->
      recurse = fn
        (recurse, [[_, key] | rest], cur) ->
          cur = cur || %{}
          Map.put(cur, key, recurse.(recurse, rest, Map.get(cur, key, nil)))
        (recurse, [[_, _, index] | rest], cur) ->
          index = String.to_integer(index)
          cur = cur || []
          array_put.(cur, index, recurse.(recurse, rest, array_get.(cur, index, nil)))
        (_, [], _) ->
          value
      end
      recurse.(recurse, Regex.scan(regex, prop), cur)
    end)
  (data) ->
    data
end

These flatten/unflatten functions handle lists and deeply nested structures. For example:

# TEST 1
input  = %{"a" => %{"b" => 2, "c" => 5}, "d" => 1}
flat   = flatten.(input) # %{"a.b" => 2, "a.c" => 5, "d" => 1}
unflat = unflatten.(flat)
unflat == input # true

# TEST 2
input  = %{"a" => %{"b" => [5, 1, %{"c" => [3, %{"d" => 4}]}, 2]}, "e" => []}
flat   = flatten.(input) # %{"a.b[0]" => 5, "a.b[1]" => 1, "a.b[2].c[0]" => 3, "a.b[2].c[1].d" => 4, "a.b[3]" => 2, "e" => []}
unflat = unflatten.(flat)
unflat == input # true

# TEST 3
input  = []
flat   = flatten.(input) # %{"" => []}
unflat = unflatten.(flat)
unflat == input # true

# TEST 4
input  = %{}
flat   = flatten.(input) # %{"" => %{}}
unflat = unflatten.(flat)
unflat == input # true

# TEST 5
input  = [1, 2, %{"a" => 3}]
flat   = flatten.(input) # %{"[0]" => 1, "[1]" => 2, "[2].a" => 3}
unflat = unflatten.(flat)
unflat == input # true

A working example is also available here: http://elixirplayground.com?gist=5d1b166557cbeb65c019f3caa356a5e8

potatosalad
  • 4,819
  • 2
  • 19
  • 21
0

And you can adjust @tompave's sample and let it work with nested maps:

defmodule MyMaps do
  def flatten(map) when is_map(map) do
    map
    |> to_list_of_tuples
    |> Enum.into(%{})
  end

  defp to_list_of_tuples(m) do
    m
    |> Enum.map(&process/1)
    |> List.flatten
  end

  defp process({key, sub_map}) when is_map(sub_map) do
    for { sub_key, value } <- flatten(sub_map) do
      { "#{key}.#{sub_key}", value }
    end
  end

  defp process(next), do: next
end

m = %{"a" => %{"b" => %{"c" => 1}, "d" => 2}}
MyMaps.flatten(m) # => %{"a.b.c" => 1, "a.d" => 2}
Vitalii Elenhaupt
  • 7,146
  • 3
  • 27
  • 43
0

I went with the solution @vitalii-elenhaupt suggested, which worked great with a couple of corner cases.

In particular, I was using a map that has a DateTime struct as a value, which will complain with the following error:

** (Protocol.UndefinedError) protocol Enumerable not implemented for #DateTime<2018-05-29 10:43:07.993000Z>. This protocol is implemented for: Amnesia.Table.Stream, Date.Range, File.Stream, Function, GenEvent.Stream, HashDict, HashSet, IO.Stream, List, Map, MapSet, Range, Stream, Timex.Interval

To get around this, I had to add specific pattern matches for unsupported values:

defmodule MyMaps do
  def flatten(map) when is_map(map) do
    map
    |> to_list_of_tuples
    |> Enum.into(%{})
  end

  defp to_list_of_tuples(m) do
    m
    |> Enum.map(&process/1)
    |> List.flatten()
  end

  defp process({key, %DateTime{} = datetime}), do: {"#{key}", datetime}
  defp process({key, %Date{} = date}), do: {"#{key}", date}

  defp process({key, sub_map}) when is_map(sub_map) do
    for {sub_key, value} <- flatten(sub_map) do
      {"#{key}.#{sub_key}", value}
    end
  end

  defp process(next), do: next
end
Will
  • 11
  • 2