Зв'язок багато до багатьох і upsert в Ecto 2.1


У попередньому розділі ми говорили про many_to_many асоціаціях і як маппить зовнішні дані в асоційовані сутності за допомогою
Ecto.Changeset.cast_assoc/3
. Тоді ми були змушені слідувати правилам, що накладаються функцією
cast_assoc/3
, але робити це не завжди можливо або бажано.
У цій главі ми розглянемо
Ecto.Changeset.put_assoc/4
в порівнянні з
cast_assoc/3
і розберемо кілька прикладів. Також ми поглянемо на функцію upsert, які з'являться в Ecto 2.1.

put_assoc vs cast_assoc
Уявіть, що ми робимо додаток-блог, у якого є пости і кожен пост може мати багато тегів. І також, кожен тег може відноситися до декількох постів. Це класичний сценарій, де можна використовувати
many_to_many
зв'язок. Наша міграція буде виглядати так:
create table(:posts) do
add title
add :body
timestamps()
end

create table(:tags) do
add :name
timestamps()
end

create unique_index(:tags, [:name])

create table(:posts_tags, primary_key: false) do
add :post_id, references(:posts)
add :tag_id, references(:tags)
end

Зауважте, що ми додали унікальний індекс до імені тега, тому що нам не потрібні теги з однаковими іменами в нашій базі даних. Важливо додати індекс саме на рівні бази даних, замість використання валідації, так як завжди є шанс, що два тега з однаковими іменами провалидированы і вставлені одночасно, що призведе до дублювання записів.
Тепер, уявімо також, що користувач вводить теги, як список слів, розділений комою, як наприклад: «elixir, erlang, ecto». Коли ці дані будуть отримані на сервері, ми розділимо їх на окремі теги, з'єднаємо з потрібним постом, і створимо ті теги, яких ще немає у базі даних.
Поки що, умови вище, звучать розумно, але вони абсолютно точно викличуть проблеми при використанні
cast_assoc/3
. Ми пам'ятаємо, що
cast_assoc/3
це changeset функція, створена для отримання зовнішніх параметрів, порівняння їх з асоційованими даними в нашій моделі. Ecto вимагає тегів для відправлення у вигляді списку мапов (прим.пер. list of maps). Однак, у нашому випадку, ми очікуємо теги у вигляді рядка, що розділяється коми.
Більш того,
cast_assoc/3
спирається на значення первинного ключа для кожного тега, для того щоб вирішити, чи треба його вставити, оновити, або видалити. Знову, так як у нас користувач просто надсилає рядок, ми не маємо інформації про первинному ключі.
Коли ми не можемо боротися з
cast_assoc/3
, настає час для використання
put_assoc/4
. В
put_assoc/4
, ми отримуємо Ecto структуру або changeset, замість параметрів, що дозволяє нам маніпулювати даними так, як ми хочемо. Давайте визначимо схему і changeset функцію для поста, який може приймати теги у вигляді рядка:
defmodule MyApp.Post do
use Ecto.Schema

schema "posts" do
add title
add :body
many_to_many :tags, MyApp.Tag, join_through: "posts_tags"
timestamps()
end

def changeset(struct, params \\ %{}) do
struct
|> Ecto.Changeset.cast(struct, [:title :body])
|> Ecto.Changeset.put_assoc(:tags, parse_tags(params))
end

defp parse_tags(params) do
(params["tags"] || "")
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(& &1 == "")
|> Enum.map(&get_or_insert_tag/1)
end

defp get_or_insert_tag(name) do
Repo.get_by(MyApp.Tag, name: name) ||
Repo.insert!(MyApp.Tag, %Tag{name: name})
end
end

