Insert many to many ecto was harder because lack of info

See: Example: Adding tags to a post

TLDNR: For put_assoc to work as expected it needs to be handed full schema structs, not just IDs (plain maps or keyword lists lead to Ecto trying to insert a new record on the other end of the association).

This works without referring to the association table:

  # Usually in MusicDB.Album
  def changeset(album, params \\ %{}) do
    # obtain genres as full schema structs
    genres = fetch_selected_genres(params["genres"])

    album
    |> EC.cast(params, [:title])
    |> EC.put_assoc(:genres, genres)
    |> EC.validate_required([:title])
  end

(I also find that it is usually necessary to show the schemas involved to make sense of some of the changeset and association code)

# file: lib/music_db/artist.ex

defmodule MusicDB.Artist do
  use Ecto.Schema
  import Ecto.Changeset
  alias MusicDB.{Artist, Album}

  schema "artists" do
    field(:name)
    field(:birth_date, :date)
    field(:death_date, :date)
    timestamps()

    has_many(:albums, Album)
    has_many(:tracks, through: [:albums, :tracks])
  end

  def changeset(artist, params) do
    artist
    |> cast(params, [:name, :birth_date, :death_date])
    |> validate_required([:name])
  end

  def changeset(%MusicDB.Band{} = band) do
    {:ok, birth_date} = Date.new(band.year_started, 1, 1)
    {:ok, death_date} = Date.new(band.year_ended, 12, 31)

    changeset(
      %Artist{
        name: band.name,
        birth_date: birth_date,
        death_date: death_date
      },
      %{}
    )
  end

  def changeset(%MusicDB.SoloArtist{} = solo_artist) do
    name =
      "#{solo_artist.name1} #{solo_artist.name2} #{solo_artist.name3}"
      |> String.trim()

    changeset(
      %Artist{
        name: name,
        birth_date: solo_artist.birth_date,
        death_date: solo_artist.death_date
      },
      %{}
    )
  end
end
# file: lib/music_db/album.ex

defmodule MusicDB.Album do
  use Ecto.Schema
  alias MusicDB.{Artist, Track, Genre}

  schema "albums" do
    field(:title, :string)
    timestamps()

    belongs_to(:artist, Artist)
    has_many(:tracks, Track)
    many_to_many(:genres, Genre, join_through: "albums_genres")
  end
end
# file: lib/music_db/album_genre.ex

defmodule MusicDB.AlbumGenre do
  use Ecto.Schema
  alias MusicDB.{Album, Genre}

  schema "albums_genres" do
    belongs_to(:albums, Album)
    belongs_to(:genres, Genre)
  end
end
# file: lib/music_db/genre.ex

defmodule MusicDB.Genre do
  use Ecto.Schema
  alias MusicDB.Album

  schema "genres" do
    field(:name)
    field(:wiki_tag)
    timestamps()

    many_to_many(:albums, Album, join_through: "albums_genres")
  end
end
# file: music_db/priv/repo/playground.exs
#
# http://www.pragmaticprogrammer.com/titles/wmecto
# https://pragprog.com/titles/wmecto/source_code
# http://media.pragprog.com/titles/wmecto/code/wmecto-code.zip
#
# pg_ctl -D /usr/local/var/postgres start
# mix format ./priv/repo/playground.exs
# mix run ./priv/repo/playground.exs
#

defmodule AppInfo do
  def string() do
    Application.loaded_applications()
    |> Enum.map(&to_app_keyword/1)
    |> Enum.sort_by(&map_app_name/1)
    |> Enum.map_join(", ", &app_keyword_to_string/1)
  end

  defp to_app_keyword({app, _, vsn}),
    do: {app, vsn}

  defp app_keyword_to_string({app, vsn}),
    do: "#{app}: #{vsn}"

  defp map_app_name({app, _}),
    do: app
end

