Manipulate Nested Map

I’m trying to manipulate the following map in Elixir, but I fail to get it working. I want to set a user_id for all entries of user_sports.

%{"zip" => "",
  "location => "",
  "user_sports" => 
     %{"0" => %{"sport_id" => "1", "user_id" => ""},
       "1" => %{"sport_id" => "1", "user_id" => ""}
     }
}

As far as I understood it, the following makes it harder: The keys are “numbers”, so they aren’t stable; it’s not a single entry, there are multiple.

I have tried Map.replace and Map.put, but it feels like the Enum.each prevents the results being saved in the map.

Enum.each(user_sports, fn {k, v} ->
  Map.replace!(user_sports, k, %{v | "user_id" => user.id})
end)

When I tried to use them with fixed keys, it worked, but unfortunately, I never know how many entries I have.

Map.put(user_sports, "0", %{Map.get(user_sports, "0") | "user_id" => user.id})

I have also tried get_and_update(_in), but I was always running into the given function must return a two-element tuple or :pop, got: :ok:

params = get_and_update_in(user_params, ["user_sports"], fn c -> Enum.each(c, fn {k, v} -> {k, %{v | "user_id" => user.id}} end) end)

I feel, I am not far way from the right solution, but I have a stupid mistake/misunderstanding.

Do you know about Kernel.put_in/2 and Kernel.put_in/3? It might be just what you are looking for.

1 Like
user_id = "1"

# extract `user_sports` from data
%{"user_sports" => user_sports} = data = %{
  "zip" => "",
  "location" => "",
  "user_sports" =>  %{
    "0" => %{"sport_id" => "1", "user_id" => ""},
    "1" => %{"sport_id" => "1", "user_id" => ""}
  }
}

# put user_id into user_sports
user_sports =
  user_sports
  |> Enum.map(fn {key, user_sport_info} -> 
    {key, Map.put(user_sport_info, "user_id", user_id)}
  end)
  |> Enum.into(%{})

# update data with new user_sports
data = %{data | "user_sports" => user_sports}

I hoped

put_in(data, ["user_sports", Access.all(), "user_id"], user_id)

would also work, but it didn’t. Access.all() only works with lists:

iex(4)> put_in(data, ["user_sports", Access.all(), "user_id"], user_id)
** (RuntimeError) Access.all/0 expected a list, got: %{"0" => %{"sport_id" => "1", "user_id" => ""}, "1" => %{"sport_id" => "1", "user_id" => ""}}
    (elixir) lib/access.ex:626: Access.all/3
    (elixir) lib/map.ex:773: Map.get_and_update/3
    (elixir) lib/kernel.ex:2057: Kernel.put_in/3
2 Likes

One way to do it (not tested):

new_user_sports =
  initial_data
  |> get_in(["user_sports"])
  |> Enum.map(fn {k, v} -> 
    {k, Map.put(v, "user_id", user.id)}
  end)

Map.put(initial_data, "user_sports", new_user_sports)

Enum.map would return a list of key-value tuples, not a map, so something like Enum.into(%{}) is required for the initial structure to stay intact.

Or a reduce function

Enum.reduce(user_sports, %{}, fn {key, user_sport_info}, acc ->
  Map.put(acc, key, Map.put(user_sport_info, "user_id", user_id))
end)

or a for comprehension

for {key, user_sport_info} <- user_sports, into: %{} do
  {key, Map.put(user_sport_info, "user_id", user_id)}
end

But both of these would be less efficient than a map.

2 Likes

Enum.into(%{}) is better imho

update_in(
  map["user_sports"],
  &:maps.map(fn _key, user_sport -> %{user_sport | "user_id" => user_id} end, &1)
)

Also, Map.new(enum, fun) can be used instead of enum |> Enum.map(fun) |> Enum.into(%{}).

1 Like

If you don’t mind writing a helper function, the logic becomes simpler when you break it into pieces. For instance,

def update_sports_map(map) do
  Map.new(map, fn({index, attrs}) -> {index, Map.put(attrs, "user_id", "id")} end)
end

new_data = Map.update!(data, "user_sports", &update_sports_map/1)

Awesome solutions. Thanks a lot :slight_smile: I feel like I was slowed on the uptake

@idi527: I tried it with Access.fetch too, but unluckily it does not seem to be as flexible as I need to have it

The only thing I still don’t understand is the fact that the following does not work:

Enum.each(user_sports, fn {k, v} ->
  Map.replace!(user_sports, k, %{v | "user_id" => user.id})
end)

Can anyone explain this?

That’s because most data types in elixir/erlang are immutable. So Enum.each won’t mutate user_sports.

1 Like

To expand on what @idi527 said, since data is immutable the way an update works is to have a function return an updated version. So something like

data = update_fun(data)

works by returning the updated data and switching the label data to the new data. The original data is not modified and is still there, until the VM sees that it’s no longer referenced and garbage collects it.

Enum.each(enumerable, fun) simply calls fun for every element of the enumerable, and then returns :ok. This means any value returned by fun doesn’t get used and winds up being garbage collected. The main use for Enum.each is to call a function with a side-effect, like printing to an IO device or sending a message to another process, for each element.

3 Likes

There is a really interesting Hex package named lens (or on github) which allows to easily perform manipulations of deeply nested maps.

Below you’ll find two examples. The first updates user_id without any context (key of current map value) and the second has the context to allow updating user_id with the value of the key (0 or 1). Lens.into/2 is used to reconstruct a map out of the list we get from Lens.all/0.

iex(1)> m = %{
...(1)>   "zip"         => "",
...(1)>   "location"    => "",
...(1)>   "user_sports" => %{
...(1)>     "0" => %{"sport_id" => "1", "user_id" => ""},
...(1)>     "1" => %{"sport_id" => "1", "user_id" => ""},
...(1)>   },
...(1)> }
%{
  "location" => "",
  "user_sports" => %{
    "0" => %{"sport_id" => "1", "user_id" => ""},
    "1" => %{"sport_id" => "1", "user_id" => ""}
  },
  "zip" => ""
}
iex(2)> lens1 = Lens.key("user_sports") |> Lens.map_values() |> Lens.key("user_id")
#Function<41.112152499/3 in Lens.seq/2>
iex(3)> Lens.map(lens1, m, fn _v -> "updated" end)
%{
  "location" => "",
  "user_sports" => %{
    "0" => %{"sport_id" => "1", "user_id" => "updated"},
    "1" => %{"sport_id" => "1", "user_id" => "updated"}
  },
  "zip" => ""
}
iex(4)> lens2 = Lens.key("user_sports") |> Lens.into(Lens.context(Lens.all(),  Lens.at(1) |> Lens.key("user_id")), %{})
#Function<41.112152499/3 in Lens.seq/2>
iex(5)> Lens.map(lens2, m, fn {{id, _map}, _v} -> "updated with #{id}" end)
%{
  "location" => "",
  "user_sports" => %{
    "0" => %{"sport_id" => "1", "user_id" => "updated with 0"},
    "1" => %{"sport_id" => "1", "user_id" => "updated with 1"}
  },
  "zip" => ""
}
2 Likes