Брошюра об Ecto – интерфейсе для работы с базами данных на Elixir

ecto


Вступление


Ecto написанный на Elixir DSL для коммуникации с базами данных. Ecto это не ORM. Почему? Да, потому что Elixir не объектно-ориентированный язык, вот и Ecto не может быть Object-Relational Mapping (объектно-реляционным отображением). Ecto — это абстракция над базами данных состоящая из нескольких больших модулей, которые позволяют создавать миграции, объявлять модели (схемы), добавлять и обновлять данные, а также посылать к ним запросы.


Если вы знакомы с Rails, то для вас самой близкой аналогией, конечно же, будет его ORM ActiveRecord. Но эти две системы не являются копиями друг друга, и хороши в использовании в рамках своих базовых языков. На данный момент актуальная версия Ecto 2, она совместима с PostgreSQL и MySQL. Более ранняя версия дополнительно имеет совместимость с MSSQL, SQLite3 и MongoDB. Независимо от того, какая используется СУБД, формат функций Ecto будет всегда одинаковый. Также Ecto идёт из коробки с Phoenix и является хорошим стандартным решением.


Если надумаете расширить брошюру, то милости прошу присоединиться к развитию данного репозитория https://github.com/wunsh/ecto-book-ru


Новшества Ecto 2.X


  • Обновлённый модуль Ecto.Changeset
  • Новая функция Subquery/1 в модуле Ecto.Query
  • Новая функция insert_all/3 в модуле Ecto.Repo
  • Добавлена Many-to-Many ассоциация
  • Улучшена работа с ассоциациями
  • Новая функция предзагрузки ассоциаций assoc/2 в модуле Ecto
  • Upsert
  • Новые условия выборки or_where и or_having
  • Добавлена возможность пошагового построения запроса

Обновлённый модуль Ecto.Changeset


  1. changeset.model переименована в changeset.data (отныне «models» в Ecto нет).
  2. Устаревшим считается передача обязательных полей и опций для них в cast/4, отныне следует использовать cast/3 и validate_required/3.
  3. Атом :empty в cast(source, :empty, required, optional) стал устаревшим, желательно вместо этого использовать empty map or :invalid.

Как итог, вместо этого:


def changeset(user, params \\ :empty) do
  user
  |> cast(params, [:name], [:age])
end

Рекомендуется делать лучше так:


def changeset(user, params \\ %{}) do
  user
  |> cast(params, [:name, :age])
  |> validate_required([:name])
end

Новая функция Subquery/1 в модуле Ecto.Query


Функция Ecto.Query.subquery/1 даёт возможность конвертировать любые запросы в подзапросы. Для примера, если вы хотите посчитать среднее кол-во просмотров публикаций, вы можете написать:


query = from p in Post, select: avg(p.visits)
TestRepo.all(query) #=> [#Decimal<1743>]

Однако, если вы хотите посчитать средне кол-во просмотров только для 10 самых популярных постов, вам потребуется подзапрос:


query = from p in Post, select: [:visits], order_by: [desc: :visits], limit: 10
TestRepo.all(from p in subquery(query), select: avg(p.visits)) #=> [#Decimal<4682>]

Для практического примера, если вы используете для подсчёта агрегированных данных функцию Repo.aggregate:


# Среднее кол-во просмотров для всех публикаций
TestRepo.aggregate(Post, :avg, :visits) #=> #Decimal<1743>

# Среднее кол-во просмотров для 10 самых популярных публикаций
query = from Post, order_by: [desc: :visits], limit: 10
TestRepo.aggregate(query, :avg, :visits) #=> #Decimal<4682>

В subquery/1 есть возможность задавать имена полям в подзапросах. Что позволит обрабатывать таблицы с конфликтующими именами:


posts_with_private = from p in Post, select: %{title: p.title, public: not p.private}
from p in subquery(posts_with_private), where: p.public, select: p

Новая функция insert_all/3 в модуле Ecto.Repo


Функция Ecto.Repo.insert_all/3 предназначена для множественной вставки записей внутри одного запроса:


