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

Basics

Ecto - официальный проект от создателей Elixir. Это оболочка, которая предоставляет возможность коммуникации с базой данных. Ecto позволяет создавать миграции, объявлять модели, вносить и обновлять данные, а также посылать запросы к ним.

Установка

Для начала необходимо добавить Ecto и адаптер для базы данных в файл mix.exs, который находится в нашем проекте. Список поддерживаемых адаптеров для баз данных можно найти в Usage секции README для Ecto. В примере мы используем PostgreSQL:

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

После этого добавим Ecto и адаптер в список приложений.

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

Репозиторий

Создадим репозиторий для проекта - оболочку для базы данных. Для этого выполним команду mix ecto.gen.repo -r FriendsApp.Repo. Репозиторий находится в lib/<project name>/repo.ex.

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

Супервизор

После создания репозитория, необходимо создать дерево надзора. Файл находится в lib/<project name>.ex.

Важно, чтобы мы настроили репозиторий, как супервизор с помощью supervisor/3 метода, а не worker/3. Если создать приложение с флагом --sup, большинство нужного кода для нас уже будет сгенерировано.

defmodule FriendsApp.App do
  use Application

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

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

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

Чтобы узнать больше о супервизорах советуем посетить урок Супервизоры.

Настройка

Чтобы настроить Ecto, необходимо добавить конфигурацию в файл config/config.exs. В конфигурации необходимо указать репозиторий, адаптер, базу данных и информацию об аккаунте.

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

Mix задачи

Ecto включает в себя mix задачи для работы с базой данных:

mix ecto.create         # Создать хранилище для репозитория.
mix ecto.drop           # Удалить хранилище для репозитория.
mix ecto.gen.migration  # Сгенерировать новую миграцию для репозитория.
mix ecto.gen.repo       # Сгенерировать новый репозиторий.
mix ecto.migrate        # Запустить миграции для репозитория.
mix ecto.rollback       # Откатить миграции из репозитория.

Миграции

Лучший способ создать миграцию - использовать команду mix ecto.gen.migration <название_миграции>. Это похоже на создание миграций в ActiveRecord.

Давайте рассмотрим, как выглядит миграция для таблицы users:

defmodule FriendsApp.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 по умолчанию создает первичный автоинкрементируемый ключ под названием id. Также по умолчанию используется change/0 callback, но для большего контроля можно использовать up/0 и down/0.

Вы уже могли догадаться, что при добавлении timestamps к миграции у Вас появляется возможность создать и управлять полями inserted_at и updated_at.

Следующим шагом будет запуск только что созданной миграции с помощью команды mix ecto.migrate.

Дополнительную информацию о миграциях Вы можете найти в главе Ecto Миграции.

Модели

Теперь, имея миграцию, можно двигаться дальше, к модели. Модели определяют структуру(schema), вспомогательные методы(helper methods) и набор изменений. Набор изменений объяснен в следующей главе.

Рассмотрим как выглядит модель для нашей миграции:

defmodule FriendsApp.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

Структура, заданная в нашей модели, очень похожа на код в самой миграции. Дополнительно к полям базы данных мы также добавили 2 виртуальных поля. Виртуальные поля не сохраняются в базу данных, но могут быть полезными для валидаций. Примеры виртуальных полей можно найти здесь Набор изменений.

Запросы

Перед тем как посылать запросы к репозиторию, необходимо импортировать Query API. Для начала нам понадобится только один метод из библиотеки from/2:

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

Официальную документацию можно найти перейдя по ссылке Ecto запросы.

Основы

Ecto предоставляет мощный язык запросов. Запрос для поиска всех пользователей, аккаунты которых приняты, будет выглядеть так:

alias FriendsApp.{Repo, User}

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

Repo.all(query)

Кроме all/2, Repo предоставляет различные callback-функции, такие как one/2, get/3, insert/2, и delete/2. Список всех функций можно найти в официальной документации Ecto.Repo#callbacks.

Количество

Если мы хотим посчитать количество пользователей, подтвердивших учётные записи, мы можем использовать count/1:

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

Также с помощью функции count/2 можно считать уникальные значения:

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

Группировка

Для того, чтобы сгруппировать всех пользователей по статусу, можно добавить опцию group_by:

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

Repo.all(query)

Сортировка

Сортировка пользователей по дате создания записи в базе данных:

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

Repo.all(query)

Сортировка по убыванию(DESC):

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

Объединение

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

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

Фрагменты

Иногда, когда нам необходимы специфические функции базы данных, Query API недостаточно. В таких случаях следует использовать функцию fragment/1:

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

Примеры построения различных запросов можно найти здесь Ecto.Query.API.

Набор изменений

В предыдущем параграфе мы увидели как извлечь необходимую информацию из базы данных, а что насчет добавления и обновления записей? Для этого нам необходим набор изменений (Changesets).

Набор изменений выполняет функции фильтрации, валидации, и поддержки ограничений при изменении модели.

Давайте используем пример набора изменений для создания аккаунта для пользователя. Для начала необходимо обновить модель:

defmodule FriendsApp.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

Таким образом, мы улучшили функцию changeset/2, а также добавили три новые вспомогательные функции: validate_password_confirmation/1, password_mismatch_error/1 и password_incorrect_error/1.

По названию можно догадаться, что функция changeset/2 создает новый набор изменений. В теле функции используем функцию cast/4, которая трансформирует данные параметры в набор изменений, который состоит из обязательных и необязательных полей. Далее происходит валидация длины пароля, находящегося в наборе изменений. После этого, используя созданную нами функцию, мы можем проверить, что подтверждение пароля является таким же как и сам пароль, а также проверить уникальность имени пользователя. В конце, обновим пароль в базе данных. Для этого используем функцию put_change/3, которая обновит значение в наборе изменений.

Использование User.changeset/2 довольно просто:

alias FriendsApp.{User, Repo}

pw = "passwords should be hard"

changeset =
  User.changeset(%User{}, %{
    username: "doomspork",
    email: "sean@seancallan.com",
    password: pw,
    password_confirmation: pw
  })

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

И это все! Теперь Вы готовы обновлять записи в базе данных.

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