[Из песочницы] Связь многие ко многим и upsert в Ecto 2.1

yes_we_can.jpg

В предыдущей главе мы говорили о 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
  |> case do
    {: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
  |> case do
    {: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

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

  • 14 декабря 2016 в 17:40

    0

    Вместо «мэпы» лучше сказать «ассоциативные массивы».

© Habrahabr.ru