Search code examples
databaseelixirassociationsecto

Elixir Phoenix update nested many-to-many association


I'm using Elixir & Phoenix. I have the schemas Venues.Team and Accounts.Job with a many-to-many relationship between them.

defmodule Runbook.Venues.Team do
  use Ecto.Schema
  import Ecto.Changeset

  schema "teams" do
    field :name, :string
    belongs_to :venue, Runbook.Venues.Venue
    many_to_many :employees, Runbook.Accounts.Job, join_through: "jobs_teams"

    timestamps()
  end

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

and

defmodule Runbook.Accounts.Job do
  use Ecto.Schema
  import Ecto.Changeset

  schema "jobs" do
    field :role, :string
    belongs_to :user, Runbook.Accounts.User
    belongs_to :venue, Runbook.Venues.Venue
    many_to_many :teams, Runbook.Venues.Team, join_through: "jobs_teams"

    timestamps()
  end

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

I have the default Venues.update_team/2 method:

def update_team(%Team{} = team, attrs) do
  team
  |> Team.changeset(attrs)
  |> Repo.update()
end

I want to be able to include a jobs argument inside the attrs parameter when updating a Team. This should insert an association into the :employees field.

I can do this in the interactive elixir shell (from ElixirSchool docs)

team = Venues.get_team!(1)
team_changeset = Ecto.Changeset.change(team)                                                                                                                               
team_add_employees_changeset = team_changeset |> put_assoc(:employees, [job])                                                                                              
Repo.update!(team_add_employees_changeset)

But I'm not sure how to abstract this to the update_team method, which builds the changeset up without a database call.

When I try to do it:

%Team{}
|> Team.changeset(%{id: 1, name: "Floor"})
|> put_assoc(:employees, [job])
|> Repo.update()

I get an error complaining that the :employees association is not loaded:

** (Ecto.NoPrimaryKeyValueError) struct `%Runbook.Venues.Team{__meta__: #Ecto.Schema.Metadata<:built, "teams">, employees: #Ecto.Association.NotLoaded<association :employees is not 
loaded>, id: nil, inserted_at: nil, name: nil, updated_at: nil, venue: #Ecto.Association.NotLoaded<association :venue is not loaded>, venue_id: nil}` is missing primary key value   
    (ecto) lib/ecto/repo/schema.ex:898: anonymous fn/3 in Ecto.Repo.Schema.add_pk_filter!/2                                                                                          
    (elixir) lib/enum.ex:1948: Enum."-reduce/3-lists^foldl/2-0-"/3                                                                                                                   
    (ecto) lib/ecto/repo/schema.ex:312: Ecto.Repo.Schema.do_update/4

I can sorta do it like this:

changeset = Team.changeset(%Team{}, %{id: 1, name: "Floor"})
team = Venues.get_team!(1) |> Repo.preload(:employees)
preloaded_changeset = %Ecto.Changeset{changeset | data: team}
preloaded_changeset |> put_assoc(:employees, [job]) |> Repo.update()

(untested, a cleaner version of the below)

 %Ecto.Changeset{tc | data: Venues.get_team!(1) |> Repo.preload(:employees)} |> put_assoc(:employees, [job]) |> Repo.update()

What is the best/cleanest/most conventional way to do this?


Solution

  • You can update by using Ecto.Changeset.cast_assoc/3

    def update_team(%Team{} = team, attrs) do
      team
      |> Repo.preload(:employees) # has to be preloaded to perform update
      |> Team.changeset(attrs)
      |> Ecto.Changeset.cast_assoc(:employees, with: &Job.changeset/2)
      |> Repo.update
    end
    

    update form

    <%= form_for @changeset, @action, fn f -> %>
      <%= if @changeset.action do %>
        <div class="alert alert-danger">
          <p>Oops, something went wrong! Please check the errors below.</p>
        </div>
      <% end %>
    
      <%= label f, :name %>
      <%= text_input f, :name %>
      <%= error_tag f, :name %>
    
     <div class="form-group">
         <%= inputs_for f, :job, fn cf -> %>
         <%= label cf, :job_param_1 %>
         <%= text_input cf, :job_param_1 %>
         <%= error_tag cf, :job_param_1 %>
       <% end %>
       </div>
    
      <div class="form-group">
         <%= inputs_for f, :job, fn cf -> %>
         <%= label cf, :job_param_2 %>
         <%= text_input cf, :job_param_2 %>
         <%= error_tag cf, :job_param_2 %>
       <% end %>
       </div>
    
      <div>
        <%= submit "Save" %>
      </div>
    <% end %>