Using put_assoc in changeset for many to many

Hi,

Just started working on my first Phoenix/Elixir app and ran into a problem with Ecto.

In my app, I have an Artist and Song model. They have a many_to_many relationship via Performance model

schema "artists" do
  field :name, :string
  field :channel_id, :string
  field :channel_title, :string

  many_to_many :songs, Song, join_through: "performances"
  has_many :performances, Performance

  timestamps()
end

schema "songs" do
  field :name, :string
  many_to_many :artists, Artist, join_through: "performances"
  has_many :performances, Performance

  timestamps()
end

schema "performances" do
  field :yt_video_id, :string
  field :yt_view_count, :integer
  field :yt_title, :string
  field :verified, :boolean, default: false
  field :published_date, Ecto.Date
  belongs_to :artist, Artist
  belongs_to :song, Song

  timestamps()
end

The way the database is populated is via performances table.
When I add an record to performance, it will always come with artist name, song name and the relevant performance fields

I want to first try to retrieve artist from the artists table, and only create if it is not found
The same goes for song. After these two records are found/created. I will then create a performance record to associate them up.

Based on my understanding, I should use put_assoc in this case since artist and song are created independently from performance

This is how my changeset for performance is written now

def changeset(struct, params \\ %{}) do
  struct
  |> cast(params, [:yt_video_id, :yt_view_count, :yt_title, :verified, :published_date])
  |> validate_required([:yt_video_id, :yt_view_count, :yt_title, :verified, :published_date])
  |> put_assoc(:song, get_or_create_song(params["song"]))
  |> put_assoc(:artist, get_or_create_artist(params["artist"]))
end

def get_or_create_artist(artist_params) do
  case artist_params["name"] || [] do
    [] -> []
    artist_name ->
      Repo.get_by(Artist, name: artist_name) ||
        Repo.insert!(Artist.changeset(%Artist{}, artist_params))
  end
end

def get_or_create_song(song_params) do
  case song_params || [] do
    [] -> []
    song_params ->
      case song_params["name"] || [] do
        [] -> []
        song_name ->
          Repo.get_by(Song, name: song_name) ||
            Repo.insert!(Song.changeset(%Song{}, song_params))
      end
  end
end

I ran into this problem for edit route for performance
i.e http://localhost:4000/performances/10/edit

The error message:
no function clause matching in anonymous fn/1 in Ecto.Changeset.Relation.struct_pk/2

The call stack:
Request: GET /performances/9/edit
** (exit) an exception was raised:
** (FunctionClauseError) no function clause matching in anonymous fn/1 in Ecto.Changeset.Relation.struct_pk/2
(ecto) lib/ecto/changeset/relation.ex:357: anonymous fn([]) in Ecto.Changeset.Relation.struct_pk/2
(ecto) lib/ecto/changeset/relation.ex:225: Ecto.Changeset.Relation.single_change/6
(ecto) lib/ecto/changeset.ex:1083: Ecto.Changeset.put_relation/5
(covered) web/models/performance.ex:26: Covered.Performance.changeset/2
(covered) web/controllers/performance_controller.ex:63: Covered.PerformanceController.edit/2

And the line that causes trouble is
|> put_assoc(:song, get_or_create_song(params[“song”]))

Some questions I have:

  1. Is my assumption that put_assoc should be used in this case? I assume yes because I do not want duplicated artist/song for every entry of performance.
  2. The thing is that for edit route, there is no need to go through the artist/song retrieval/creation flow, since the performance record already has the data, in that case, should I be adding a different flow for changeset for edit, or should artist/song retrieval/creation be part of performances changeset?

This is the blogpost that I have been following http://blog.plataformatec.com.br/2016/12/many-to-many-and-upserts/

Any feedback or help is appreciated

2 Likes

I will improve the error message in Ecto but you are returning an empty list when there is no song_params to put_assoc. However, that put_assoc is about a belongs_to relationship and that expects either nil or a struct.

2 Likes

To clarify, here is how to rewrite your functions:

def get_or_create_artist(artist_params) do
  if name = artist_params["name"] do
    Repo.get_by(Artist, name: artist_name) ||
      Repo.insert!(Artist.changeset(%Artist{}, artist_params || []))
  end
end

Now it will return nil if there are no parameters instead of an empty list which would then cause the error above.

2 Likes

Thanks Jose! I am able to get past that error now.

2 Likes