Ecto.Schema.many_to_many
many_to_many
, go back to Ecto.Schema module for more information.
Indicates a many-to-many association with another schema.
The association happens through a join schema or source, containing foreign keys to the associated schemas. For example, the association below:
# from MyApp.Post
many_to_many :tags, MyApp.Tag, join_through: "posts_tags"
is backed by relational databases through a join table as follows:
[Post] <-> [posts_tags] <-> [Tag]
id <-- post_id
tag_id --> id
More information on the migration for creating such a schema is shown below.
Options
:join_through
- Specifies the source of the associated data. It may be a string, like "posts_tags", representing the underlying storage table or an atom, likeMyApp.PostTag
, representing a schema. This option is required.:join_keys
- Specifies how the schemas are associated. It expects a keyword list with two entries, the first being how the join table should reach the current schema and the second how the join table should reach the associated schema. In the example above, it defaults to:[post_id: :id, tag_id: :id]
. The keys are inflected from the schema names.:on_delete
- The action taken on associations when the parent record is deleted. May be:nothing
(default) or:delete_all
. Using this option is DISCOURAGED for most relational databases. Instead, in your migration, setreferences(:parent_id, on_delete: :delete_all)
. Opposite to the migration option, this option cannot guarantee integrity and it is only triggered forEcto.Repo.delete/2
(and not onEcto.Repo.delete_all/2
). This option can only remove data from the join source, never the associated records, and it never cascades.:on_replace
- The action taken on associations when the record is replaced when casting or manipulating parent changeset. May be:raise
(default),:mark_as_invalid
, or:delete
.:delete
will only remove data from the join source, never the associated records. SeeEcto.Changeset
's section on related data for more info.:defaults
- Default values to use when building the association. It may be a keyword list of options that override the association schema or a atom/{module, function, args}
that receives the struct and the owner as arguments. For example, if you setPost.many_to_many :tags, defaults: [public: true]
, then when usingEcto.build_assoc(post, :tags)
, the tag will havetag.public == true
. Alternatively, you can set it toPost.many_to_many :tags, defaults: :update_tag
, which will invokePost.update_tag(tag, post)
, or set it to a MFA tuple such as{Mod, fun, [arg3, arg4]}
, which will invokeMod.fun(tag, post, arg3, arg4)
:join_defaults
- The same as:defaults
but it applies to the join schema instead. This option will raise if it is given and the:join_through
value is not a schema.:unique
- When true, checks if the associated entries are unique whenever the association is cast or changed via the parent record. For instance, it would verify that a given tag cannot be attached to the same post more than once. This exists mostly as a quick check for user feedback, as it does not guarantee uniqueness at the database level. Therefore, you should also set a unique index in the database join table, such as:create unique_index(:posts_tags, [:post_id, :tag_id])
:where
- A filter for the association. See "Filtering associations" inhas_many/3
:join_where
- A filter for the join table. See "Filtering associations" inhas_many/3
:preload_order
- Sets the defaultorder_by
of the association. It is used when the association is preloaded. For example, if you setPost.many_to_many :tags, Tag, join_through: "posts_tags", preload_order: [asc: :foo]
, whenever the:tags
associations is preloaded, the comments will be order by the:foo
field. SeeEcto.Query.order_by/3
for more examples.
Using Ecto.assoc/2
One of the benefits of using many_to_many
is that Ecto will avoid
loading the intermediate whenever possible, making your queries more
efficient. For this reason, developers should not refer to the join
table of many_to_many
in queries. The join table is accessible in
few occasions, such as in Ecto.assoc/2
. For example, if you do this:
post
|> Ecto.assoc(:tags)
|> where([t, _pt, p], p.public == t.public)
It may not work as expected because the posts_tags
table may not be
included in the query. You can address this problem in multiple ways.
One option is to use ...
:
post
|> Ecto.assoc(:tags)
|> where([t, ..., p], p.public == t.public)
Another and preferred option is to rewrite to an explicit join
, which
ellide the intermediate bindings as they are resolved only later on:
# keyword syntax
from t in Tag,
join: p in assoc(t, :post), on: p.id == ^post.id
# pipe syntax
Tag
|> join([t], :inner, p in assoc(t, :post), on: p.id == ^post.id)
If you need to access the join table, then you likely want to use
has_many/3
with the :through
option instead.
Removing data
If you attempt to remove associated many_to_many
data, Ecto will
always remove data from the join schema and never from the target
associations be it by setting :on_replace
to :delete
, :on_delete
to :delete_all
or by using changeset functions such as
Ecto.Changeset.put_assoc/3
. For example, if a Post
has a many to many
relationship with Tag
, setting :on_delete
to :delete_all
will
only delete entries from the "posts_tags" table in case Post
is
deleted.
Migration
How your migration should be structured depends on the value you pass
in :join_through
. If :join_through
is simply a string, representing
a table, you may define a table without primary keys and you must not
include any further columns, as those values won't be set by Ecto:
create table(:posts_tags, primary_key: false) do
add :post_id, references(:posts)
add :tag_id, references(:tags)
end
However, if your :join_through
is a schema, like MyApp.PostTag
, your
join table may be structured as any other table in your codebase,
including timestamps:
create table(:posts_tags) do
add :post_id, references(:posts)
add :tag_id, references(:tags)
timestamps()
end
Because :join_through
contains a schema, in such cases, autogenerated
values and primary keys will be automatically handled by Ecto.
Examples
defmodule Post do
use Ecto.Schema
schema "posts" do
many_to_many :tags, Tag, join_through: "posts_tags"
end
end
# Let's create a post and a tag
post = Repo.insert!(%Post{})
tag = Repo.insert!(%Tag{name: "introduction"})
# We can associate at any time post and tags together using changesets
post
|> Repo.preload(:tags) # Load existing data
|> Ecto.Changeset.change() # Build the changeset
|> Ecto.Changeset.put_assoc(:tags, [tag]) # Set the association
|> Repo.update!
# In a later moment, we may get all tags for a given post
post = Repo.get(Post, 42)
tags = Repo.all(assoc(post, :tags))
# The tags may also be preloaded on the post struct for reading
[post] = Repo.all(from(p in Post, where: p.id == 42, preload: :tags))
post.tags #=> [%Tag{...}, ...]
Join Schema Example
You may prefer to use a join schema to handle many_to_many associations. The
decoupled nature of Ecto allows us to create a "join" struct which
belongs_to
both sides of the many to many association.
In our example, a User
has and belongs to many Organization
s:
defmodule MyApp.Repo.Migrations.CreateUserOrganization do
use Ecto.Migration
def change do
create table(:users_organizations) do
add :user_id, references(:users)
add :organization_id, references(:organizations)
timestamps()
end
end
end
defmodule UserOrganization do
use Ecto.Schema
@primary_key false
schema "users_organizations" do
belongs_to :user, User
belongs_to :organization, Organization
timestamps() # Added bonus, a join schema will also allow you to set timestamps
end
def changeset(struct, params \\ %{}) do
struct
|> Ecto.Changeset.cast(params, [:user_id, :organization_id])
|> Ecto.Changeset.validate_required([:user_id, :organization_id])
# Maybe do some counter caching here!
end
end
defmodule User do
use Ecto.Schema
schema "users" do
many_to_many :organizations, Organization, join_through: UserOrganization
end
end
defmodule Organization do
use Ecto.Schema
schema "organizations" do
many_to_many :users, User, join_through: UserOrganization
end
end
# Then to create the association, pass in the ID's of an existing
# User and Organization to UserOrganization.changeset
changeset = UserOrganization.changeset(%UserOrganization{}, %{user_id: id, organization_id: id})
case Repo.insert(changeset) do
{:ok, assoc} -> # Assoc was created!
{:error, changeset} -> # Handle the error
end