Ecto.Repo.insert_all Post, [%{title: "foo"}, %{title: "bar"}]

Стоит учесть, что при вставки строк через insert_all/3 не обрабатываются автогенерируемые поля, такие как inserted_at или updated_at. Также insert_all/3 даёт возможность вставлять строки в базу данных в обход Ecto.Schema, просто указав имя таблицы:


Ecto.Repo.insert_all "some_table", [%{hello: "foo"}, %{hello: "bar"}]

Добавлена Many-to-Many ассоциация


Теперь Ecto поддерживает many_to_many ассоциации:


defmodule Post do
  use Ecto.Schema

  schema "posts" do
    many_to_many :tags, Tag, join_through: "posts_tags"
  end
end

Значение в опции join_through может быть именем таблицы, которая содержит в себе колонки post_id и tag_id, или может быть схемой, такой как PostTag, которая содержит внешние ключи и автогенерируемые колонки.


Улучшена работа с ассоциациями


Отныне Ecto даёт возможность вставлять и изменять строки к belongs_to и many_to_many ассоциациям через changeset. Кроме этого, Ecto поддерживает определение ассоциаций напрямую в структуре данных для вставки. Например:


Repo.insert! %Permalink{
  url: "//root",
  post: %Post{
    title: "A permalink belongs to a post which we are inserting",
    comments: [
      %Comment{text: "child 1"},
      %Comment{text: "child 2"},
    ]
  }
}

Это улучшение позволяет проще вставлять древовидные структуры в базу данных.


Новая функция предзагрузки ассоциаций assoc/2 в модуле Ecto


Функция Ecto.assoc/2 позволяет определять связи второго порядка, которые должны быть загружены к выбираемым записям. Как пример, можно получить авторов и комментарии к выбираемым публикациям:


posts = Repo.all from p in Post, where: is_nil(p.published_at)
Repo.all assoc(posts, [:comments, :author])

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


Upsert


Функции Ecto.Repo.insert/2 и Ecto.Repo.insert_all/3 начали поддерживать upserts (вставка и обновление) через опции :on_conflict и :conflict_target.


Опция :on_conflict определяет, как база данных должна себя вести в случае совпадения primary key.
Опция :conflict_target определяет по каким полям необходимо проверять конфликты при вставке новых строк.


# Вставка оригинала
{:ok, inserted} = MyRepo.insert(%Post{title: "inserted"})

# Попытка вставки без вызова ошибки.
{:ok, upserted} = MyRepo.insert(%Post{id: inserted.id, title: "updated"},
                                on_conflict: :nothing)

# Попытка вставки, с обновлением поля title.
on_conflict = [set: [title: "updated"]]
{:ok, updated} = MyRepo.insert(%Post{id: inserted.id, title: "updated"},
                               on_conflict: on_conflict, conflict_target: :id)

Новые условия выборки or_where и or_having


В Ecto добавили выражения Ecto.Query.or_where/3 и Ecto.Query.or_having, которые добавляют новые фильтры к уже существующим условиям через «OR».


from(c in City, where: [state: "Sweden"], or_where: [state: "Brazil"])

Добавлена возможность пошагового построения запроса


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


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


dynamic = false

dynamic =
  if params["is_public"] do
    dynamic([p], p.is_public or ^dynamic)
  else
    dynamic
  end

dynamic =
  if params["allow_reviewers"] do
    dynamic([p, a], a.reviewer == true or ^dynamic)
  else
    dynamic
  end

from query, where: ^dynamic

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


Динамическое выражение всегда можно интерполировать внутри другого динамического выражения или внутри where, having, update или в условии on у join.


Интерфейс запросов


Разбор документации модулей Ecto.Query и Ecto.Repo из HexDocs сделан на манер Интерфейса запросов ActiveRecord из RusRailsGuide. Текст ниже раскрывает различные способы получения данных из БД, используя Ecto. Примеры кода далее в тексте ниже будут относиться к некоторым из этих моделей:


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

