13
votes

How can I make a many to many relation with ecto 2? As an example app I want to create a Post which can be in multiple categories. The categories already exist. For example:

[%Category{id: "1", name: "elixir"}, %Category{id: "2", name: "erlang"}]

Im using Ecto 2 beta 0. The example project is called Ecto2.

I defined two models:

defmodule Ecto2.Post do
  use Ecto2.Web, :model
  use Ecto.Schema

  schema "posts" do
    field :title, :string
    many_to_many :categories, Ecto2.Category, join_through: "posts_categories", on_replace: :delete
    timestamps
  end

  @required_fields ~w(title)
  @optional_fields ~w()
  def changeset(model, params \\ :empty) do
    model
    |> cast(params, @required_fields, @optional_fields)
    |> cast_assoc(:categories)  # not suitable?
  end
end

defmodule Ecto2.Category do
  use Ecto2.Web, :model

  schema "categories" do
    field :name, :string

    timestamps
  end

  @required_fields ~w(name)
  @optional_fields ~w()
  def changeset(model, params \\ :empty) do
    model
    |> cast(params, @required_fields, @optional_fields)
  end
end

I tried doing it like this:

post = Repo.get!(Post, 1) |> Repo.preload(:categories)
changeset = Post.changeset(post, %{"title"=> "bla", "categories"=> [%{id: "1"}]})
Repo.update!(changeset)

But cast_assoc in Post.changeset is not suitable for this task, it wants to create a whole new Category instead of associate one. What should I use instead? build_assoc? But build_assoc docs do not mention it is useful with many_to_many. How do I use it? Should I put build_assoc in Post.changeset then, or should i use it in a phoenix controller.

2
I can see the below answer shows a way to solve this outside of the Post changeset by using put_assoc(:categories, [category1]) (possibly in a controller etc.) did you find a way to solve it within the Post changeset itself? Like you mention, cast_assoc creates new models, it doesn't associate with existing ones.Morgz

2 Answers

18
votes

You can join through a table by passing a string like "posts_categories", or through a schema by passing through a schema like MyApp.PostCategory. I prefer joining through schema as timestamps can be included. Let say you choose join through a schema instead of a table:

  1. You need to create a separate table (e.g. :posts_categories) for the many_to_many relationships to join to.

```

def change do
  create table(:posts_categories) do
    add :post_id, references(:posts)
    add :category_id, references(:categories)
    timestamps
  end
end
  1. Create a schema for the table you created in step 1. In your web\models folder, create a file post_category.ex:

```

defmodule Ecto2.PostCategory do
use Ecto2.Web, :model

schema "posts_categories" do
  belongs_to :post, Ecto2.Post
  belongs_to :category, Ecto2.Category
  timestamps
end

def changeset(model, params \\ %{}) do
  model
  |> cast(params, [])
end
end

Ecto beta 2 has changed :empty to empty map and change cast\4 to cast \3. Check changelog.

  1. Add this line to your post schema:

    many_to_many :categories, Ecto2.Category, join_through: Ecto2.PostCategory

  2. Add this line to your category schema:

many_to_many :posts, Ecto2.Post, join_through: Ecto2.PostCategory

That's it! Now you can update like ```

post1 = Repo.get!(Post, 1)
category1 = Repo.get!(Category, 1)

post1
|> Repo.preload(:categories)
|> Post.changeset(%{})
|> put_assoc(:categories, [category1])
|> Repo.update!

```

2
votes

After a good night of sleep and some digging in the ecto unit tests i have found a partial answer. The right function to call is Ecto.Changeset.put_assoc. It returns a changeset. The rest of the question is on the bottom of this reply.

def run_insert_1 do
  c1 = Repo.get!(Category, 1)
  c2 = %Category{name: "cat 2"}

  # Inserting
  changeset =
    %Post{title: "1"}
    |> Ecto.Changeset.change
    |> Ecto.Changeset.put_assoc(:categories, [c1, c2])
  post = Repo.insert!(changeset)
  IO.inspect post
end

def run_insert_2 do
  c1 = Repo.insert! %Category{name: "cat 1"}
  c2 = %Category{name: "cat 2"}

  # Inserting
  changeset =
    %Post{title: "1"}
    |> Ecto.Changeset.change
    |> Ecto.Changeset.put_assoc(:categories, [c1, c2])
  post = Repo.insert!(changeset)
  IO.inspect post
end

def run_update do
  c1 = Repo.insert! %Category{name: "cat update"}
  c2 = %Category{name: "cat 2"}
  post = Repo.get!(Post, 1) |> Repo.preload(:categories)
  # Updating
  changeset =
    post
    |> Ecto.Changeset.change
    |> Ecto.Changeset.put_assoc(:categories, [c1])
  post = Repo.update!(changeset)
  IO.inspect post
end

It is a partial solution, because if i want to update the related categories (Post already has a list of related categories) I have to remove and then save the empty list of categories first. Is it possible to do this in one go?

def run_update_2 do
  c2 = Repo.get!(Tag, 2)
  # Assumes Post 1 already has  a few categories in it (for example after
  # running run_update()
  post = Repo.get!(Post, 1) |> Repo.preload(:categories)

  # Remove and add again
  changeset =
    post
    |> Ecto.Changeset.change
    |> Ecto.Changeset.put_assoc(:categories, [])
  IO.inspect changeset
  post = Repo.update!(changeset)

  changeset =
    post
    |> Ecto.Changeset.change
    |> Ecto.Changeset.put_assoc(:categories, [c2])

  post = Repo.update!(changeset)
  IO.inspect post
end