Ecto.Multi - Loop over list of changesets

Hello there, dear community!

I have been dealing the last days with a function that save multiple records at once using Ecto.Multi. At the moment on my code I have 2 functions that successfully works (yeey :tada:) using Multi too.
The logic of this function is to take the Attributes comming after submiting a form. The aim of this form is to allow users to Sign-up for a Sports Club: This form could send:
A - The data to insert 1 new user. (Already working :white_check_mark:).
B - The data to insert 2 new users, a minor user + the “Legal guardian” (Already working :white_check_mark:).
C - (The one I am currently working on) That will insert X amount of users (group of users like 2 parents and 2 children for instance).

Starting from function A.

def create_membership(attrs, club_id, plan_ids) do
    # This will store the new user changeset
    membership_cs = Membership.create_changeset(attrs, plan_ids)

    multi = Multi.insert(Multi.new(), :insert_membership, membership_cs)

    # list of events to record on the activity logs table.
    ~w(register_membership accept_articles join_club)a
    |> Enum.reduce(multi, fn event, multi ->
      Multi.run(multi, {:insert_activity_log, event}, fn repo, %{insert_membership: membership} ->
        membership = Repo.preload(membership, :club)
        Users.update_user(membership.user, %{membership_id: membership.id})
        log_params = %{event: event, actor: :membership, data: %{club_name: membership.club.name}}

        %ActivityLog{}
        |> ActivityLog.create_changeset(membership.club, membership, log_params)
        |> repo.insert()
      end)
    end)
    |> Repo.transaction()
  end

Function B.

This will record 2 new users, a minor user and an adult user. The logic the for the sport clubs is also to save the users on a family group.

def create_membership_with_group(attrs, club_id, plan_ids) do
    membership_cs = Membership.create_changeset_minor(attrs, plan_ids)

    membership_guardian = Membership.create_changeset(attrs, []) #not passing plan_ids since the guardian is not an active member of the club, only pays for the kid.

    multi =
      Multi.insert(Multi.new(), :insert_membership, membership_cs)
      |> Multi.run(:insert_guardian, fn repo, %{insert_membership: membership} ->
        Users.update_user(membership.user, %{membership_id: membership.id})
        membership_number = generate_membership_number(membership.membership_number)

        repo.insert(
          Ecto.Changeset.put_change(
            membership_guardian,
            :membership_number,
            membership_number
          )
        )
      end)
      # After the new 2 memberships are created, we create a new group, and we insert both memberships.
      |> Multi.run(:create_group, fn repo, %{insert_guardian: guardian} ->
        repo.insert(Group.create_changeset(attrs, guardian))
      end)
      |> Multi.run(:update_group, fn repo, %{insert_membership: membership, create_group: group} ->
        repo.update(Group.add_memberships_changeset([membership], group))
      end)

    # List of events to record on the activity-logs table.
    ~w(register_membership accept_articles join_club)a
    |> Enum.reduce(multi, fn event, multi ->
      Multi.run(multi, {:insert_activity_log, event}, fn repo, %{insert_membership: membership} ->
        membership = Repo.preload(membership, :club)
        Users.update_user(membership.user, %{membership_id: membership.id})
        log_params = %{event: event, actor: :membership, data: %{club_name: membership.club.name}}

        %ActivityLog{}
        |> ActivityLog.create_changeset(membership.club, membership, log_params)
        |> repo.insert()
      end)
    end)
    |> Repo.transaction()
  end

Function C.

This will record X new users (minimum 2 as the example above, but it could be up to 12 new users), The logic of the membership group is to have between 1 Parent and 1 children (But in this case the parent is an active members of the club, so it has plans), and 2 Parents and 10 children. The logic the for the sport clubs here is also to save all the new memberships into a family group, as Funciton B.

My attempt:

