Merging maps in list based on matching value

Hey I have a list of maps, and those maps are items such:

[
%purchase{
    amount: #Decimal<2.0>,
    command: "buy",
    id: 71,
    price: #Decimal<90.394>,
  },
%purchase{
    amount: #Decimal<1.0>,
    command: "buy",
    id: 72,
    price: #Decimal<90.394>,
  }
]

As you can see the prices are the same but the amount isn’t how would you go through the list to merge the items with the same price to get something like this:

[
%purchase{
    amount: #Decimal<3.0>,
    command: "buy",
    price: #Decimal<90.394>,
  }
]

Hey @benonymus what have you tried so far? If you need help getting started, check out the functions in https://hexdocs.pm/elixir/Enum.html and see if there’s something that might like you group_by the items with the same price, after which you could go through each price group and reduce them into a single purchase.

Hey, I came up with 2 solutions so far:

for order <- buy do
      Enum.reduce(
        buy,
        order,
        fn x, y ->
          cond do
            Decimal.cmp(x.price, y.price) == :eq and x.id != y.id ->
              new_amount = Decimal.add(x.amount, y.amount)
              Map.put(y, :amount, new_amount)

            true ->
              y
          end
        end
      )
    end
    |> Enum.uniq_by(fn x -> x.price end)

and

    for order <- buy do
      Enum.map(buy, fn x ->
        cond do
          Decimal.cmp(x.price, order.price) == :eq and x.id != order.id ->
            new_amount = Decimal.add(x.amount, order.amount)
            Map.put(order, :amount, new_amount)

          true ->
            order
        end
      end)
    end
    |> List.flatten()
    |> Enum.uniq_by(fn x -> x.price end)

But I am having a hard time deciding which one to stick with :confounded:
Edit:
I decided to stick with the reduce version, so I don’t need to flatten a list

Needlessly verbose IMO. Here’s how I went about it:

defmodule Stock do
  def aggregate_by_price(items) do
    items
    |> Enum.group_by(&(Decimal.reduce(&1.price)))
    |> Enum.map(fn({_price, items}) ->
      Enum.reduce(items, fn(acc, item) ->
        %{acc | amount: Decimal.add(acc.amount, item.amount)}
      end)
    end)
  end
end

Then in iex:

items = [
  %{price: Decimal.from_float(90.394), amount: Decimal.from_float(2.0)},
  %{price: Decimal.from_float(90.394), amount: Decimal.from_float(1.0)},
  %{price: Decimal.from_float(17.64738), amount: Decimal.from_float(10.0)},
  %{price: Decimal.from_float(90.394), amount: Decimal.from_float(5.0)},
  %{price: Decimal.from_float(17.64738), amount: Decimal.from_float(13.0)}
]

Stock.aggregate_by_price(items)

This returns:

[
  %{amount: #Decimal<8.0>, price: #Decimal<90.394>},
  %{amount: #Decimal<23.0>, price: #Decimal<17.64738>}
]

Which seems to be what you need.

3 Likes

We can golf this slightly smaller by using Enum.reduce/2 which does the hd/tl thing for you:

      Enum.reduce(items, fn(acc, item) ->
        %{acc | amount: Decimal.add(acc.amount, item.amount)}
      end)

The only real concern with this approach I’m realizing is that it compares price based on term equality not Decimal.cmp. That may require manual grouping, but that shouldn’t be too bad.

Heh, always thought Enum.reduce/2 repeats the first element when iterating. Thanks for ridding me of that dumb illusion!

Oops, I actually thought of addressing this but forgot. We can get away with it by using Decimal.reduce/1 because sometimes even identical float values – but represented differently when passed to Decimal.from_float – yield false when compared with == (which I agree should not be done).

Edited above to reflect your remarks.