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)