def create_membership_with_family_group(attrs, club_id, plan_ids) do
    # This creates the changeset for the Payer of the Group (User on the form)
    membership_cs = Membership.create_changeset(attrs, plan_ids)

    # This Returns a list of Changesets for the rest of the members of the group (without the payer).
    membership_fm = []
    membership_fm = for _group_member <- attrs["group_members"] do
      group_member = Membership.create_changeset_family_group_members(attrs, [])
      membership_fm ++ group_member
    end
    
    # The boths variables have the required data, which I checked with the inspector.

    # Here is where my code starts to sink. 
    # Since I have now X amount of memberships, I should do a loop over the list of memberships_fm. 
    # On my logic I should insert the first changeset, membership_cs, and then loop over the rest and insert them too. I am trying to follow the logic from Function B but I am confused on the loop part.
    
    multi_payer = Multi.insert(Multi.new(), :insert_membership, membership_cs) 
    multi =
        membership_fm
        |> Enum.reduce(multi_payer, fn group_member, multi_payer ->
            Multi.insert(Multi.new(), :insert_membership, membership_cs)
            |> Multi.run(:insert_family_group, fn repo, %{insert_membership: membership} ->
              Users.update_user(membership.user, %{membership_id: membership.id})
              membership_number = generate_membership_number(membership.membership_number)

              # Here where I think I should start to loop over membership_fm.
                repo.insert(
                  Ecto.Changeset.put_change(
                    group_member,
                    :membership_number,
                    membership_number
                  )
                )
              end
            end)
            |> Multi.run(:create_group, fn repo, %{insert_membership: payer_group} ->
              repo.insert(Group.create_changeset(attrs, payer_group))
            end)
            |> Multi.run(:update_group, fn repo, %{insert_family_group: memberships, create_group: group} ->
                # Here I insert the list of memberships into the group already created with the payer.
                 repo.update(Group.add_memberships_changeset([memberships], group))
            end)
       end)

    # List of events to record on the activity-logs table.
    ~w(register_membership accept_articles join_club)a
    |> Enum.reduce(multi, fn event, multi ->
      Multi.run(multi, {:insert_activity_log, event}, fn repo, %{insert_membership: membership} ->
        membership = Repo.preload(membership, :club)
        Users.update_user(membership.user, %{membership_id: membership.id})
        log_params = %{event: event, actor: :membership, data: %{club_name: membership.club.name}}

        %ActivityLog{}
        |> ActivityLog.create_changeset(membership.club, membership, log_params)
        |> repo.insert()
      end)
    end)
    |> Repo.transaction()
  end

I hope it’s enough information, if needed I can post more on the thread. I will truly appreciate your time to read and give me a feedback.

Thanks a lot! :smiley:

When I need to iterate on stuff I sometimes use Ecto.Multi — Ecto v3.7.1. In your case you should also notice that you’re not making a unique key per each insert.

Instead of using :insert_membership you should either use a tuple like {:insert_membership, something_unique_here} or just a key with a number contained inside it like String.to_atom("insert_membership_#{key}").

That’s where I’d start.

1 Like

Since you seem to be doing only DB operations in the transaction, I would suggest trying doing this with Repo.transaction. In cases where the Multi is not passed between contexts, I found working directly with the transactions easier.

To make this work, I usually follow these rules:

  • Make each function return an ok/error tuple,
  • Use with to code the happy path and have a fallback for the error path,
  • Wrap the whole thing in a transaction.

Something like:

def do_things() do
  Repo.transaction(fn ->
    with {:ok, foo} <- foo(),
         {:ok, bar} <- bar(foo),
         {:ok, baz} <- baz(bar) do
      # The transaction will return {:ok, baz}
      baz
    else
      # The transaction will return {:error, reason}
      {:error, reason} -> Repo.rollback(reason)
    end
  end)
end
3 Likes

Thanks @stefanchrobot and @dimitarvp Both answers helped me to go through a solution. Indeed the String.to_atom("insert_membership_#{key}") was a key part of it.

My solution:

def create_membership_with_family_group(attrs, club_id, plan_ids) do
    membership_cs =
      attrs
      |> Membership.create_changeset(plan_ids)

    membership_fm = []
    membership_fm = for _group_member <- attrs["group_members"] do
      group_member =
        attrs
        |> handle_payer(club_id)

      membership_fm ++ group_member
    end

    membership_fm_updated = handle_family_member(membership_fm)
    membership_fm_updated_cs = []
    membership_fm_updated_cs = for group_member <- membership_fm_updated do
      group_member =
        group_member
        |> Membership.create_changeset_family_group_members([])

        membership_fm_updated_cs ++ group_member
    end

    membership_fm = membership_fm_updated_cs |> Enum.with_index

    multi_payer = Multi.insert(Multi.new(), :insert_membership, membership_cs)
    multi =
      membership_fm
      |> Enum.reduce(multi_payer, fn {group_member, index}, acc ->
        Multi.run(acc, String.to_atom("insert_family_group_#{index}"), fn repo, %{insert_membership: membership} ->
          Users.update_user(membership.user, %{membership_id: membership.id})
          membership_number = generate_membership_number_fm(membership.membership_number, index)

          repo.insert(
            Ecto.Changeset.put_change(
              group_member,
              :membership_number,
              membership_number
            )
          )
        end)
      end)