defmodule Showcase.Client do  
  use Ecto.Schema
  import Ecto.Query

  schema "clients" do
    field :name, :string
    field :age, :integer, default: 0
    field :sex, :integer, default: 0
    field :state, :string, default: "new"

    has_many :orders, Showcase.Order
  end

  # ...
end

defmodule Showcase.Order do  
  use Ecto.Schema
  import Ecto.Query

  schema "orders" do
    field :description, :text
    field :total_cost, :integer
    field :state, :string, default: "pending"

    belongs_to :client, Showcase.Client
    has_many :products, Showcase.Product
  end

  # ...
end

  • Получение одиночной строки
  • Получение нескольких строк
  • Условия выборки строк
  • Сортировка строк
  • Выбор определенных полей строк
  • Группировка строк
  • Лимитирование и смещение выбираемых строк
  • Соединение таблиц
  • Переопределяющее условие

Для получения объектов из базы данных Ecto предоставляет несколько функций поиска. В каждую функцию поиска можно передавать аргументы для выполнения определенных запросов в базу данных без необходимости писать на чистом SQL. Ecto предусматривает два стиля построения запросов: с использованием ключевого слова и через выражение (функцию/макрос).


Ниже перечислены некоторые выражения, предоставляемые Ecto, они объявлены в двух модулях:


Ecto.Repo:


  • get/3
  • get_by/3
  • one/2
  • all/3

Ecto.Query:


  • where/3
  • or_where/3
  • order_by/3
  • select/3
  • group_by/3
  • limit/3
  • offset/3
  • join/5
  • exclude/2

1. Получение одиночной строки


get/3


Используя функцию get/3, можно получить запись, соответствующую определенному первичному ключу (primary key). Например:


# Ищет клиента с первичным ключом (id) 10.
client = Ecto.Repo.get(Client, 10)
=> %Client{id: 10, name: "Cain Ramirez", age: 34, sex: 1, state: "good"}

Функция get/3 возвратит nil, если ни одной записи не найдено. Если запрос не будет иметь primary key или их будет больше одного, то функция вызовет ошибку argument error.


Функция get!/3 ведет себя подобно get/3, за исключением того, что она вызовет Ecto.NoResultsError, если не найдено ни одной соответствующей записи.


Ближайший аналог данной функции в ActiveRecord это метод find.


get_by/3


Используя функцию get_by/3, можно получить запись, соответствующую предоставленным условия выборки. Например:


# Ищет клиента с именем (name) "Cain Ramirez".
client = Ecto.Repo.get_by(Client, name: "Cain Ramirez")
=> %Client{id: 10, name: "Cain Ramirez", age: 34, sex: 1, state: "good"}

Функция get_by/3 возвратит nil, если ни одной записи не найдено.


Функция get_by!/3 ведет себя подобно get_by/3, за исключением того, что она вызовет ошибку Ecto.NoResultsError, если не найдено ни одной соответствующей записи.


Ближайший аналог данной функции в ActiveRecord это метод find_by_*.


one/2


Используя функцию one/2, можно получить одну запись, соответствующую предоставленным условия выборки. Например:


# Выбирает все записи модели Клиент.
query = Ecto.Query.from(c in Client, where: c.name == "Jean Rousey")
client = Ecto.Repo.one(query)
=> %Client{id: 1, name: "Jean Rousey", age: 29, sex: -1, state: "good"}

Функция one/2 возвратит nil, если ни одной записи не найдено. И функция вызовет ошибку если по запросу найдётся больше одной записи.


Функция one!/2 ведет себя подобно one/2, за исключением того, что она вызовет ошибку Ecto.NoResultsError, если не найдено ни одной соответствующей записи.


Ближайший аналог данной функции в ActiveRecord это метод first из <4 версии, который принимал условия выборки, а не лимит как сейчас.


2. Получение нескольких строк


all/3


Используя функцию all/3, можно получить все записи, соответствующие предоставленным условиям запроса. Например:


# Выбирает все записи модели Клиент.
query = Ecto.Query.from(c in Client)
clients = Ecto.Repo.all(query)
=> [%Client{id: 1, name: "Jean Rousey", age: 29, sex: -1, state: "good"}, ..., %Client{id: 10, name: "Cain Ramirez", age: 34, sex: 1, state: "good"}]

Функция all/3 вернет Ecto.QueryError, если запрос не пройдёт валидацию.


Ближайший аналог данной функции в ActiveRecord это метод all из < 4 версии, который принимал условия выборки.


3. Условия выборки строк


where/3


Выражение where/3 позволяет определить условия для ограничения возвращаемых записей, которые представляет WHERE часть выражения SQL. В случае если передаётся несколько условий выборки, то они комбинируются оператором AND.


Вызов по ключевому слову where: является неотъемлемой частью макроса from/2:


from(c in Client, where: c.name == "Cain Ramirez")
from(c in Client, where: [name: "Cain Ramirez"])

Есть возможность интерполировать списки с условиями выборки, что позволяет предварительно собрать необходимые ограничения.


filters = [name: "Cain Ramirez"]
from(c in Client, where: ^filters)

Вызов макросом where/3:


Client |> where([c], c.name == "Cain Ramirez")
Client |> where(name: "Cain Ramirez")

or_where/3


Выражение or_where/3 позволяет определить более гибкие условия для ограничения возвращаемых записей, которые представляет WHERE часть выражения SQL. Разница между where/3 и or_where/3 минимальна, но принципиальна. Переданное условие присоединяется к уже существующим через оператор OR. В случае если передаётся несколько условий выборки в or_where/3, то между собой они комбинируются оператором AND.


Вызов по ключевому слову or_where: является неотъемлемой частью макроса from/2:


from(c in Client, where: [name: "Cain Ramirez"], or_where: [name: "Jean Rousey"])

Есть возможность интерполировать списки с условиями выборки, что позволяет предварительно собрать необходимые ограничения. Условия в списке между собой объединяются через AND, и присоединятся к существующим условиям через OR:


filters = [sex: 1, state: "good"]
from(c in Client, where: [name: "Cain Ramirez"], or_where: ^filters)

… данное выражение эквивалентно:


from c in Client, where: (c.name == "Cain Ramirez") or
                         (c.sex == 1 and c.state == "good")

Вызов макросом or_where/3:


Client |> where([c], c.name == "Jean Rousey")
       |> or_where([c], c.name == "Cain Ramirez")

4. Сортировка строк


Выражение order_by/3 позволяет определить условие сортировки записей полученных из базы данных. order_by/3 задаёт ORDER BY часть SQL запроса.


Есть возможность сортировать сразу по нескольким полям. Направление сортировки по умолчанию на возрастание (:asc), может быть переопределено на убывание (:desc). Для каждого поля можно задать своё направление сортировки.


Вызов по ключевому слову order_by: является неотъемлемой частью макроса from/2:


from(c in Client, order_by: c.name, order_by: c.age)
from(c in Client, order_by: [c.name, c.age])
from(c in Client, order_by: [asc: c.name, desc: c.age])

from(c in Client, order_by: [:name, :age])
from(c in Client, order_by: [asc: :name, desc: :age])

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


values = [asc: :name, desc: :age]
from(c in Client, order_by: ^values)

Вызов макросом order_by/3:


Client |> order_by([c], asc: c.name, desc: c.age)
Client |> order_by(asc: :name)

5. Выбор определенных полей строк


Выражение select/3 позволяет определить поля таблиц, которые необходимо вернуть для получаемых записей из базы данных. select/3 задаёт SELECT часть SQL запроса. По умолчанию Ecto выбирает все множество полей результата, используя select *.


Вызов по ключевому слову select: является неотъемлемой частью макроса from/2:


from(c in Client, select: c)
from(c in Client, select: {c.name, c.age})
from(c in Client, select: [c.name, c.state])
from(c in Client, select: {c.name, ^to_string(40 + 2), 43})
from(c in Client, select: %{name: c.name, order_counts: 42})

Вызов макросом select/3:


