Update Ecto Many to many relation (and weird related map behavior)

Hi,

I am new to Elixir and Phoenix, and I just did the Pragmatic Studio Elixir and Live view courses.
I started a Live view project, and I don’t understand something about a many to many relationship update in my project.

Here is the situation:
I initiated the Authentication module, works just fine.
I created a Context and schema called Groups, in which User can be part of. Each group has a “name”, and can have many users through a join table.
I created a many to many relationship between users and groups, called “members” within the groups.

The creation and deletion of groups work well.

But when I try to update the “name” (string) field of my group, if I only give the “name” field in the params of the changeset, I get an error as errors: [members: {"is invalid", [type: {:array, :map}]} ]. (full error below)

The only way I managed to make it work so far, is to manually add the existing users to the changeset params:

defp save_group(socket, :edit, %{"name" => _} = group_params) do
    case Groups.update_group(
           socket.assigns.group,
           **Map.put(group_params, "members", socket.assigns.group.members)**
         ) do
      {:ok, group} ->
        notify_parent({:saved, group})

        {:noreply,
         socket
         |> put_flash(:info, "Group updated successfully")
         |> push_patch(to: socket.assigns.patch)}

      {:error, %Ecto.Changeset{} = changeset} ->
        Logger.error("group editing error")
        {:noreply, assign(socket, form: to_form(changeset))}
    end
  end

I don’t find it convenient because I imagine if I had more fields in my struct, I would also have to pass them manually every time.
Is there a better way to do this?

For better context, here is the error I got initially:

[data: 
	%Wallet.Groups.Group{
		__meta__: #Ecto.Schema.Metadata<:loaded, "groups">, 
		id: 3, name: "family",
		 members: [
			#Wallet.Accounts.User<__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
			 id: 1, 
			email: "test@gmail.com", 
			confirmed_at: nil, 
			inserted_at: ~U[2024-06-29 19:24:02Z],
			 updated_at: ~U[2024-06-29 19:24:02Z], ...>
		],
		 inserted_at: ~U[2024-06-30 10:34:12Z], 
		updated_at: ~U[2024-06-30 10:34:12Z]
	},
	 prepare: [],
	 filters: %{},
	 params: %{"name" => "Family"},
	 __struct__: Ecto.Changeset,
	 required: [],
	 errors: [
		members: {"is invalid", [type: {:array, :map}]}
	],
	action: nil, 
	valid?: false, 
	constraints: [], 
	types: %{
		id: :id, 
		name: :string, 
		members: {
			:assoc, 
			%Ecto.Association.ManyToMany{
				field: :members,
				owner: Wallet.Groups.Group, 
				related: Wallet.Accounts.User, 
				owner_key: :id,
				queryable: Wallet.Accounts.User, 
				on_delete: :nothing, 
				on_replace: :raise, 
				join_keys: [group_id: :id, user_id: :id], 
				join_through: "group_members",
				on_cast: nil,
				where: [], 
				join_where: [], 
				defaults: [], 
				join_defaults: [], 
				relationship: :child, 
				cardinality: :many, 
				unique: false, 
				ordered: false, 
				preload_order: []
			}
		}, 
		inserted_at: :utc_datetime, 
		updated_at: :utc_datetime
	}, 
	repo: nil, 
	empty_values: [&Ecto.Type.empty_trimmed_string?/1], 
	validations: [], 
	changes: %{name: "Family"}, 
	repo_opts: []
]

The Schema:

defmodule Wallet.Groups.Group do
  use Ecto.Schema

  schema "groups" do
    field :name, :string
    many_to_many :members, Wallet.Accounts.User, join_through: "group_members"
    timestamps(type: :utc_datetime)
  end

  def changeset(struct, params \\ %{}) do
    struct
    |> Ecto.Changeset.cast(params, [:name])
    |> Ecto.Changeset.put_assoc(:members, Map.get(params, "members"), required: false)
  end
end

The code giving me the error:

defp save_group(socket, :edit, group_params) do
    case Groups.update_group(socket.assigns.group, group_params) do
      {:ok, group} ->
        notify_parent({:saved, group})

        {:noreply,
         socket
         |> put_flash(:info, "Group updated successfully")
         |> push_patch(to: socket.assigns.patch)}

      {:error, %Ecto.Changeset{} = changeset} ->
        Logger.error("group editing error")
        {:noreply, assign(socket, form: to_form(changeset))}
    end
  end

Finally, the weird map behavior I am getting is the following:
in save_group function, the group_params I am receiving are the following: [{"name", "Test"}] so I thought I could cast this to a Map, that I would then Merge into the existing date, to update the changed values and keep the existing ones, but Elixir doesn’t successfully cast this to a map, even using Map.new, here are my logs with (before on the first line, and after using Map.new):

[debug] [{"name", "Test"}]
[debug] tuple to map
[debug] [{"name", "Test"}]

Although when I try this in a iex session, it works perfectly :frowning:

Map.new([{"name", "Test"}])
%{"name" => "Test"}