У changeset функції вище, ми винесли всю обробку тегів в окрему функцію, названу
parse_tags/1
, яка перевіряє існування параметра, поділяє його на значення за допомогою
String.split/2
, потім видаляє зайві пробіли з
String.trim/1
, видаляє всі порожні рядки і в кінці перевіряє, чи існує тег в базі даних, і якщо немає, то створює його.
Функція
parse_tags/1
Повертає список
MyApp.Tag
структур, який ми прокидываем
put_assoc/3
. Викликаючи
put_assoc/3
, ми говоримо Ecto, що з цього моменту ось ці теги будуть пов'язані з постом. Якщо теги, які були асоційовані раніше, не будуть отримані в
put_assoc/3
, то Ecto потурбується про те, щоб видалити асоціацію між постом і віддаленим тегом в базі даних.
І це все що необхідно для використання зв'язку
many_to_many
,
put_assoc/3
. Функція
put_assoc/3
працює з
has_many
,
belongs_to
і з усіма іншими типами асоціацій. Однак, наш код не готовий до продакшену, давайте подивимося, чому.
Обмеження і стан гонки
Згадаємо, що ми додали унікальний індекс до колонці
:name
тега. Ми зробили це, щоб захистити себе від дублікатів тегів в базі даних.
Додаючи унікальний індекс і потім, використовуючи
get_by
,
insert!
, щоб отримати або вставити тег, ми створюємо потенційну помилку в нашому додатку. Якщо два поста вирушать одночасно з однаковими тегами, то є шанс що перевірка на наявність тега відбудеться в один час, і обидва процесу вирішать, що такого тегу не існує в базі даних. Коли це станеться, тільки один процес успішно виконується, тоді як другий обов'язково провалиться з помилкою. Це стан гонки (прим. пер. — race condition): ваш код буде падати з помилкою час від часу, коли певні умови зустрінуться. І ці умови будуть зустрічатися в залежності від часу.
Багато розробники вважають, що такі помилки ніколи не трапляються на практиці, або якщо трапляються, то вони незначні. Але на практиці, виникнення цих помилок сильно погіршує враження користувача. Я чув приклад «з перших рук», від компанії, яка займається розробкою мобільних ігор. У їхній грі, гравці можуть проходити квести, і кожен квест ти вибираєш другого гравця (гостя) з якогось списку, для проходження квесту разом з тобою. В кінці квесту ти можеш додати другого гравця в друзі.
Спочатку список гостей був рандомным, але через деякий час, користувачі почали скаржитися, що іноді старі акаунти, часто неактивні, з'являються в списку гостей. Щоб виправити цю ситуацію, розробники почали сортувати ігровий список найбільш активним за останній час гравцям. Це означало, що якщо ти грав нещодавно, тобто високі шанс з'явиться у кого небудь в гостьовому списку.
Проте, коли вони внесли ці зміни, у користувачів почало з'являтися багато помилок, і вони, розлючені, прийшли на ігрові форуми. Це сталося тому що, коли вони сортували список активності, як тільки два гравці входять в систему, їх персонажі швидше за все, з'являться в кожному списку інших гостей. Якщо ці гравці вибирають один одного, то тільки перший гравець, який встигне додати другого в друзі, зможе зробити це, другий же не зможе, тому що ставлення з цим користувачем вже буде існувати! Не тільки в цьому проблема, ще й справа в тому, що весь прогрес, отриманий за квест, буде втрачено, тому що сервер не зможе належним чином зберегти результати в базу даних. Зрозуміло, чому користувачі почали скаржитися.
Long story short: ми повинні розібратися зі станом гонки.
На щастя Ecto надає нам механізм обробки constraint errors бази даних.
Перевірка на constraint errors
Коли наша функція
get_or_insert_tag(name)
впаде із за того що тег вже існує в базі даних, нам потрібно обробити цей сценарій. Давайте перепишемо цю функцію, тримаючи в розумі стан гонки.
defp get_or_insert_tag(name) do
%Tag{}
|> Ecto.Changeset.change(name: name)
|> Ecto.Changeset.unique_constraint(:name)
|> Repo.insert
|> do case
{:ok, tag} -> tag
{:error, _} -> Repo.get_by!(MyApp.Tag, name: name)
end
end

Замість того щоб безпосередньо записати тег, ми спочатку збираємо changeset, який дозволяє нам використовувати
unique_constraint
анотацію. Тепер
Repo.insert
не впаде за помилки пов'язаної з унікальним індексом на
:name
, але поверне
{:error, changeset}
кортеж. Отже, якщо
Repo.insert
проходить успішно, це означає що тег збережений, у іншому випадку, якщо існує тег, тоді ми просто отримаємо його з допомогою
Repo.get_by!
.
Хоча механізм, описаний вище, лагодить стан гонки, він досить дорогий. Нам необхідно обробити два запиту для кожного тега, який вже існує в базі даних: (неуспішна) insert і потім отримання тега з репозиторію. Враховуючи що це досить поширений сценарій, ми можемо переписати це все наступним чином:
defp get_or_insert_tag(name) do
Repo.get_by(MyApp.Tag, name: name) || maybe_insert_tag(name)
end

defp maybe_insert_tag(name) do
%Tag{}
|> Ecto.Changeset.change(name: name)
|> Ecto.Changeset.unique_constraint(:name)
|> Repo.insert
|> do case
{:ok, tag} -> tag
{:error, _} -> Repo.get_by!(MyApp.Tag, name: name)
end
end

