Ecto Association Guide
This guide assumes you worked through the Getting Started guide and want to learn more about associations.
There are three kinds of associations:
- one-to-one
- one-to-many
- many-to-many
In this tutorial we’re going to create a minimal Ecto project then we’re going to create basic schemas and migrations, and finally associate the schemas.
Ecto Setup
First, we’re going to create a fresh Ecto project which is going to be used for the rest of the tutorial:
$ mix new ecto_assoc --sup
Add ecto and postgrex as dependencies to mix.exs
# mix.exs
defp deps do
[{:ecto, "~> 2.0"},
{:postgrex, "~> 0.11"}]
end
Let’s generate a repo and create the corresponding DB.
$ mix ecto.gen.repo -r EctoAssoc.Repo
Make sure the config for the repo is set properly:
# config/config.exs
config :ecto_assoc, EctoAssoc.Repo,
adapter: Ecto.Adapters.Postgres,
database: "ecto_assoc_repo",
username: "postgres",
password: "postgres",
hostname: "localhost"
config :ecto_assoc, ecto_repos: [EctoAssoc.Repo]
Add the repo to the supervision tree:
def start(_type, _args) do
import Supervisor.Spec
children = [
supervisor(EctoAssoc.Repo, [])
]
...
Finally let’s create the DB:
$ mix ecto.create
One-to-one
Prep
Let’s start with two schemas that are not yet associated: User and Avatar.
We will generate the migration for User:
mix ecto.gen.migration create_user
And add some columns:
# priv/repo/migrations/*_create_user.exs
defmodule EctoAssoc.Repo.Migrations.CreateUser do
use Ecto.Migration
def change do
create table(:users) do
add :name, :string
add :email, :string
end
end
end
And the following schema:
# lib/ecto_assoc/user.ex
defmodule EctoAssoc.User do
use Ecto.Schema
schema "users" do
field :name, :string
field :email, :string
end
end
Avatar also has its own migration as well:
mix ecto.gen.migration create_avatar
with the following columns:
# priv/repo/migrations/*_create_avatar.exs
defmodule EctoAssoc.Repo.Migrations.CreateAvatar do
use Ecto.Migration
def change do
create table(:avatars) do
add :nick_name, :string
add :pic_url, :string
end
end
end
and the following schema:
# lib/ecto_assoc/avatar.ex
defmodule EctoAssoc.Avatar do
use Ecto.Schema
schema "avatars" do
field :nick_name, :string
field :pic_url, :string
end
end
Adding Associations
Now we want to associate the user with the avatar and vice-versa:
- one user has one avatar
- one avatar belongs to one user
The difference between has_one and belongs_to is where the primary key belongs. In this case, we want the “avatars” table to have a “user_id” columns, therefore the avatar belongs to the user.
For the avatar we create a migration that adds a user_id reference:
mix ecto.gen.migration avatar_belongs_to_user
with the following steps:
# priv/repo/migrations/20161117101812_avatar_belongs_to_user.exs
defmodule EctoAssoc.Repo.Migrations.AvatarBelongsToUser do
use Ecto.Migration
def change do
alter table(:avatars) do
add :user_id, references(:users)
end
end
end
This adds a user_id column to the DB which references an entry in the users table.
For the avatar we add a belongs_to field to the schema:
defmodule EctoAssoc.Avatar do
schema "avatars" do
field :nick_name, :string
field :pic_url, :string
belongs_to :user, EctoAssoc.User # this was added
end
end
belongs_to is a macro which uses a foreign key (in this case user_id) to make the associated schema accessible through the avatar. In this case, you can access the user via avatar.user.
For the user we add a has_one field to the schema:
# lib/ecto_assoc/user.ex
defmodule EctoAssoc.User do
schema "users" do
field :name, :string
field :email, :string
has_one :avatar, EctoAssoc.Avatar # this was added
end
end
has_one does not add anything to the DB. The foreign key of the associated schema, Avatar, is used to make the avatar available from the user, allowing you to access the avatar via user.avatar.
Persistence
Now let’s add data to the DB. Start iex:
$ iex -S mix
For convenience we alias some modules:
iex> alias EctoAssoc.{Repo, User, Avatar}
Create a user struct and insert it into the repo:
iex> user = %User{name: "John Doe", email: "john.doe@example.com"}
iex> user = Repo.insert!(user)
%EctoAssoc.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
avatar: #Ecto.Association.NotLoaded<association :avatar is not loaded>,
email: "john.doe@example.com", id: 3, name: "John Doe"}
This time let’s add another user with an avatar association. We can define it directly in the User struct in the :avatar field:
iex> avatar = %Avatar{nick_name: "Elixir", pic_url: "http://elixir-lang.org/images/logo.png"}
iex> user = %User{name: "John Doe", email: "john.doe@example.com", avatar: avatar}
iex> user = Repo.insert!(user)
%EctoAssoc.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
avatar: %{__meta__: #Ecto.Schema.Metadata<:loaded, "avatars">,
__struct__: EctoAssoc.Avatar, id: 2, nick_name: "Elixir",
pic_url: "http://elixir-lang.org/images/logo.png",
user: #Ecto.Association.NotLoaded<association :user is not loaded>,
user_id: 4}, email: "jane@example.com", id: 4, name: "Jane Doe"}
Let’s verify that it works by retrieving all Users and their associated avatars:
iex> Repo.all(User) |> Repo.preload(:avatar)
[%EctoAssoc.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, avatar: nil,
email: "john.doe@example.com", id: 3, name: "John Doe"},
%EctoAssoc.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
avatar: %EctoAssoc.Avatar{__meta__: #Ecto.Schema.Metadata<:loaded, "avatars">,
id: 2, nick_name: "Elixir", pic_url: "http://elixir-lang.org/images/logo.png",
user: #Ecto.Association.NotLoaded<association :user is not loaded>,
user_id: 4}, email: "jane@example.com", id: 4, name: "Jane Doe"}]
One-to-many
Prep
Let’s assume we have two schemas: User and Post. The User was defined in the previous section and the Post one will be defined now.
Let’s start with the migration:
mix ecto.gen.migration create_post
with the following columns:
# priv/repo/migrations/*_create_post.exs
defmodule EctoAssoc.Repo.Migrations.CreatePost do
use Ecto.Migration
def change do
create table(:posts) do
add :header, :string
add :body, :string
end
end
end
and the following schema:
# lib/ecto_assoc/post.ex
defmodule EctoAssoc.Post do
use Ecto.Schema
schema "posts" do
field :header, :string
field :body, :string
end
end
Adding Associations
Now we want to associate the user with the post and vice-versa:
- one user has many posts
- one post belongs to one user
As in one-to-one associations, the belongs_to reveals on which table the foreign key should be added. For the post we create a migration that adds a user_id reference:
mix ecto.gen.migration post_belongs_to_user
with the following contents:
# priv/repo/migrations/*_post_belongs_to_user.exs
defmodule EctoAssoc.Repo.Migrations.PostBelongsToUser do
use Ecto.Migration
def change do
alter table(:posts) do
add :user_id, references(:users)
end
end
end
For the post we add a belongs_to field to the schema:
defmodule EctoAssoc.Post do
use Ecto.Schema
schema "posts" do
field :header, :string
field :body, :string
belongs_to :user, EctoAssoc.User # this was added
end
end
belongs_to is a macro which uses a foreign key (in this case user_id) to make the associated schema accessible through the post. The user can be accessed via post.user.
For the user we add a has_many field to the schema:
defmodule EctoAssoc.User do
use Ecto.Schema
schema "users" do
field :name, :string
field :email, :string
has_many :posts, EctoAssoc.Post # this was added
end
end
has_many does not require anything to the DB. The foreign key of the associated schema, Post, is used to make the posts available from the user, allowing all posts for a given to user to be accessed via user.posts.
Persistence
Start iex:
$ iex -S mix
For convenience we alias some modules:
iex> alias EctoAssoc.{Repo, User, Post}
Let’s create a User and store it in the DB:
iex> user = %User{name: "John Doe", email: "john.doe@example.com"}
iex> user = Repo.insert!(user)
%EctoAssoc.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
email: "john.doe@example.com", id: 1, name: "John Doe",
posts: #Ecto.Association.NotLoaded<association :posts is not loaded>}
Let’s build an associated post and store it in the DB. We can take a similar approach as we did in one_to_one and directly pass a list of posts in the posts field when inserting the user, effectively inserting the user and multiple posts at once.
However, let’s try a different approach and use Ecto.build_assoc/3 to build a post that is associated to the existing user we have just defined:
iex> post = Ecto.build_assoc(user, :posts, %{header: "Clickbait header", body: "No real content"})
%EctoAssoc.Post{__meta__: #Ecto.Schema.Metadata<:built, "posts">,
body: "No real content", header: "Clickbait header", id: nil,
user: #Ecto.Association.NotLoaded<association :user is not loaded>, user_id: 1}
iex> Repo.insert!(post)
%EctoAssoc.Post{__meta__: #Ecto.Schema.Metadata<:loaded, "posts">,
body: "No real content", header: "Clickbait header", id: 1,
user: #Ecto.Association.NotLoaded<association :user is not loaded>, user_id: 1}
Let’s add another post to the user:
iex> post = Ecto.build_assoc(user, :posts, %{header: "5 ways to improve your Ecto", body: "Add url of this tutorial"})
iex> Repo.insert!(post)
%EctoAssoc.Post{__meta__: #Ecto.Schema.Metadata<:loaded, "posts">,
body: "Add url of this tutorial", header: "5 ways to improve your Ecto",
id: 2, user: #Ecto.Association.NotLoaded<association :user is not loaded>,
user_id: 1}
Let’s see if it worked:
iex> Repo.get(User, user.id) |> Repo.preload(:posts)
%EctoAssoc.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
email: "john.doe@example.com", id: 1, name: "John Doe",
posts: [%EctoAssoc.Post{__meta__: #Ecto.Schema.Metadata<:loaded, "posts">,
body: "No real content", header: "Clickbait header", id: 1,
user: #Ecto.Association.NotLoaded<association :user is not loaded>,
user_id: 1},
%EctoAssoc.Post{__meta__: #Ecto.Schema.Metadata<:loaded, "posts">,
body: "Add url of this tutorial", header: "5 ways to improve your Ecto",
id: 2, user: #Ecto.Association.NotLoaded<association :user is not loaded>,
user_id: 1}]}
In the example above, Ecto.build_assoc received an existing user struct, that was already persisted to the database, and built a Post struct, based on its :posts association, with the user_id foreign key field properly set to the ID in the user struct.
Many-to-many
Prep
Let’s assume we have two schemas: Post and Tag. The Post was defined in the previous section and the Tag one will be defined now.
Let’s start with the tag migration:
mix ecto.gen.migration create_tag
with the following columns:
# priv/repo/migrations/*create_tag.exs
defmodule EctoAssoc.Repo.Migrations.CreateTag do
use Ecto.Migration
def change do
create table(:tags) do
add :name, :string
end
end
end
and the following schema:
defmodule EctoAssoc.Tag do
use Ecto.Schema
schema "tags" do
field :name, :string
end
end
Adding Associations
Now we want to associate the post with the tags and vice-versa:
- one post can have many tags
- one tag can have many posts
This is a many-to-many relationship. Notice both sides can have many entries. In the previous sections we were used to put the foreign key on the side that “belongs to” the other, which is not available here.
One way to handle many-to-many relationships is to introduce an additional table which explicitly tracks the tag-post relationship by pointing to both tags and posts entries.
So let’s do that:
mix ecto.gen.migration create_posts_tags
with the following contents:
# priv/repo/migrations/*_create_posts_tags
defmodule EctoAssoc.Repo.Migrations.CreatePostsTags do
use Ecto.Migration
def change do
create table(:posts_tags) do
add :tag_id, references(:tags)
add :post_id, references(:posts)
end
create unique_index(:posts_tags, [:tag_id, :post_id])
end
end
On the DB level, this creates a new table posts_tags with two columns that point at the tag_id and post_id. We also create a unique index, such that the association is always unique.
For the post we use the many_to_many macro to associate the Tag through the
new posts_tags table.
# lib/ecto_assoc/post.ex
defmodule EctoAssoc.Post do
use Ecto.Schema
schema "posts" do
field :header, :string
field :body, :string
# the following line was added
many_to_many :tags, EctoAssoc.Tag, join_through: "posts_tags"
end
end
For the post we do the same. We use the many_to_many macro to associate the Post through the
new posts_tags schema:
# lib/ecto_assoc/tag.ex
defmodule EctoAssoc.Tag do
use Ecto.Schema
schema "tags" do
field :name, :string
# the following line was added
many_to_many :posts, EctoAssoc.Post, join_through: "posts_tags"
end
end
Persistence
Let’s create some tags:
iex> clickbait_tag = Repo.insert! %Tag{name: "clickbait"}
%EctoAssoc.Tag{__meta__: #Ecto.Schema.Metadata<:loaded, "tags">, id: 1,
name: "clickbait",
posts: #Ecto.Association.NotLoaded<association :posts is not loaded>}
iex> misc_tag = Repo.insert! %Tag{name: "misc"}
%EctoAssoc.Tag{__meta__: #Ecto.Schema.Metadata<:loaded, "tags">, id: 2,
name: "misc",
posts: #Ecto.Association.NotLoaded<association :posts is not loaded>}
iex> ecto_tag = Repo.insert! %Tag{name: "ecto"}
%EctoAssoc.Tag{__meta__: #Ecto.Schema.Metadata<:loaded, "tags">, id: 3,
name: "ecto",
posts: #Ecto.Association.NotLoaded<association :posts is not loaded>}
And let’s create a post:
iex> post = %Post{header: "Clickbait header", body: "No real content"}
...> post = Repo.insert!(post)
%EctoAssoc.Post{__meta__: #Ecto.Schema.Metadata<:loaded, "posts">,
body: "No real content", header: "Clickbait header", id: 1,
tags: #Ecto.Association.NotLoaded<association :tags is not loaded>}
Ok, but tag and post are not associated, yet. We for, as done in one-to-one, create either a post or a tag with the associated entries and insert them all at once. However, notice we cannot use Ecto.build_assoc/3, since the foreign key does not belong to the post nor the tag struct.
Another option is to use Ecto changesets, which provides many conveniences for dealing with changes. For example:
iex> post_changeset = Ecto.Changeset.change(post)
iex> post_with_tags = Ecto.Changeset.put_assoc(post_changeset, :tags, [clickbait_tag, misc_tag])
iex> post = Repo.update!(post_with_tags)
%EctoAssoc.Post{__meta__: #Ecto.Schema.Metadata<:loaded, "posts">,
body: "No real content", header: "Clickbait header", id: 1,
tags: [%EctoAssoc.Tag{__meta__: #Ecto.Schema.Metadata<:loaded, "tags">, id: 1,
name: "clickbait",
posts: #Ecto.Association.NotLoaded<association :posts is not loaded>},
%EctoAssoc.Tag{__meta__: #Ecto.Schema.Metadata<:loaded, "tags">, id: 2,
name: "misc",
posts: #Ecto.Association.NotLoaded<association :posts is not loaded>}]}
Let’s examine the post:
iex> post = Repo.get(Post, post.id) |> Repo.preload(:tags)
%EctoAssoc.Post{__meta__: #Ecto.Schema.Metadata<:loaded, "posts">,
body: "No real content", header: "Clickbait header", id: 1,
tags: [%EctoAssoc.Tag{__meta__: #Ecto.Schema.Metadata<:loaded, "tags">, id: 1,
name: "clickbait",
posts: #Ecto.Association.NotLoaded<association :posts is not loaded>},
%EctoAssoc.Tag{__meta__: #Ecto.Schema.Metadata<:loaded, "tags">, id: 2,
name: "misc",
posts: #Ecto.Association.NotLoaded<association :posts is not loaded>}]}
iex> post.header
"Clickbait header"
iex> post.body
"No real content"
iex> post.tags
[%EctoAssoc.Tag{__meta__: #Ecto.Schema.Metadata<:loaded, "tags">, id: 1,
name: "clickbait",
posts: #Ecto.Association.NotLoaded<association :posts is not loaded>},
%EctoAssoc.Tag{__meta__: #Ecto.Schema.Metadata<:loaded, "tags">, id: 2,
name: "misc",
posts: #Ecto.Association.NotLoaded<association :posts is not loaded>}]
iex> Enum.map(post.tags, & &1.name)
["clickbait", "misc"]
The associations also works in the other direction:
iex> tag = Repo.get(Tag, 1) |> Repo.preload(:posts)
%EctoAssoc.Tag{__meta__: #Ecto.Schema.Metadata<:loaded, "tags">, id: 1,
name: "clickbait",
posts: [%EctoAssoc.Post{__meta__: #Ecto.Schema.Metadata<:loaded, "posts">,
body: "No real content", header: "Clickbait header", id: 1,
tags: #Ecto.Association.NotLoaded<association :tags is not loaded>}]}
The advantage of using Ecto.Changeset is that it is responsible for tracking the changes between your data structures and the associated data. For example, if you want you remove the clickbait tag from from the post, one way to do so is by calling Ecto.Changeset.put_assoc/3 once more but without the clickbait tag. This will not work right now, because the :on_replace option for the many_to_many relationship defaults to :raise. Go ahead and try it. When you try to call put_assoc, a runtime error will be raised:
iex> post_changeset = Ecto.Changeset.change(post)
iex> post_with_tags = Ecto.Changeset.put_assoc(post_changeset, :tags, [misc_tag])
** (RuntimeError) you are attempting to change relation :tags of
Website.CMS.Page but the `:on_replace` option of
this relation is set to `:raise`.
By default it is not possible to replace or delete embeds and
associations during `cast`. Therefore Ecto requires all existing
data to be given on update. Failing to do so results in this
error message.
If you want to replace data or automatically delete any data
not sent to `cast`, please set the appropriate `:on_replace`
option when defining the relation. The docs for [`Ecto.Changeset`](Ecto.Changeset.html)
covers the supported options in the "Related data" section.
However, if you don't want to allow data to be replaced or
deleted, only updated, make sure that:
* If you are attempting to update an existing entry, you
are including the entry primary key (ID) in the data.
* If you have a relationship with many children, at least
the same N children must be given on update.
...
You should carefully read the documentation for Ecto.Schema.many_to_many/3. It makes sense in this case that we want to delete relationships in the join table posts_tags when updating a post with new tags. Here we want to drop the tag “clickbait” and just keep the tag “misc”, so we really do want the relationship in the joining table to be removed. To do that, change the definition of the many_to_many/3 in the Post schema:
# lib/ecto_assoc/post.ex
defmodule EctoAssoc.Post do
use Ecto.Schema
schema "posts" do
field :header, :string
field :body, :string
# the following line was edited to change the on_replace option from its default value of :raise
many_to_many :tags, EctoAssoc.Tag, join_through: "posts_tags", on_replace: :delete
end
end
On the other hand, it probably doesn’t make much sense to be able to remove relationships from the other end. That is, with just a tag, it is hard to decide if a post should be related to the tag or not. So it makes sense that we should still raise an error if we try to change posts that are related to tags from the tag side of things.
With the :on_replace option changed, Ecto will compare the data you gave with the tags currently in the post and conclude the association between the post and the clickbait tag must be removed, as follows:
iex> post_changeset = Ecto.Changeset.change(post)
iex> post_with_tags = Ecto.Changeset.put_assoc(post_changeset, :tags, [misc_tag])
iex> post = Repo.update!(post_with_tags)