defmodule Playground do
  import Ecto.Query
  alias Ecto.Changeset, as: EC
  alias Ecto, as: E
  alias MusicDB.Repo
  alias MusicDB.{Artist, Album, Genre}

  def query_bill_evans do
    from(a in Artist, where: a.name == "Bill Evans")
    |> Repo.one()
  end

  def cons_integer(text, values) do
    case Integer.parse(text) do
      {value, _rest} ->
        [value | values]
      _ ->
        values
    end
  end

  def fetch_selected_genres(selected) do
    ids = Enum.reduce(selected, [], &cons_integer/2)

    case ids do
      [] = empty ->
        empty
      _ ->
        from(g in Genre, where: g.id in ^ids)
        |> Repo.all()
    end
  end

  # Usually in MusicDB.Album
  def changeset(album, params \\ %{}) do
    # obtain genres as full schema structs
    genres = fetch_selected_genres(params["genres"])

    album
    |> EC.cast(params, [:title])
    |> EC.put_assoc(:genres, genres)
    |> EC.validate_required([:title])
  end

  def make_params do
    %{
      "title" => "Test Album",
      "genres" => ["1","2"]
    }
  end

  def play do
    # Note: Album needs to be associated with
    # with an Artist so we use build_assoc
    # to create the "shell" schema struct
    # for the Album
    #
    query_bill_evans()
    |> E.build_assoc(:albums)
    |> changeset(make_params())
    |> Repo.insert()
  end
end

IO.puts(AppInfo.string())
IO.inspect(Playground.play())

$ mix run ./priv/repo/playground.exs
asn1: 5.0.9, compiler: 7.4.4, connection: 1.0.4, crypto: 4.5.1, db_connection: 2.0.5, decimal: 1.6.0, ecto: 3.0.6, ecto_sql: 3.0.5, elixir: 1.9.1, hex: 0.20.1, inets: 7.0.9, jason: 1.1.2, kernel: 6.4.1, logger: 1.9.1, mariaex: 0.9.1, mix: 1.9.1, music_db: 0.1.0, postgrex: 0.14.1, public_key: 1.6.7, ssl: 9.3.5, stdlib: 3.9.2, telemetry: 0.3.0

18:46:13.559 [debug] QUERY OK source="artists" db=4.4ms decode=0.6ms queue=1.2ms
SELECT a0."id", a0."name", a0."birth_date", a0."death_date", a0."inserted_at", a0."updated_at"
FROM "artists" AS a0 WHERE (a0."name" = 'Bill Evans') []

18:46:13.566 [debug] QUERY OK source="genres" db=0.8ms queue=0.9ms
SELECT g0."id", g0."name", g0."wiki_tag", g0."inserted_at", g0."updated_at"
FROM "genres" AS g0 WHERE (g0."id" = ANY($1)) [[2, 1]]

18:46:13.569 [debug] QUERY OK db=0.2ms
begin []

18:46:13.574 [debug] QUERY OK db=1.2ms
INSERT INTO "albums" ("artist_id","title","inserted_at","updated_at")
VALUES ($1,$2,$3,$4) RETURNING "id" [2, "Test Album", ~N[2019-08-03 22:46:13], ~N[2019-08-03 22:46:13]]

18:46:13.576 [debug] QUERY OK db=0.7ms
INSERT INTO "albums_genres" ("album_id","genre_id")
VALUES ($1,$2) [16, 1]

18:46:13.577 [debug] QUERY OK db=0.2ms
INSERT INTO "albums_genres" ("album_id","genre_id")
VALUES ($1,$2) [16, 2]

18:46:13.583 [debug] QUERY OK db=6.0ms
commit []
{:ok,
 %MusicDB.Album{
   __meta__: #Ecto.Schema.Metadata<:loaded, "albums">,
   artist: #Ecto.Association.NotLoaded<association :artist is not loaded>,
   artist_id: 2,
   genres: [
     %MusicDB.Genre{
       __meta__: #Ecto.Schema.Metadata<:loaded, "genres">,
       albums: #Ecto.Association.NotLoaded<association :albums is not loaded>,
       id: 1,
       inserted_at: ~N[2019-08-03 14:35:27],
       name: "jazz",
       updated_at: ~N[2019-08-03 14:35:27],
       wiki_tag: "Jazz"
     },
     %MusicDB.Genre{
       __meta__: #Ecto.Schema.Metadata<:loaded, "genres">,
       albums: #Ecto.Association.NotLoaded<association :albums is not loaded>,
       id: 2,
       inserted_at: ~N[2019-08-03 14:35:27],
       name: "live",
       updated_at: ~N[2019-08-03 14:35:27],
       wiki_tag: "Concert"
     }
   ],
   id: 16,
   inserted_at: ~N[2019-08-03 22:46:13],
   title: "Test Album",
   tracks: #Ecto.Association.NotLoaded<association :tracks is not loaded>,
   updated_at: ~N[2019-08-03 22:46:13]
 }}
$
9 Likes