Does anyone have an idea why this doesn’t work in my Phoenix app?

Hello and welcome to the forums!

I’m gonna throw a whole bunch of info at you so feel free to ask further questions :slight_smile:

Yes, you are calling put_assoc which is always going to put whatever you give it, including nil. Your required: false is actually ignored here since it’s not a valid option. It’s a bit unfortunate that invalid options are just ignored and to top it off, even though put_assoc accepts options, it ignores them! (see docs). Regardless, required: false doesn’t make any sense for put_assoc because you are explicitly giving it a value to put. If it ignored nil then there would be no way to nilify an association!

put_assoc and cast_assoc can be tough to grok at first and there are a bunch of things to consider here, but really I think what you want is cast_assoc.

  def changeset(struct, params \\ %{}) do
    struct
    |> Ecto.Changeset.cast(params, [:name])
    |> Ecto.Changeset.cast_assoc(:members)
  end

Note, there is no Map.get and while cast_assoc does accept a :required option, the default is false. This will leave the member association alone if there is no members key present in params. The thing to note, though, is that if you do want to update/add members, you still need to pass all of them since cast_assoc also works with the whole association. This also requires you have a changeset/2 function on your Member schema as cast_assoc implicitly uses it. If you’d like to be explicit you can write is as: cast_assoc(:member, with: &Member.changeset/2)

Another option is to have different changesets for different purposes, and even different context functions for different purposes. For example you could have create_group, update_group, delete_group but then have Group.join_group(group, member) and Group.leave_group(group, member), so adding and removing members are separate operations. This is good if you have a lot of members but also nice because it explicitly spells out in functions things your app does as opposed to passing some params to a “save” function and having a bunch of other stuff happen. As always, YMMV.

Just to throw a little more at you, I would not do things like Map.get in a changeset function. I alway say there is wisdom in how the generators call that argument attrs instead of params. This is because changeset functions should work wether they are passed known data (atom keys) or untrusted data (string keys). Whenever I use put_assoc, I always pass them as parameters to the changeset function to spell out the contract and indicate they are required.

def add_user_changeset(post, user, attrs) do
  post
  |> cast(attrs, [:name])
  |> put_assoc(:user, user)
end
5 Likes

sodapopcan,

Thank you so much for your answer!

As you imagined I have quite a few questions :smiley:

Related to cast_assoc, I actually started using it, but for the creation function, as I was passing the User struct to fill the “members” of my Group, I was receiving an error. If I am correct, it is because put_assoc uses the structs themselves whereas cast_assoc expects a Map. Did I get this right?

Then thanks for the advice of create multiple changeset functions instead of trying to fit all in one, I definitely changed my code for that!

In the end, for my current problem what worked here was to actually create a changeset function that would only create a changeset being:

def edit_name_changeset(struct, params \\ %{}) do
    struct
    |> Ecto.Changeset.cast(params, [:name])
 end

I also followed your advice to remove the Map.get in the changeset function and do it earlier instead, to simply give the part I want as a parameter to the function.

Thanks again for your help!!

One finaly question though, regarding your note if I needed to update/add members to the group. Would I really need to have a changeset/2 function in the Member context? Because the groups and members are related through a join table, so wouldn’t the join table be updated automatically if I added a new member to a group?

Yes, this is correct!

You shouldn’t need to use Map.get anywhere (though it’s possible depending on what your calling code looks like). One purpose of the cast function is to take care of this for you. It’s casting data from one form into another! Given a plain map of string or atom keys, it will only take what you tell it to. Which leads to a couple of things:

Your edit_name_changeset could just be edit_changeset which would allow you to expand it in the future:

def edit_changeset(struct, attrs) do
  struct
  |> cast(attrs, [:name, :some_other_column])
end

Unless you validate_required on :some_other_column, it’s totally cool to just call edit_changeset(struct, %{"name" => "Some name"}).

You need Member.changeset/2 if you were to pass untrusted params like %{"name" => "Name", "members" => %{"id" => "1", name: "Member"}} because it needs to know how to properly cast and validate the data. Since you are using put_assoc it would not be necessary here (although I assume you have some member changeset!)

In your case it might be easier to add the association through the user so that you don’t have to always work with the full list of members.

defmodule MyApp.Groups do
  def join_group(%Group{} = group, %Member{} = member) do
    changeset =
      member
      |> Ecto.Changeset.change()
      |> Ecto.Changeset.put_assoc(:group, group)

    with {:ok, member} <- Repo.update(changeset) do
      group = %{group | members: [member | group.members]} # could also be Repo.preload(group, :members)

      {:ok, group}
    end
  end
end

Note that I’m using Changeset.change instead of cast here because member is a Member struct so there is no need to cast it. This is also off the top of my head—the ergonomics are a bit weird since it returns {:ok, group} on success and {:error, member_changeset} on failure, though I personally don’t think it’s that bad. It depends on it’s going to be used! You also don’t have to do it this way :slight_smile: