Update deepest element value in a list of maps with nested list of maps [ %{ items: [ %{element: value} ] } ]

Hi, I am probably missing something fundamental of Elixir knowledge, but I am not able to add nor update values of elements in deeply nested list of maps structured like this:

    original_animals = [
      %{
        group_title: "domestic animals",
        items: [
          %{title: "dog"},
          %{title: "cat"},
        ]
      },
      %{
        group_title: "wild animals",
        items: [
          %{title: "elephant"},
        ]
      }
    ]

Expected output is

    updated_animals = [
      %{
        group_title: "domestic animals",
        items: [
          %{title: "dog", **NEW_KEY: NEW_VAL**},
          %{title: "cat", **NEW_KEY: NEW_VAL**},
        ]
      },
      %{
        group_title: "wild animals",
        items: [
          %{title: "elephant", **NEW_KEY: NEW_VAL**},
        ]
      }
    ]

I have tried several ways how to achieve the expected result, but without any success. These wrong ways were:

Combination of Enum.map() and Map.put()

original_animals
|> Enum.map(fn group ->
  group.items
  |> Enum.map(fn item ->
    item
    |> Map.put(NEW_KEY, NEW_VAL)
  end )
end )

Function get_and_update_in()

original_animals
|> get_and_update_in([Access.all(), :items, NEW_KEY], &{&1, NEW_VAL})
|> case do {_original, updated_animals} ->
      updated_animals
    end

Could you please recomend how to solve this issue? (4 hours ago I’d say “trivial issue” :slightly_smiling_face: )

Hello and welcome, You might try this.

original_animals 
|> Enum.map(fn m -> 
  put_in(m, [:items], Enum.map(m.items, & Map.put(&1, :key, :value))) 
end)  
[
  %{
    group_title: "domestic animals",
    items: [%{key: :value, title: "dog"}, %{key: :value, title: "cat"}]
  },
  %{group_title: "wild animals", items: [%{key: :value, title: "elephant"}]}
]

Maybe this one too…

original_animals 
|> Enum.map(fn m -> 
  %{m | items: Enum.map(m.items, & Map.put(&1, :key, :value))} 
end)
2 Likes

You can use Access.all/0 on each and every level. Also, you don’t need get_and_update_in/3, update_in/3 does.

update_in(
  original_animals,
  [Access.all(), :items, Access.all()],
  &Map.put(&1, :foo, :bar)
)
6 Likes

Ok gentlemen, all your solutions above work great and I have to seriously thank you for making my day :slight_smile: Now I’m wondering, how to get familiar with those kind of operations without asking for a magical help of you wizards every time. Probably it could be a good start to get familiar with anonymous functions.

2 Likes

It’s still “trivial issue”, but don’t worry … such things happens. There are many ways how to solve a “trivial issue” when you are lost:

  1. Ask on forum as you did :ok_hand:
  2. Read documentation again :muscle:
  3. Try to simplify and isolate issue :brain:
  4. My favourite and also the simplest one is: go to toilet (i.e. just relax) :smiling_imp:

Looks like you understand Elixir enough to solve your problem. You remember about Access.all() and just forgot to use it in all required places. You just need to chill out and take a look at your code later with fresh mind.

Let me explain everything. Let’s start from what you have tried:

# a variable which would be manipulated - simple
original_animals
# map (i.e. update) each item of `list` called `group` - also simple
|> Enum.map(fn group ->
  # here is a mistake …
  # get items value - you forget about group
  # we map group and returning only the list of it's changed items
  # most probably such mistake comes from OOP language
  group.items
  # as above map (i.e. update) each item of `list` called `item`
  |> Enum.map(fn item ->
    # if there is only one call then using pipe does not makes sense
    item
    # simply put key in value
    |> Map.put(NEW_KEY, NEW_VAL)
  # end of map`item`
  end )
# end of map `group`
end )
# a variable which would be manipulated - simple
original_animals
# here your path requests for a `NEW_KEY` inside a `list` which obviously does not works
# as list is not a map
# you just forgot about calling `Access.all()` again for `items` list
# however it's not the only problem …
# you want to return a tuple of `{old_list, NEW_VAL}` - you intended to `{old_map, NEW_VAL}`
# but both are wrong - what you need here is to update `old_map` and not wrap it in `tuple`
|> get_and_update_in([Access.all(), :items, NEW_KEY], &{&1, NEW_VAL})
# you wrongly expect that `case` would magically update
# however the `update` already happen - list is mapped to `{old_list, NEW_VALUE}`
# you do not need `original` so `get_and_update_in` is bad by assumption
# therefore you wanted `update_in`
|> case do {_original, updated_animals} ->
      updated_animals
    end