Client |> select([c], c)
Client |> select([c], {c.name, c.age})
Client |> select([c], %{"name" => c.name})
Client |> select([:name])
Client |> select([c], struct(c, [:name]))
Client |> select([c], map(c, [:name]))

Важно: При ограничении полей выборки для ассоциаций важно выбирать внешние ключи связей, иначе Ecto не сможет найти связанные объекты.


6. Группировка строк


Для определения условия GROUP BY в SQL запросе, предназначен макрос group_by/3. Все столбцы упомянутые в SELECT, должны быть переданы в group_by/3. Это общее правило для агрегатных функций.


Вызов по ключевому слову group_by: является неотъемлемой частью макроса from/2:


from(c in Client, group_by: c.age, select: {c.age, count(c.id)})

from(c in Client, group_by: :sex, select: {c.sex, count(c.id)})

Вызов макросом group_by/3:


Client |> group_by([c], c.age) |> select([c], count(c.id))

7. Лимитирование и смещение выбираемых строк


Для определения LIMIT в SQL запросе используйте выражение limit/3, оно определит количество необходимых записей, которые будут получены.


Если limit/3 передан дважды, то первое значение будет перекрыто вторым.


from(c in Client, where: c.age == 29, limit: 1)

Client |> where([c], c.age == 29) |> limit(1)

Для определения OFFSET в SQL запросе используйте выражение offset/3, оно определит количество записей, которые будут пропущены до начала возвращаемых записей.


Если offset/3 передан дважды, то первое значение будет перекрыто вторым.


from(c in Client, limit: 10, offset: 30)

Client |> limit(10) |> offset(30)

8. Соединение таблиц


Часто запросы обращаются к нескольким таблицам, такие запросы строятся с использованием конструкции JOIN. В Ecto для определения такой конструкции предназначено выражение join/5. По умолчанию стратегией присоединения таблицы является INNER JOIN, которую можно переопределить на: :inner, :left, :right, :cross or :full. В случае построения запроса по ключу, :join можно заменить на: :inner_join, :left_join, :right_join, :cross_join или :full_join.


Вызов по ключевому слову join: является неотъемлемой частью макроса from/2:


from c in Comment,
  join: p in Post, on: p.id == c.post_id,
  select: {p.title, c.text}

from p in Post,
  left_join: c in assoc(p, :comments),
  select: {p, c}

from c in Comment,
  join: p in Post, on: [id: c.post_id],
  select: {p.title, c.text}

Все ключи переданные в on будут учтены как условия соединения.


Есть возможность интерполировать правую часть относительно in. Для примера:


posts = Post
from c in Comment,
  join: p in ^posts, on: [id: c.post_id],
  select: {p.title, c.text}

Вызов макросом join/5:


Comment
|> join(:inner, [c], p in Post, c.post_id == p.id)
|> select([c, p], {p.title, c.text})

Post
|> join(:left, [p], c in assoc(p, :comments))
|> select([p, c], {p, c})

Post
|> join(:left, [p], c in Comment, c.post_id == p.id and c.is_visible == true)
|> select([p, c], {p, c})

9. Переопределяющее условие


Ecto даёт возможность убрать уже определенные условия в запросе или вернуть значения по умолчанию, для этого используйте выражение exclude/2.


query |> Ecto.Query.exclude(:select)

Ecto.Query.exclude(query, :select)

Команды для массовых операций со строками


Массовая вставка


Функция Ecto.Repo.insert_all/3 вставит все переданные записей.


Repo.insert_all(Client, [[name: "Cain Ramirez", age: 34], [name: "Jean Rousey", age: 29]])
Repo.insert_all(Client, [%{name: "Cain Ramirez", age: 34}, %{name: "Jean Rousey", age: 29}])

Функция insert_all/3 не обрабатывает автогенерируемые поля, такие как inserted_at или updated_at.


Массовое обновление


Функция Ecto.Repo.update_all/3 обновит все строки подпавшие под условие запроса на переданные значения полей.


Repo.update_all(Client, set: [state: "new"])

