Do you want to pick up from where you left of?
Take me there

Связи

В этом уроке мы рассмотрим, как использовать Ecto для определения связей и работы с ассоциациями между схемами.

Настройка

Начнём с приложения Friends, которое уже использовалось в предыдущих уроках. Здесь можно вспомнить, о чём идёт речь.

Типы ассоциаций

Между схемами в Ecto можно объявить три типа связей. Рассмотрим их и узнаем, как работать с каждым из них.

Belongs To/Has Many

Добавим несколько новых сущностей в приложение, чтобы пользователи могли начать каталогизировать свои любимые фильмы. Начнём с добавления двух схем: Movie и Character. Добавим двухстороннюю связь между ними: у фильма есть много героев (has many), и каждый герой принадлежит какому-то одному фильму (belongs to).

Миграция Has Many

Сгенерируем миграцию для схемы Movie:

mix ecto.gen.migration create_movies

Откроем файл сгенерированной миграции и определим содержимое функции change для создания таблицы movies со всеми нужными атрибутами:

# priv/repo/migrations/*_create_movies.exs
defmodule Friends.Repo.Migrations.CreateMovies do
  use Ecto.Migration

  def change do
    create table(:movies) do
      add :title, :string
      add :tagline, :string
    end
  end
end

Схема для связи Has Many

Добавим модель для нашей миграции со связью между фильмами и героями:

# lib/friends/movie.ex
defmodule Friends.Movie do
  use Ecto.Schema

  schema "movies" do
    field :title, :string
    field :tagline, :string
    has_many :characters, Friends.Character
  end
end

Макрос has_many/3 не делает ничего в самой базе данных. Вместо этого он использует ключ (foreign key) связанной схемы characters для получения героев, связанных с этим фильмом. Этот макрос позволяет в дальнейшем использовать синтаксис movie.characters.

Миграция для создания связи Belongs To

Теперь можно создать миграцию и схемы Character. Герой относится к фильму — определим миграцию и схему, описывающие эту связь.

Сначала опишем миграцию:

mix ecto.gen.migration create_characters

Для описания факта принадлежности героя к фильму нужна колонка movie_id в таблице characters. Используем эту колонку как foreign key. Это можно сделать с помощью следующей строки внутри функции create_table/1:

add :movie_id, references(:movies)

После добавления этой строки миграция должна выглядеть вот так:

# priv/migrations/*_create_characters.exs
defmodule Friends.Repo.Migrations.CreateCharacters do
  use Ecto.Migration

  def change do
    create_table(:characters) do
      add :name, :string
      add :movie_id, references(:movies)
    end
  end
end

Схема для связи Belongs To

В схеме также нужно определить эту связь между героем и фильмом:

# lib/example/character.ex

defmodule Friends.Character do
  use Ecto.Schema

  schema "characters" do
    field :name, :string
    belongs_to :movie, Friends.Movie
  end
end

Давайте ближе посмотрим на то, как работает макрос belongs_to/3. Кроме объявления поля movie_id в таблице characters, этот макрос также даёт нам возможность доступа к связанным фильмам (movies) через героев (characters). Эта функциональность использует movie_id для получения фильма, когда мы его запрашиваем. Также это позволит нам вызывать character.movie.

Теперь можно запустить миграции:

mix ecto.migrate

Belongs To/Has One

Допустим, что у фильма есть один дистрибьютор. К примеру, Netflix является дистрибьютором эксклюзивного для их платформы фильма Bright.

Определим миграцию и схему Distributor со связью belongs to. Для начала создадим миграцию:

mix ecto.gen.migration create_distributors

Добавим создание поля movie_id в таблице distributors вместе с уникальным индексом, который позволит фильму иметь только одного дистрибьютора:

# priv/repo/migrations/*_create_distributors.exs

defmodule Friends.Repo.Migrations.CreateDistributors do
  use Ecto.Migration

  def change do
    create table(:distributors) do
      add :name, :string
      add :movie_id, references(:movies)
    end

    create unique_index(:distributors, [:movie_id])
  end
end

Схема Distributor должна также запускать макрос belongs_to/3 для дальнейшего использования distributor.movie и нахождения фильмов этого дистрибьютора по этому ключу.

# lib/friends/distributor.ex

defmodule Friends.Distributor do
  use Ecto.Schema

  schema "distributors" do
    field :name, :string
    belongs_to :movie, Friends.Movie
  end
end

Следующим шагом мы добавим связь в схему Movie:

# lib/friends/movie.ex

defmodule Friends.Movie do
  use Ecto.Schema

  schema "movies" do
    field :title, :string
    field :tagline, :string
    has_many :characters, Friends.Character
    has_one :distributor, Friends.Distributor # I'm new!
  end
end

Макрос has_one/3 работает так же, как и has_many/3. Он даёт возможность работать с переданной структурой с помощью вызова movie.distributor.

Теперь можно запустить миграции:

mix ecto.migrate

Many To Many

Допустим, что у фильма есть много актёров и актёр может принадлежать нескольким фильмам. Создадим промежуточную таблицу, которая включает в себя ссылки и на актёров, и на фильмы для хранения данных этой связи.