Now let’s take a look at answers. Personally I prefer @mudasobwa as it’s solution is easiest and shortest.

# as above `update_in` is what you need
update_in(
  # simply your variable
  original_animals,
  # also as above here is your missed `Access.all()` call on `items`
  [Access.all(), :items, Access.all()],
  # the update function gets each element of list in items
  # and puts a new key with a new value to element
  &Map.put(&1, :foo, :bar)
)

@kokolegorille’s solution is a bit overcomplicated. Let’s check what he is doing …

# easy start
original_animals
# as in your first example - `m` here is called be you `group`
|> Enum.map(fn m -> 
  # @kokolegorille also forgot about `Access.all()`
  # however he understand how to update every item in group.items list
  # first of all he is not updating - he is just putting - in this case both putting and updating works
  # as a new value of `items` i.e. new `items`
  # he maps every item of list
  # and as @mudasobwa simply puts a value under key
  put_in(m, [:items], Enum.map(m.items, & Map.put(&1, :key, :value))) 
end)
# start as always
original_animals 
# map as above
|> Enum.map(fn m -> 
  # use `%{map | existing_key: new_value}` syntax instead of `put_in`
  # while both works `put_in` is easier in this case
  # the rest did not changed
  # you returned only changed `items` instead of updated `group`
  # but he is updating `m` (i.e. `group`) and map each element of `items` list
  %{m | items: Enum.map(m.items, & Map.put(&1, :key, :value))} 
end)

We can “merge” both answers into one:

# as @kokolegorille we are going to use put_in instead of update_in
put_in(
  # the variable to manipulate
  original_animals,
  # as @mudasobwa we are using `Access.all()` including the one you have missed at end
  # but also we can add a `key` to our path, so we do not need to call `Map.put`
  [Access.all(), :items, Access.all(), :key],
  # a new value
  :value
)

Now there is only one problem … When to use put_in and when to use update_in. We want put_in in nested structure in case we want to simply put same value regardless of content whereas update_in allows you to pattern match every item map and update it based on it’s values.

Helpful resources:

  1. Access.all/0
  2. get_and_update_in/2
  3. put_in/3
  4. update_in/3

Hope that helps! Let us know if there is something you don’t understand. Happy coding! :heart:

4 Likes

I agree Access.all() is much nicer :slight_smile:

Thank you Eiji for such a complex explanation. You are absolutely right in all given points. With it and those helpful resources, the issue with map updating seems to be almost :wink: trivial again.

I marked kokolegorilles answer as a solution because with the Access.all() way there seems to be an error when interacting with Phoenix schema data:

function App.Context.Schema.get_and_update/3 is undefined 
(App.Context.Schema does not implement the Access behaviour)

Should be the mudasobwa’s answer the right solution?

Anyway, hats off to Elixirforum community. Thank you all guys!

Unlike plain map your custom struct (Ecto.Schema defines custom schema struct) does not have an Access implementation and therefore you cannot access keys in it dynamically like in maps.

defmodule Example do
  defstruct [:sample]
end

iex> %{sample: 5}[:sample]
5
iex> %Example{sample: 5}[:sample]
** (UndefinedFunctionError) function Example.fetch/2 is undefined (Example does not implement the Access behaviour)
    Example.fetch(%Example{sample: 5}, :sample)
    (elixir 1.12.0-dev) lib/access.ex:285: Access.get/3

To solve it you need to implement Access behaviour. However you do not need to do it by hand all the time. You can use accessible library.

defmodule Example do
  defstruct [:sample]

  use Accessible
end

iex> %{sample: 5}[:sample]
5
iex> %Example{sample: 5}[:sample]
5

We could have few solutions for one problem. The one who should choose which one is best for you is … you. :wink:

3 Likes