Changing data on a many to many relation

Hello, I’m new to Elixir and I’'m strugling with the next problem.

As an admin, I have the power to change roles on other users. Which means I can change their role from member to admin or superuser. The project I’m working in right now uses a many to many relationship between the roles and the users. Now I need to update the role but that is not an actual option in the changeset. So passing this with a put_assoc does not work as I’m getting a “no function clause matching in …”

I have no clue how to achieve it but that could be because of my unexistig knowledge and the code structure so maybe you guys can see how it is setup and get me back on track…

So this is the select option:

    <div class="form-group">
    <%= label f, :role, class: "control-label" %>
    <%= select f, :role, MailgunLogger.Roles.list_roles(), class: "form-control" %>
  </div>

When I inspect the params on this update_user function, I get the role param with the ID that matches the role I picked in the selector.

  @spec update_user(User.t(), map) :: ecto_user()
  def update_user(user, params) do
    user
    |> User.update_changeset(params)
    |> Repo.update()
  end

So given the next schema for a user:

  schema "users" do
    field(:email, :string)
    field(:firstname, :string)
    field(:lastname, :string)
    field(:token, :string)
    field(:encrypted_password, :string)
    field(:reset_token, :string, default: nil)
    field(:password, :string, virtual: true)

    many_to_many(:roles, Role, join_through: UserRole, on_replace: :delete)

    timestamps()
  end

You can see that there is a many to many relation between roles and users => the role of a user cannot be changed directly in the user table but in the roles_user table.

I’m trying to change the role with the put_assoc but it just keeps giving me the no function clause matching error…

  @doc false
  @spec changeset(User.t(), map()) :: Ecto.Changeset.t()
  def changeset(%User{} = user, attrs \\ %{}) do
    user
    |> cast(attrs, [:firstname, :lastname, :email, :password])
    |> validate_required([:email, :password])
    |> update_change(:email, &String.downcase/1)
    |> validate_format(:email, @email_format)
    |> validate_length(:password, min: 8)
    |> unique_constraint(:email)
    |> hash_password()
    |> generate_token()
  end

  @doc false
  @spec update_changeset(User.t(), map()) :: Ecto.Changeset.t()
  def update_changeset(%User{} = user, attrs \\ %{}) do
    role_id = String.to_integer(attrs["role"])
    user
    |> cast(attrs, [:firstname, :lastname, :email])
    |> update_change(:email, &String.downcase/1)
    |> validate_format(:email, @email_format)
    |> unique_constraint(:email)
    |> put_assoc(:roles, [Roles.get_role_by_id(role_id)])
  end

I tried the put_assoc because when no users are registered, you have to create an admin account which gives you a role of admin, what can bee seen here:

  @spec admin_changeset(User.t(), map()) :: Ecto.Changeset.t()
  def admin_changeset(%User{} = user, attrs) do
    user
    |> cast(attrs, [:email, :password])
    |> unique_constraint(:email)
    |> validate_required([:email, :password])
    |> update_change(:email, &String.downcase/1)
    |> validate_format(:email, @email_format)
    |> validate_length(:password, min: 8)
    |> hash_password()
    |> generate_token()
    |> put_assoc(:roles, [Roles.get_role_by_name("admin")]) # Here is the put_assoc
  end

So anyone that could get an idea how it could work? Thanks in advance!

Don’t put_assoc on :roles. You’re not editing the roles. You’re editing the relationship to roles being modeled by UserRole. You want put_assoc(cs, :user_roles, [%{role_id: role.id}]).

Maybe a stupid question but what does cs mean as a first parameter and how should I define it as for now it gives an error as an undefined variable…

cs would be short for changeset. The one you pipe in your code.

So is this the right code?

  @doc false
  @spec update_changeset(User.t(), map()) :: Ecto.Changeset.t()
  def update_changeset(%User{} = user, attrs \\ %{}) do
    role_id = String.to_integer(attrs["role"])
    user
    |> cast(attrs, [:firstname, :lastname, :email])
    |> update_change(:email, &String.downcase/1)
    |> validate_format(:email, @email_format)
    |> unique_constraint(:email)
    |> put_assoc(cs, [%{role_id: role_id}])
  end

No.

put_assoc(cs, :user_roles, [%{role_id: role.id}])

# or

cs
|> put_assoc(:user_roles, [%{role_id: role.id}])

Well for the moment he still complains about cs is an undefined variable…

And does elixir knows what do change with the newly created :user_roles ?

I guess I’ll need to explain a bit more:

Yes cs does not exist in your code. You need to combine my code snippet with our existing code to:

def update_changeset(%User{} = user, attrs \\ %{}) do
    role_id = String.to_integer(attrs["role"])
    user
    |> cast(attrs, [:firstname, :lastname, :email])
    |> update_change(:email, &String.downcase/1)
    |> validate_format(:email, @email_format)
    |> unique_constraint(:email)
    |> put_assoc(:user_roles, [%{role_id: role_id}])
  end

Also your schema does need to change to get to know about :user_roles by adding:

has_many(:user_roles, UserRole, on_replace: :delete)

You can leave the many_to_many if you want, but for this write operation it’s unnecessary.

Thanks, we’re coming closer.

I got this error:

attempting to cast or change association `user_roles` from `MailgunLogger.User` that was not loaded. Please preload your associations before manipulating them through changesets

Does this mean I need to do a migrate to make sure the new has_many relation is operational?

Also, I put the has_many below the many_to_many. Is that the correct spot to put it?

Nope. It means you somewhere need to run Repo.preload(user, [:user_roles]), likely where you right now preload :roles. put_assoc works by comparing the set of existing data with the set of data you provided to figure out what needs inserting/updating/deleting. Not having access to the existing data therefore is a problem.

Doesn’t matter at all, so what you got is fine.

I was navigating through the pages and I found this on user_role.ex

  @doc false
  @spec changeset(UserRole.t(), map()) :: Ecto.Changeset.t()
  def changeset(%UserRole{} = user_role, attrs \\ %{}) do
    user_role
    |> cast(attrs, [:user_id, :role_id])
    |> validate_required([:user_id, :role_id])
  end

So it seems there already is a changeset to update the user role.
Can I import that into the other pages so it get updates as well with the user changeset?

The list given to put_assoc can work with changeset as well.

Personally I’d probably even switch the form to use inputs_for for the role selector and use cast_assoc, which would call the changeset of UserRole automatically.

Well. the action only changes the users now so I don’t think I can implement another changeset, right?

<%= render "form.html", action: Routes.user_path(@conn, :update, @user), changeset: @changeset, new?: false, flash: @flash %>

Where should I put cast_assoc then? In the userrole changeset?

You can take a look a the first few sections of - up and including the “the form” titled section:

The blog post uses embeds, but assocs work the same for the most part.

This question pops up regularly in different ways. I suggest we call it the first Ecto Mini-Boss :grin:

I wonder if the docs could be improved?

2 Likes

It’s a boss fight for sure, been looking for a solve since 11AM…
And I’m not the best fan of the elixir docs… :confused:

Yep, I’ve been there :slight_smile:

How long did it for you to let it make sense?
Also, I’ve almost got my solution with my freestyle code :crazy_face:
But still have a bug to fix tho :stuck_out_tongue:

Here’s another good article about it.

Thanks for the article but I just got it!
@LostKobrakai Thank you for your guidance, some small teaks were neccasary but it works and I kinda got it.

Just one thing that maybe need some clarification:

    many_to_many(:roles, Role, join_through: UserRole, on_replace: :delete)
    has_many(:user_roles, UserRole, on_replace: :delete)

Why did I need the has_many in order to make it work?
Why was the many to many not good enough?