# Here I realized that I was creating a group fo each member of the team, when I needed to create 1 group and add all the members of the team inside that group :facepalm:
    multi =
      multi
      |> Multi.run(:create_group, fn repo, %{insert_membership: payer_group} ->
        repo.insert(Group.create_changeset(attrs, payer_group))
      end)

    multi =
      membership_fm
      |> Enum.reduce(multi, fn {_group_member, index}, acc ->
        update_group = String.to_atom("update_group_#{index}")

        cond do
          update_group == :update_group_0 ->
            Multi.run(acc, update_group, fn repo, %{insert_family_group_0: membership, create_group: group} ->
              repo.update(Group.add_memberships_changeset([membership], group))
            end)

          update_group == :update_group_1 ->
            Multi.run(acc, update_group, fn repo, %{insert_family_group_1: membership, create_group: group} ->
              repo.update(Group.add_memberships_changeset([membership], group))
            end)

          update_group == :update_group_2 ->
            Multi.run(acc, update_group, fn repo, %{insert_family_group_2: membership, create_group: group} ->
              repo.update(Group.add_memberships_changeset([membership], group))
            end)

          update_group == :update_group_3 ->
            Multi.run(acc, update_group, fn repo, %{insert_family_group_3: membership, create_group: group} ->
              repo.update(Group.add_memberships_changeset([membership], group))
            end)

          update_group == :update_group_4 ->
            Multi.run(acc, update_group, fn repo, %{insert_family_group_4: membership, create_group: group} ->
              repo.update(Group.add_memberships_changeset([membership], group))
            end)

          update_group == :update_group_5 ->
            Multi.run(acc, update_group, fn repo, %{insert_family_group_5: membership, create_group: group} ->
              repo.update(Group.add_memberships_changeset([membership], group))
            end)

          update_group == :update_group_6 ->
            Multi.run(acc, update_group, fn repo, %{insert_family_group_6: membership, create_group: group} ->
              repo.update(Group.add_memberships_changeset([membership], group))
            end)

          update_group == :update_group_7 ->
            Multi.run(acc, update_group, fn repo, %{insert_family_group_7: membership, create_group: group} ->
              repo.update(Group.add_memberships_changeset([membership], group))
            end)

          update_group == :update_group_8 ->
            Multi.run(acc, update_group, fn repo, %{insert_family_group_8: membership, create_group: group} ->
              repo.update(Group.add_memberships_changeset([membership], group))
            end)

          update_group == :update_group_9 ->
            Multi.run(acc, update_group, fn repo, %{insert_family_group_9: membership, create_group: group} ->
              repo.update(Group.add_memberships_changeset([membership], group))
            end)

          update_group == :update_group_10 ->
            Multi.run(acc, update_group, fn repo, %{insert_family_group_10: membership, create_group: group} ->
              repo.update(Group.add_memberships_changeset([membership], group))
            end)
        end
      end)

    ~w(register_membership accept_articles join_club)a
    |> Enum.reduce(multi, fn event, multi ->
      Multi.run(multi, {:insert_activity_log, event}, fn repo, %{insert_membership: membership} ->
        membership = Repo.preload(membership, :club)
        Users.update_user(membership.user, %{membership_id: membership.id})
        log_params = %{event: event, actor: :membership, data: %{club_name: membership.club.name}}

        %ActivityLog{}
        |> ActivityLog.create_changeset(membership.club, membership, log_params)
        |> repo.insert()
      end)
    end)
    |> Repo.transaction()
  end

At the moment of updating the created group, I have to pattern match with the key I used with String.to_atom("insert_family_group_#{index}"). I couldn’t find a way to make the key “dynamic” so that I pattern match like %{insert_family_group_"#{index}": membership, …} for instance
I tried doing something like

map = %{"insert_family_group_#{index}" => membership , "create_group": group}
map = for {key, val} <- map, into: %{}, do: {String.to_atom(key), val}
iex> %{insert_family_group_0: membership, create_group: group}

but membership and group are not defined. Is there any way to solve that?

Thanks for the replies, and sorry for the late reply :beers:

The key can be any term, so you can do update_group = {:update_group, index}. This should help you remove the repetitive code.

1 Like

That’s a super long function. Break it apart a little bit. :slight_smile:

1 Like

Hey guys @dimitarvp @stefanchrobot . Thanks again for the reply! Now the code is way much shorter, and thanks @stefanchrobot for the tip, it helped a lot to get rid of the repetitive code :slight_smile:

Thanks! :smiley: :beers:

1 Like