Сначала создадим миграцию для актёров Actors:

mix ecto.gen.migration create_actors

Определим миграцию:

# priv/migrations/*_create_actors.ex

defmodule Friends.Repo.Migrations.CreateActors do
  use Ecto.Migration

  def change do
    create table(:actors) do
      add :name, :string
    end
  end
end

Создадим миграцию для промежуточной таблицы:

mix ecto.gen.migration create_movies_actors

Определим таблицу, как имеющую два foreign key. Также добавим уникальный индекс для проверки уникальности данных:

# priv/migrations/*_create_movies_actors.ex

defmodule Friends.Repo.Migrations.CreateMoviesActors do
  use Ecto.Migration

  def change do
    create table(:movies_actors, primary_key: false) do
      add :movie_id, references(:movies)
      add :actor_id, references(:actors)
    end

    create unique_index(:movies_actors, [:movie_id, :actor_id])
  end
end

Следующим шагом добавим макрос many_to_many в схему Movie:

# lib/friends/movie.ex

defmodule Friends.Movie do
  use Ecto.Schema

  schema "movies" do
    field :title, :string
    field :tagline, :string
    has_many :characters, Friends.Character
    has_one :distributor, Friends.Distributor
    many_to_many :actors, Friends.Actor, join_through: "movies_actors" # I'm new!
  end
end

Также добавим в схему Actor тот же самый макрос many_to_many:

# lib/friends/actor.ex

defmodule Friends.Actor do
  use Ecto.Schema

  schema "actors" do
    field :name, :string
    many_to_many :movies, Friends.Movie, join_through: "movies_actors"
  end
end

И после всех изменений запустим миграции:

mix ecto.migrate

Сохранение связанных данных

Способ сохранения данных зависит от того, какая именно связь между этими объектами. Начнём с belongs to/has many.

Belongs To

Сохранение с помощью Ecto.build_assoc/3

Со связью belongs to, можно использовать метод Ecto build_assoc/3.

build_assoc/3 принимает три параметра:

Создадим фильм и героя, участвующего в нём. Начнём с записи фильма:

iex> alias Friends.{Movie, Character, Repo}
iex> movie = %Movie{title: "Ready Player One", tagline: "Something about video games"}

%Friends.Movie{
  __meta__: %Ecto.Schema.Metadata<:built, "movies">,
  actors: %Ecto.Association.NotLoaded<association :actors is not loaded>,
  characters: %Ecto.Association.NotLoaded<association :characters is not loaded>,
  distributor: %Ecto.Association.NotLoaded<association :distributor is not loaded>,
  id: nil,
  tagline: "Something about video games",
  title: "Ready Player One"
}

iex> movie = Repo.insert!(movie)

Теперь создадим связанного героя и сохраним его в базу данных:

iex> character = Ecto.build_assoc(movie, :characters, %{name: "Wade Watts"})
%Friends.Character{
  __meta__: %Ecto.Schema.Metadata<:built, "characters">,
  id: nil,
  movie: %Ecto.Association.NotLoaded<association :movie is not loaded>,
  movie_id: 1,
  name: "Wade Watts"
}
iex> Repo.insert!(character)
%Friends.Character{
  __meta__: %Ecto.Schema.Metadata<:loaded, "characters">,
  id: 1,
  movie: %Ecto.Association.NotLoaded<association :movie is not loaded>,
  movie_id: 1,
  name: "Wade Watts"
}

Стоит отметить, что так как в Movie макрос has_many/3 определяет, что в фильме несколько :characters, то именно такое название мы передаем в build_assoc/3 вторым параметром. Можно также увидеть, что мы создали героя, у которого свойство movie_id правильно установлено в идентификатор связанного фильма.

Для использования build_assoc/3 для сохранения дистрибьютора, воспользуемся тем же подходом передачи названия связи во втором аргументе:

iex> distributor = Ecto.build_assoc(movie, :distributor, %{name: "Netflix"})
%Friends.Distributor{
  __meta__: %Ecto.Schema.Metadata<:built, "distributors">,
  id: nil,
  movie: %Ecto.Association.NotLoaded<association :movie is not loaded>,
  movie_id: 1,
  name: "Netflix"
}
iex> Repo.insert!(distributor)
%Friends.Distributor{
  __meta__: %Ecto.Schema.Metadata<:loaded, "distributors">,
  id: 1,
  movie: %Ecto.Association.NotLoaded<association :movie is not loaded>,
  movie_id: 1,
  name: "Netflix"
}

Many to Many

Сохранение с помощью Ecto.Changeset.put_assoc/4

Подход, использованный ранее с build_assoc/3, не сработает со связью многие-ко-многим, так как ни у таблицы актёров, ни у таблицы фильмов нет полей для этой связи. Вместо этого нужно использовать функцию put_assoc/4.

Допустим, что фильм уже сохранен в базу данных ранее, и создадим запись для актёра:

iex> alias Friends.Actor
iex> actor = %Actor{name: "Tyler Sheridan"}
%Friends.Actor{
  __meta__: %Ecto.Schema.Metadata<:built, "actors">,
  id: nil,
  movies: %Ecto.Association.NotLoaded<association :movies is not loaded>,
  name: "Tyler Sheridan"
}
iex> actor = Repo.insert!(actor)
%Friends.Actor{
  __meta__: %Ecto.Schema.Metadata<:loaded, "actors">,
  id: 1,
  movies: %Ecto.Association.NotLoaded<association :movies is not loaded>,
  name: "Tyler Sheridan"
}

Теперь мы готовы связать фильм и актёра с помощью промежуточной таблицы.

Сначала для работы с наборами изменений, нужно убедиться, что структура movie загрузила данные связанных структур. Чуть позже также рассмотрим предзагрузку данных. Сейчас будет достаточно понимать, что таким образом можно предзагрузить данные связанных таблиц:

iex> movie = Repo.preload(movie, [:distributor, :characters, :actors])
%Friends.Movie{
 __meta__: #Ecto.Schema.Metadata<:loaded, "movies">,
  actors: [],
  characters: [
    %Friends.Character{
      __meta__: #Ecto.Schema.Metadata<:loaded, "characters">,
      id: 1,
      movie: #Ecto.Association.NotLoaded<association :movie is not loaded>,
      movie_id: 1,
      name: "Wade Watts"
    }
  ],
  distributor: %Friends.Distributor{
    __meta__: #Ecto.Schema.Metadata<:loaded, "distributors">,
    id: 1,
    movie: #Ecto.Association.NotLoaded<association :movie is not loaded>,
    movie_id: 1,
    name: "Netflix"
  },
  id: 1,
  tagline: "Something about video game",
  title: "Ready Player One"
}

Следующим шагом создадим набор изменений для записи фильма:

iex> movie_changeset = Ecto.Changeset.change(movie)
%Ecto.Changeset<action: nil, changes: %{}, errors: [], data: %Friends.Movie<>,
 valid?: true>

Теперь передадим набор изменений в качестве первого аргумента в Ecto.Changeset.put_assoc/4:

iex> movie_actors_changeset = movie_changeset |> Ecto.Changeset.put_assoc(:actors, [actor])
%Ecto.Changeset<
  action: nil,
  changes: %{
    actors: [
      %Ecto.Changeset<action: :update, changes: %{}, errors: [],
       data: %Friends.Actor<>, valid?: true>
    ]
  },
  errors: [],
  data: %Friends.Movie<>,
  valid?: true
>

Это создаст новый набор изменений, который отображает добавление этих актёров в список актёров выбранного фильма.

И в качестве финального штриха, сохраним этот набор изменений:

iex> Repo.update!(movie_actors_changeset)
%Friends.Movie{
  __meta__: #Ecto.Schema.Metadata<:loaded, "movies">,
  actors: [
    %Friends.Actor{
      __meta__: #Ecto.Schema.Metadata<:loaded, "actors">,
      id: 1,
      movies: #Ecto.Association.NotLoaded<association :movies is not loaded>,
      name: "Tyler Sheridan"
    }
  ],
  characters: [
    %Friends.Character{
      __meta__: #Ecto.Schema.Metadata<:loaded, "characters">,
      id: 1,
      movie: #Ecto.Association.NotLoaded<association :movie is not loaded>,
      movie_id: 1,
      name: "Wade Watts"
    }
  ],
  distributor: %Friends.Distributor{
    __meta__: #Ecto.Schema.Metadata<:loaded, "distributors">,
    id: 1,
    movie: #Ecto.Association.NotLoaded<association :movie is not loaded>,
    movie_id: 1,
    name: "Netflix"
  },
  id: 1,
  tagline: "Something about video game",
  title: "Ready Player One"
}

Можно увидеть, что этот вызов вернул запись фильма с актёром, предзагруженным в movie.actors.

Тот же подход может использоваться для создания актёра, связанного с имеющимся фильмом. Вместо передачи сохраненной записи актёра можно передать структуру, описывающую создаваемого актёра:

iex> changeset = movie_changeset |> Ecto.Changeset.put_assoc(:actors, [%{name: "Gary"}])
%Ecto.Changeset<
  action: nil,
  changes: %{
    actors: [
      %Ecto.Changeset<
        action: :insert,
        changes: %{name: "Gary"},
        errors: [],
        data: %Friends.Actor<>,
        valid?: true
      >
    ]
  },
  errors: [],
  data: %Friends.Movie<>,
  valid?: true
>
iex>  Repo.update!(changeset)
%Friends.Movie{
  __meta__: %Ecto.Schema.Metadata<:loaded, "movies">,
  actors: [
    %Friends.Actor{
      __meta__: %Ecto.Schema.Metadata<:loaded, "actors">,
      id: 2,
      movies: %Ecto.Association.NotLoaded<association :movies is not loaded>,
      name: "Gary"
    }
  ],
  characters: [],
  distributor: nil,
  id: 1,
  tagline: "Something about video games",
  title: "Ready Player One"
}

Можно увидеть, что новый актёр был создан с идентификатором 2 и переданными ему атрибутами.

В следующем уроке мы увидим, как искать по связанным записям.

Caught a mistake or want to contribute to the lesson? Edit this lesson on GitHub!