Ecto

Od ostatniej aktualizacji wprowadzono wiele zmian i tłumaczenie może być nieaktualne.
Od ostatniej aktualizacji wprowadzono kilka dużych zmian.

Ecto jest oficjalnym projektem zespołu Elixira zapewniającym obsługę baz danych wraz z odpowiednim, zintegrowanym językiem. Za pomocą Ecto możemy migrować dane, definiować modele, wstawiać, aktualizować i odpytywać bazę danych.

Spis treści

Przygotowanie

Zacznijmy od dodania Ecto oraz adaptera bazy do konfiguracji projektu w pliku mix.exs. Lista wszystkich dostępnych adapterów i wspieranych baz danych, w języku angielskim, znajduje się w sekcji Usage w pliku README projektu Ecto. W naszym przykładzie użyjemy bazy PostgreSQL:

defp deps do
  [{:ecto, "~> 2.1.4"}, {:postgrex, ">= 0.13.2"}]
end

Teraz możemy dodać Ecto i nasz adapter do listy aplikacji:

def application do
  [applications: [:ecto, :postgrex]]
end

Repozytorium

W końcu musimy stworzyć repozytorium dla naszego projektu, które pełni rolę opakowania (ang. wrapper) bazy danych. Możemy to zrobić wykorzystując polecenie mix ecto.gen.repo -r ExampleApp.Repo. Zadania Ecto dla Mixa omówimy za chwilę. Moduł Repo znajdziemy w lib/<projectname>/repo.ex:

defmodule ExampleApp.Repo do
  use Ecto.Repo, otp_app: :example_app
end

Nadzorca

Po stworzeniu repozytorium musimy jeszcze skonfigurować drzewo nadzorców, które znajdziemy w plikulib/<project name>.ex.

Kluczowe jest wykorzystanie do tego funkcji supervisor/3, a nie worker/3. Jeżeli wygenerujemy aplikację z flagą --sup, to większość konfiguracji będzie już gotowa:

