Fork me on GitHub

Ecto

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, "~> 1.0"},
   {:postgrex, ">= 0.0.0"}]
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. 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(profile, :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.


Podziel się