Код вище створює один запит для кожного існуючого тега, два запиту для кожного нового тега, і 3 запиту в стані гонки. Все це в середньому буде працювати трохи краще, але Ecto 2.1 дозволяє зробити краще.
Upserts
Ecto 2.1 підтримує так звану команду «upsert», яка є абревіатурою до «update or insert». Ідея в тому, що коли ми спробуємо записати в базу даних і отримаємо помилку, наприклад з-за унікального індексу, ми можемо вирішити, чи буде база даних викидати помилку (за замовчуванням), ігнорувати помилку (no error) або оновлювати конфликтующую сутність.
Функція «upsert» в Ecto 2.1 працює з параметром
:on_conflict
. Давайте перепишемо
get_or_insert_tag(name)
ще раз, використовуючи
:on_conflict
параметр. Пам'ятайте, що «upsert» це нова фіча PostgreSQL 9.5, так що переконайтеся, що у вас коштує ця версія бази.
Спробуємо використовувати
:on_conflict
з параметром
:nothing
як нижче:
defp get_or_insert_tag(name) do
Repo.insert!(%MyApp.Tag{name: name}, on_conflict: :nothing)
end

Хоча функція вище і не буде викликати помилку, у разі конфлікту, також вона не буде оновлювати отриману структуру, і буде повертати тег без ID. Одне з рішень-це змусити оновлення відбутися навіть у разі конфлікту, навіть якщо оновлення стосується зміни імені тега. У цьому випадку PostgreSQL також вимагає
:conflict_target
параметр, який вказує на стовпець (або список стовпчиків), в яких ми чекаємо помилку:
defp get_or_insert_tag(name) do
Repo.insert!(%MyApp.Tag{name: name},
on_conflict: [set: [name: name]], conflict_target: :name)
end

І все! Ми пробуємо записати тег в базу, і якщо він вже існує, то говоримо Ecto оновити ім'я до поточного значення, оновлюємо тег і отримуємо його id. Це рішення, безумовно, на крок попереду всіх інших, воно робить один запит на кожен тег. Якщо 10 тегів отримано, буде абсолютно 10 запитів. Як ми можемо поліпшити це?
Upserts і insert_all
Ecto 2.1 додає
:on_conflict
параметр не тільки до
Repo.insert/2
, але також і до
Repo.insert_all/3
функції, представленої в Ecto 2.0. Це означає, що ми можемо одним запитом записати всі відсутні теги, і ще одним отримати їх все. Давайте подивимося як буде виглядати схема
Post
після цих змін:
defmodule MyApp.Post do
use Ecto.Schema

# Schema is the same
schema "posts" do
add title
add :body
many_to_many :tags, MyApp.Tag, join_through: "posts_tags"
timestamps()
end

# Changeset is the same
def changeset(struct, params \\ %{}) do
struct
|> Ecto.Changeset.cast(struct, [:title :body])
|> Ecto.Changeset.put_assoc(:tags, parse_tags(params))
end

# Parse tags has slightly changed
defp parse_tags(params) do
(params["tags"] || "")
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(& &1 == "")
|> insert_and_get_all()
end

defp insert_and_get_all([]) do
[]
end
defp insert_and_get_all(names) do
maps = Enum.map(names, &%{name: &1})
Repo.insert_all MyApp.Tag, maps, on_conflict: :nothing
Repo.all from t in MyApp.Tag, where: t.name in ^names)
end
end

Замість того щоб намагатися записати і отримати кожен тег індивідуально, код вище працює з усіма тегами відразу, спочатку створюючи список мапов(прим. пер. list of maps), який передається в
insert_all
і потім отримує всі теги з необхідними іменами. Тепер, незважаючи на те, скільки тегів ми отримаємо, ми завжди робимо тільки два запиту (крім того варіанту, коли тегів не отримано, тоді ми повернемо порожній список). Це рішення можливо, завдяки впровадженню у Ecto 2.1 параметра
:on_conflict
, який гарантує, що
insert_all
не впаде з помилкою, якщо отриманий тег вже існує.
Наостанок, не забувайте, що ми не використали транзакції в одному прикладі вище. Це рішення було навмисним. Отримання або запис тегів є идемпотентной операцією, тобто ми можемо повторити її скільки завгодно разів, і завжди отримаємо однаковий результат. Тому, якщо ми не можемо записати посаду в базу даних через помилки валідації, користувач буде намагатися надіслати дані форми ще раз, і кожен раз ми будемо робити операцію отримання або запису тегів. У разі, якщо це не потрібно, всі операції можуть бути обгорнуті в транзакцію, або змодельовані за допомогою
Ecto.multi
абстракції, про яку ми поговоримо в наступних розділах.
» Оригинал
Автор: Jose Valim
Джерело: Хабрахабр

0 коментарів

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