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

Relacje

W tej lekcji nauczymy się, jak korzystać z Ecto do definiowania relacji między schematami i pracy z nimi.

Przygotowanie

Będziemy pracować z tą samą aplikacją Friends, której używaliśmy w poprzednich lekcjach. Możesz zajrzeć tutaj, jeśli potrzebujesz przypomnienia.

Rodzaje relacji

Istnieją trzy rodzaje relacji, które możemy zdefiniować między naszymi schematami. Przyjrzymy się każdemu z nich i nauczymy się, jak je zaimplementować.

Jeden do wielu

Dodamy kilka nowych encji do aplikacji Friends, by móc katalogować nasze ulubione filmy. Zaczniemy od schematów: Movie (film) i Character (postać). Zaimplementujmy między nimi relację „jeden do wielu”: w każdym filmie będzie wiele postaci, a każda postać będzie związana z jakimś filmem.

Migracja „has many” — „posiada wiele”

Wygenerujmy migrację dla tabeli Movie:

mix ecto.gen.migration create_movies

Otwórz nowo utworzony plik z migracją i zdefiniuj funkcję change, by stworzyć tabelę movies z kilkoma atrybutami:

# 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

Schemat dla relacji „posiada wiele”

Dodamy schemat, który określi relację między filmem a jego postaciami.

# 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

Makro has_many/3 nie dodaje niczego do samej bazy danych, pozwala jednak na użycie klucza obcego z powiązanego schematu — characters — by umożliwić nam dostęp do postaci występujących w danym filmie. Pozwoli nam to na wywołanie movie.characters.

Migracja „belongs to” — „należy do”

Teraz jesteśmy gotowi, by zbudować migrację i schemat Character. Postać należy do filmu, więc stworzymy migrację i schemat opisujące tę zależność.

Najpierw wygenerujmy migrację:

mix ecto.gen.migration create_characters

Aby zadeklarować, że postać należy do filmu, potrzebujemy, aby tabela characters zawierała kolumnę movie_id, która będzie kluczem obcym. Możemy to uczynić poprzez dodanie następującej linii w funkcji create table/1:

add :movie_id, references(:movies)

Cała nasza migracja będzie zatem wyglądała tak:

# 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

Schemat „należy do”

Również nasz schemat powinien definiować relację „należy do” między postacią i jej filmem.

# lib/friends/character.ex

defmodule Friends.Character do
  use Ecto.Schema

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

Przyjrzyjmy się bliżej temu, co dokładniej robi dla nas makro belongs_to/3. Poza dodaniem klucza obcego movie_id, pozwala nam również na dostęp do schematu movies poprzez schemat characters. Używa klucza obcego, by umożliwić nam dostęp do filmu związanego z daną postacią. Pozwoli nam to wywoływać character.movie.

Jesteśmy już gotowi, by uruchomić nasze migracje:

mix ecto.migrate

Jeden do jednego

Załóżmy, że film ma jednego dystrybutora — na przykład Netflix jest dystrybutorem filmu „Bright”.

Zdefiniujmy migrację i schemat Distributor (dystrybutor) z relacją „należy do”. Zacznijmy od wygenerowania migracji:

mix ecto.gen.migration create_distributors

Powinniśmy dodać klucz obcy movie_id do migracji dla tabeli distributors, jak również stworzyć indeks unikalny, który zapewni, że film będzie miał tylko jednego dystrybutora:

# 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

Z kolei schemat Distributor powinien używać makra belongs_to/3, które pozwoli na wywoływanie distributor.movie i dostęp do filmu danego dystrybutora przy użyciu klucza obcego.

# lib/friends/distributor.ex

defmodule Friends.Distributor do
  use Ecto.Schema

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

Następnie dodamy relację „posiada jeden” do schematu 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 # Nowa linijka!
  end
end

Makro has_one/3 działa tak, jak makro has_many/3. Używa klucza obcego powiązanego schematu, aby wyszukać i udostępnić nam dystrybutora filmu. Umożliwi to wywołanie movie.distributor.

Jesteśmy gotowi, by uruchomić nasze migracje:

mix ecto.migrate

Wiele do wielu

Możemy założyć, że w filmie występuje wielu aktorów, a każdy aktor może wystąpić w więcej niż jednym filmie. Stworzymy tabelę łączącą, odwołującą się zarówno do filmów, jak i aktorów, by zaimplementować tę relację.

Najpierw wygenerujmy migrację Actors (aktorzy):

mix ecto.gen.migration create_actors

Zdefiniujmy, co migracja ma zrobić:

# 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

Wygenerujmy migrację dla tabeli łączącej:

mix ecto.gen.migration create_movies_actors

Zaimplementujemy migrację tak, by tabela zawierała dwa klucze obce. Dodamy również indeks unikalny, by zapewnić, że dany aktor z danym filmem będzie połączony tylko raz:

# priv/migrations/*_create_movies_actors.ex

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

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

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

Teraz dodajmy makro many_to_many do naszego schematu 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" # Nowa linijka!
  end
end

Na koniec zdefiniujmy schemat Actor, używając tego samego makra 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

Możemy uruchomić migracje:

mix ecto.migrate

Zapisywanie powiązanych danych

Sposób, w jaki będziemy zapisywać rekordy wraz z ich powiązanymi danymi zależy od rodzaju relacji między tymi rekordami. Zacznijmy od relacji „jeden do wielu”.

„Należy do”

Zapisywanie z użyciem Ecto.build_assoc/3

W relacji „należy do” możemy skorzystać z funkcji Ecto build_assoc/3.

build_assoc/3 przyjmuje trzy argumenty:

Zapiszmy więc film wraz ze związaną z nim postacią. Najpierw utwórzmy odpowiedni rekord dla filmu:

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)

Teraz zbudujemy strukturę dla postaci występującej w tym filmie i dodamy ją do bazy danych:

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"
}

Zauważ, że skoro makro has_many/3 w schemacie Movie mówi, że film ma wiele postaci:characters (liczba mnoga!) — nazwa relacji, którą przekazujemy jako drugi argument funkcji build_assoc/3 jest właśnie taka: :characters. Możesz zobaczyć, że postać, którą właśnie utworzyliśmy, w polu movie_id ma poprawnie przypisane ID powiązanego z nią filmu.

Aby użyć build_assoc/3 do zapisania zwiazanego z filmem dystrybutora, zastosujemy to samo podejście, podając nazwę relacji film-dystrybutor jako drugi argument funkcji 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"
}

Wiele do wielu

Zapisywanie z użyciem Ecto.Changeset.put_assoc/4

Sposób z build_assoc/3 nie zadziała dla relacji „wiele do wielu”. Wynika to z prostego faktu, że ani tabela filmów, ani tabela aktorów nie zawierają kluczy obcych. Zamiast tego będziemy musieli więc użyć zastawów zmian Ecto (changesetów) i funkcji put_assoc/4.

Załóżmy, że mamy już w bazie rekord z filmem, który utworzyliśmy wyżej, teraz dodajmy rekord aktora:

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"
}

Teraz jesteśmy gotowi, by powiązać nasz film z aktorem poprzez tabelę łączącą.

Zauważ najpierw, że — skoro pracujemy z changesetami — musimy mieć pewność, iż nasza struktura movie będzie miała wcześniej załadowane powiązane dane. O ładowaniu takich danych powiemy nieco więcej w późniejszym czasie — teraz wystarczy wiedzieć, że możemy ładować powiązane rekordy w następujący sposób:

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"
}

Teraz utwórzmy changeset dla rekordu naszego filmu:

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

Nasz changeset przekażemy jako pierwszy argument do funkcji 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
>

To daje nam nowy changeset, reprezentujący następującą zmianę: dodaj aktorów z tej listy do danego filmu.

Na koniec zaktualizujemy rekordy filmu i aktora, używając ostatniego zestawu zmian:

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"
}

Możesz zauważyć, że uzyskaliśmy w ten sposób rekord filmu z aktorem, poprawnie powiązanym i załadowanym dla nas pod movie.actors.

Możemy użyć tego samego sposobu, aby dodać zupełnie nowego aktora, który ma być powiązany z danym filmem. Zamiast przekazywać zapisaną już strukturę z danymi aktora do put_assoc/4, możemy po prostu przekazać strukturę opisującą aktora, którego chcemy stworzyć w naszej bazie:

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"
}

Jak możesz zauważyć, nowy aktor został stworzony z ID “2” i atrybutami, które mu przypisaliśmy.

W następnej lekcji dowiemy się, jak można tworzyć zapytania, by wyszukiwać powiązane ze sobą rekordy.

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