Inserting into many-to-many association with extra fields?

Hello, I have some troubles with inserting into a many-to-many association with one extra field.

I started using the keyword many_to_many in the schema but read somewhere that it didn’t support the extra field so I switched it up to has_many with a through parameter.

Anyway, here is the code:

defmodule Myapp.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :email, :string
    field :password, :string, virtual: true, redact: true
    field :hashed_password, :string, redact: true
    field :confirmed_at, :naive_datetime

    has_many :user_groups, MyApp.Join.UserGroup
    has_many :groups, through: [:user_groups, :group]
    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(user, attrs) do
    user
    |> cast(attrs, [:email])
    |> validate_required([:email])
  end
end

defmodule Planview.Join.UserGroup do
  use Ecto.Schema

  schema "user_groups" do
    belongs_to :group, Myapp.Groups.Group
    belongs_to :user, Myapp.Accounts.User

    field :is_admin, :boolean
    timestamps()
  end

  def changeset(struct, params \\ %{}) do
    struct
    |> Ecto.Changeset.cast(params, [:is_admin])
    |> Ecto.Changeset.cast_assoc(:user, required: true)
  end
end

defmodule Planview.Groups.Group do
  use Ecto.Schema
  import Ecto.Changeset
  alias Planview.Repo
  alias Planview.Join.UserGroup
  alias Planview.Accounts

  schema "groups" do
    field :name, :string

    has_many :properties, Planview.Properties.Property
    has_many :user_groups, Planview.Join.UserGroup
    has_many :users, through: [:user_groups, :user]
    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(group, attrs) do
    group
    |> cast(attrs, [:name])
    |> validate_required([:name])
  end
end

# groups.ex

  @doc false
  def create_new_group(attrs, user_id, is_admin \\ false) do
    # Start a transaction to ensure data integrity
    multi = Ecto.Multi.new()
      |> Ecto.Multi.insert(:group, Group.changeset(%Group{}, attrs))
      |> Ecto.Multi.insert(:user_group, fn %{group: %Group{id: group_id}} ->
        # Assuming get_user! returns a user struct or raises an error if not found
        user = Myapp.Accounts.get_user!(user_id)
        group = Repo.get!(Group, group_id)
        # Create the association with is_admin set
        Myapp.Join.UserGroup.changeset(%Planview.Join.UserGroup{}, %{user: user, group: group, is_admin: is_admin})
      end)
      |> Repo.transaction()

    case multi do
      {:ok, multi_result} ->
        {:ok, multi_result}

      {:error, changeset} ->
        {:error, changeset}

      {:error, _, changeset, _} ->
        {:error, changeset}
    end
  end

My real problem occurs when I am calling create_new_group because it doesn’t insert anything and crashes. I have tried to redo it several times but fails each time.

This is my current error:

** (CaseClauseError) no case clause matching: {:error, :user_group, #Ecto.Changeset<action: :insert, changes: %{is_admin: true}, errors: [user: {"can't be blank", [validation: :required]}], data: #Myapp.Join.UserGroup<>, valid?: false>, %{group: %Myapp.Groups.Group{__meta__: #Ecto.Schema.Metadata<:loaded, "groups">, id: 10, name: "My group 1", properties: #Ecto.Association.NotLoaded<association :properties is not loaded>, user_groups: #Ecto.Association.NotLoaded<association :user_groups is not loaded>, users: #Ecto.Association.NotLoaded<association :users is not loaded>, inserted_at: ~U[2024-02-05 09:06:49Z], updated_at: ~U[2024-02-05 09:06:49Z]}}}
    (planview 0.1.0) lib/myapp_web/live/group_live/form_component.ex:73: MyappWeb.GroupLive.FormComponent.save_group/3

I was able to use the many_to_many before and create a association, but I failed to grok how to do it when I wanted to add the is_admin to the association. The documentation is kind of sparse on how to do it as well.
Can anyone explain to an elixir noob on what I am doing wrong here? How can I insert a new group with a proper relationship with the user?

Thanks for your sage advice and better wisdom!

Changesets are useful, but only if you need what they do. Here’s a simpler option:

    multi = Ecto.Multi.new()
      |> Ecto.Multi.insert(:group, Group.changeset(%Group{}, attrs))
      |> Ecto.Multi.insert(:user_group, fn %{group: %Group{id: group_id}} ->
        # Create the association with is_admin set
        %Planview.Join.UserGroup{user_id: user_id, group_id: group_id, is_admin: is_admin}
      end)
      |> Repo.transaction()
3 Likes

Welcome @confused !

Something that stuck out to me is how there’s two database read operations within the Multi.insert step for :user_group. I’d suggest fetching the user in its own step, maybe via Multi.put. And then instead of refetching the group created from the Multi.insert in the step above, you can just reference it directly.

 def create_new_group(attrs, user_id, is_admin \\ false) do
    multi = Ecto.Multi.new()
      |> Ecto.Multi.put(:user, Myapp.Accounts.get_user!(user_id))
      |> Ecto.Multi.insert(:group, Group.changeset(%Group{}, attrs))
      |> Ecto.Multi.insert(:user_group, fn %{user: user, group: group} ->
        Myapp.Join.UserGroup.changeset(%Planview.Join.UserGroup{}, %{user: user, group: group, is_admin: is_admin})
      end)
      |> Repo.transaction()
    ...
3 Likes