Repo.update_all(Client, inc: [age: 1])

from(c in Client, where: p.sex < 0)
|> Repo.update_all(set: [state: "new"])

from(c in Client, where: p.sex > 0, update: [set: [state: "new"]])
|> Repo.update_all([])

from(c in Client, where: c.id < 10, update: [set: [state: fragment("?", new)]])
|> Repo.update_all([])

Массовое удаление


Функция Ecto.Repo.delete_all/2 удаляет все строки, подпавшие под условие запроса.


Repo.delete_all(Client)

from(p in Client, where: p.age == 0) |> Repo.delete_all

Практические примеры


Композиция запросов


query = from p in App.Product, select: p

query2 = from p in query, where: p.state == "published"

App.Repo.all(query2)

Функция для постраничного вывода


defmodule Finders.Common.Paging do
  import Ecto.Query

  def page(query), do: page(query, 1)

  def page(query, page), do: page(query, page, 10)

  def page(query, page, per_page) do
    offset = per_page * (page-1)

    query |> offset([_], ^offset)
          |> limit([_], ^per_page)
  end
end

# With Posts: second page, five per page
posts = Post |> Finders.Common.Paging.page(2, 5) |> Repo.all

# With Tags: third page, 10 per page
tags = Tag |> Finders.Common.Paging.page(3) |> Repo.all

Query.API


Операторы сравнения: ==, !=, <=, >=, <, >
Булевы операторы: and, or, not
Оператор включения: in/2
Функции поиска: like/2 и ilike/2
Проверка на null: is_nil/1
Агрегаторы: count/1, avg/1, sum/1, min/1, max/1
Функция для произвольных SQL подзапросов: fragment/1


from p in Post, where: p.published_at > ago(3, "month")

from p in Post, where: p.id in [1, 2, 3]

from p in Payment, select: avg(p.value)

from p in Post, where: p.published_at > datetime_add(^Ecto.DateTime.utc, -1, "month")

from p in Post, where: is_nil(p.published_at)

from p in Post, where: ilike(p.body, "Chapter%")

from p in Post,
  where: is_nil(p.published_at) and
         fragment("lower(?)", p.title) == "title"

Дополнение из Ecto.Adapters.SQL


Ecto.Adapters.SQL.query/4


Выполняет произвольный SQL запрос в рамках переданного репозитория.


Ecto.Adapters.SQL.query(Showcase, "SELECT $1::integer + $2", [40, 2])
=> {:ok, %{rows: [{42}], num_rows: 1}}

Ближайший аналог данной функции в ActiveRecord это метод find_by_sql.


Ecto.Adapters.SQL.to_sql/3


Конвертирует построенный из выражений запрос в SQL.


Ecto.Adapters.SQL.to_sql(:all, repo, Showcase.Client)
=> {"SELECT c.id, c.name, c.age, c.sex, c.state, c.inserted_at, c.created_at FROM clients as c", []}

Ecto.Adapters.SQL.to_sql(:update_all, repo,
                         from(c in Showcase.Client, update: [set: [state: ^"new"]]))
=> {"UPDATE clients AS c SET state = $1", ["new"]}

Данная функция тёска аналогичного метода из ActiveRecord: Relation.


Литература


http://guides.rubyonrails.org/active_record_querying.html


https://hexdocs.pm/ecto/Ecto.html


https://github.com/elixir-ecto/ecto


https://blog.drewolson.org/composable-queries-ecto/


Learning with JB


http://blog.plataformatec.com.br/2016/05/ectos-insert_all-and-schemaless-queries/


Послесловие


Если вам интересен функциональный язык программирования Elixir или вы просто сочувствующий то советую вам присоединиться к Telegram-каналу https://telegram.me/proelixir про Elixir.


У отечественного Elixir сообщества начинает появляться единая площадка в лице проекта Wunsh.ru. Сейчас у проекта есть тематическая рассылка, в которой нет ничего нелегального, раз в недельку будет приходить письмо с подборкой статей про Elixir на русском языке.

Комментарии (0)

© Habrahabr.ru