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

екто
Вступ
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
  1. changeset.model перейменована в changeset.data (відтепер "models" в Ecto немає).
  2. Застарілим вважається передача обов'язкових полів і опцій для них
    cast/4
    , відтепер слід використовувати
    cast/3
    та
    validate_required/3
    .
  3. Атом
    :empty
    на
    cast(source, :empty, required, optional)
    став застарілим, бажано замість цього використовувати
    empty map
    або
    :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.переглядів)
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 = Post from, 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: "1 child"},
%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
, які додають нові фільтри до вже існуючих умов через "АБО".
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. Текст нижче розкриває різні способи одержання даних з БД, використовуючи Екто. Приклади коду далі в тексті нижче будуть ставитися до деяких з цих моделей:
Всі моделі використовують 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
або
: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 SET c 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 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.