defmodule ExampleApp.App do
  use Application

  def start(_type, _args) do
    import Supervisor.Spec

    children = [
      supervisor(ExampleApp.Repo, [])
    ]

    opts = [strategy: :one_for_one, name: ExampleApp.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

Więcej o nadzorcach znajdziesz w lekcji Nadzorcy OTP.

Konfiguracja

By skonfigurować Ecto musimy dodać odpowiednią sekcję w pliku config/config.exs. Zawiera ona informacje o repozytorium, adapterze, bazie danych oraz dane użytkownika:

config :example_app, ExampleApp.Repo,
  adapter: Ecto.Adapters.Postgres,
  database: "example_app",
  username: "postgres",
  password: "postgres",
  hostname: "localhost"

Zadnie Mix

Ecto zawiera wiele przydatnych zadań mix by wspomagać nas w pracy z bazą danych:

mix ecto.create         # Create the storage for the repo
mix ecto.drop           # Drop the storage for the repo
mix ecto.gen.migration  # Generate a new migration for the repo
mix ecto.gen.repo       # Generate a new repository
mix ecto.migrate        # Run migrations up on a repo
mix ecto.rollback       # Rollback migrations from a repo

Migracja

Najlepszą metodą do pracy z migracjami jest zadanie mix ecto.gen.migration <name>. Jeżeli spotkałeś się ze wzorcem ActiveRecord, to odkryjesz tu wiele podobieństw.

Na początek przyjrzyjmy się migracji tabeli users:

defmodule ExampleApp.Repo.Migrations.CreateUser do
  use Ecto.Migration

  def change do
    create table(:users) do
      add(:username, :string, unique: true)
      add(:encrypted_password, :string, null: false)
      add(:email, :string)
      add(:confirmed, :boolean, default: false)

      timestamps
    end

    create(unique_index(:users, [:username], name: :unique_usernames))
  end
end

Ecto tworzy domyślnie przyrostowy klucz główny id. W tej lekcji używamy funkcji change/0, ale Ecto wspiera też operacje up/0 i down/0, pozwalające na większą i dokładniejszą kontrolę.

Jak się domyślasz, dodanie timestamps do migracji wygeneruje kolumny created_at i updated_at..

Możemy teraz uruchomić migrację poleceniem mix ecto.migrate.

Więcej na temat migracji znajdziesz w dokumentacji Ecto.Migration.

Modele

Mają gotową migrację możemy przejść do modelu. Modele opisują nasze dane, funkcje pomocnicze oraz zestawy zmian. Tymi ostatnimi zajmiemy się w następnej kolejności.

Załóżmy, że model dla naszej migracji wygląda następująco:

defmodule ExampleApp.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field(:username, :string)
    field(:encrypted_password, :string)
    field(:email, :string)
    field(:confirmed, :boolean, default: false)
    field(:password, :string, virtual: true)
    field(:password_confirmation, :string, virtual: true)

    timestamps
  end

  @required_fields ~w(username encrypted_password email)
  @optional_fields ~w()

  def changeset(user, params \\ :empty) do
    user
    |> cast(params, @required_fields, @optional_fields)
    |> unique_constraint(:username)
  end
end

To, co przedstawia powyższa definicja pokrywa się z tym, co mamy w migracji. Dodatkowo do naszej bazy danych dodaliśmy dwa pola wirtualne. Pola wirtualne nie są składowane w bazie danych, ale czasami przydają się np. w trakcie walidacji. Przyjrzymy im się bliżej w części aktualizacja danych.

Zapytania

Zanim zaczniemy odpytywać repozytorium, musimy zaimportować Ecto.Query. Na początek potrzebujemy tylko from/2:

import Ecto.Query, only: [from: 2]

Oficjalną dokumentację, w języku angielskim, znajdziesz na stronie Ecto.Query.

Podstawy

Ecto ma wspaniały DSL (ang. Domain specific language – język domeny) do definiowania zapytań. Przykładowo by pobrać wszystkie pola username dla użytkowników, którzy mają zatwierdzone konto, napiszemy:

alias ExampleApp.{Repo, User}

query =
  from(
    u in User,
    where: u.confirmed == true,
    select: u.username
  )

Repo.all(query)

Poza funkcją all/2 Repo ma też m.in. one/2, get/3, insert/2 i delete/2. Pełną listę znajdziesz na stronie Ecto.Repo#callbacks.

Zliczanie

Jeżeli chcemy policzyć, ilu użytkowników ma zatwierdzone konta, możemy użyć count/1:

query =
  from(
    u in User,
    where: u.confirmed == true,
    select: count(u.id)
  )

Jest też funkcja count/2, która zlicza liczbę unikalnych rekordów:

query =
  from(
    u in User,
    where: u.confirmed == true,
    select: count(u.id, :distinct)
  )

Grupowanie

Funkcja group_by pozwala nam grupować dane wyliczone w funkcjach agregujących. Na przykład policzyć ilu użytkowników ma konta zatwierdzone, a ilu niezatwierdzone:

query =
  from(
    u in User,
    group_by: u.confirmed,
    select: [u.confirmed, count(u.id)]
  )

Repo.all(query)

Sortowanie

Sortowanie kont po dacie utworzenia:

query =
  from(
    u in User,
    order_by: u.inserted_at,
    select: [u.username, u.inserted_at]
  )

Repo.all(query)

W kolejności malejącej DESC:

query =
  from(
    u in User,
    order_by: [desc: u.inserted_at],
    select: [u.username, u.inserted_at]
  )

Złączenia

Załóżmy, że mamy profil połączony z użytkownikiem, by odszukać wszystkie profile, które mają zatwierdzone konta napiszemy:

query =
  from(
    p in Profile,
    join: u in assoc(p, :user),
    where: u.confirmed == true
  )

Fragmenty

Czasami Query API nie wystarcza i musimy użyć funkcji dostępnej w bazie danych. Służy do tego funkcja fragment/1:

query =
  from(
    u in User,
    where: fragment("downcase(?)", u.username) == ^username,
    select: u
  )

Na stronie Ecto.Query.API znajdziesz więcej przykładów.

Aktualizacja danych

W poprzednich częściach dowiedziałeś się jak pobierać dane, ale co ze wstawianiem ich i aktualizacją? Do tego służą zestawy zmian.

Zestawy zmian dbają o zachowanie ograniczeń, filtrowanie oraz walidację w momencie wprowadzania zmian do modelu.

W tym zestawie skupimy się na zestawie zmian potrzebnym do utworzenia konta. Zacznijmy od aktualizacji naszego modelu:

defmodule ExampleApp.User do
  use Ecto.Schema
  import Ecto.Changeset
  import Comeonin.Bcrypt, only: [hashpwsalt: 1]

  schema "users" do
    field(:username, :string)
    field(:encrypted_password, :string)
    field(:email, :string)
    field(:confirmed, :boolean, default: false)
    field(:password, :string, virtual: true)
    field(:password_confirmation, :string, virtual: true)

    timestamps
  end

  @required_fields ~w(username email password password_confirmation)
  @optional_fields ~w()

  def changeset(user, params \\ :empty) do
    user
    |> cast(params, @required_fields, @optional_fields)
    |> validate_length(:password, min: 8)
    |> validate_password_confirmation()
    |> unique_constraint(:username, name: :email)
    |> put_change(:encrypted_password, hashpwsalt(params[:password]))
  end

  defp validate_password_confirmation(changeset) do
    case get_change(changeset, :password_confirmation) do
      nil ->
        password_incorrect_error(changeset)

      confirmation ->
        password = get_field(changeset, :password)
        if confirmation == password, do: changeset, else: password_mismatch_error(changeset)
    end
  end

  defp password_mismatch_error(changeset) do
    add_error(changeset, :password_confirmation, "Passwords does not match")
  end

  defp password_incorrect_error(changeset) do
    add_error(changeset, :password, "is not valid")
  end
end

Stworzyliśmy funkcję changeset/2 oraz trzy funkcje pomocnicze: validate_password_confirmation/1, password_mismatch_error/1 i password_incorrect_error/1.

Jak sama nazwa sugeruje, changeset/2 tworzy nowy zestaw zmian. W ramach niego wywołujemy cast/4 by zamienić parametry na zestaw obowiązkowych i opcjonalnych pól, które zostaną zmienione. Następnie walidujemy długość pola password. Sprawdzamy, czy pole to jest takie same jak password_confirmation oraz, czy username nie istnieje już w bazie. Na końcu, na podstawie parametrów, aktualizujemy pole encrypted_password za pomocą funkcji put_change/3 dopisując je do zestawu zmian.

Samo użycie User.changeset/2 jest stosunkowo proste:

alias ExampleApp.{User, Repo}

pw = "passwords should be hard"

changeset =
  User.changeset(%User{}, %{
    username: "doomspork",
    email: "[email protected]",
    password: pw,
    password_confirmation: pw
  })

case Repo.insert(changeset) do
  {:ok, model}        -> # Inserted with success
  {:error, changeset} -> # Something went wrong
end

I to wszystko! Możesz zapisać dane do bazy.