Собираем сложные запросы в Ecto

При выполнении запросов приложения предоставляют пользователю ту информацию, к которой у него имеется доступ. В данной статье речь пойдёт о реализации приложения с таким поведением на Фениксе версии 1.3 с использованием Ecto.

Структура приложения

Рассмотрим простой пример приложения для организации управления предприятием. Схема база данных включает в себя следующие таблицы:

  • companies;
  • users;
  • addresses;
  • appointments.

Связи между таблицами таковы:

Company->Usershas_many; User->User (менеджер) – belongs_to; User->Addresshas_one; User->Appointmentshas_many.

Роли

У каждого пользователя есть своя роль, определяющая то, чьи записи он может просматривать.

  • admin – внутренний администратор системы. Царь и бог.
  • admin_manager  – видит всех работников своей компании.
  • manager – видит себя и тех, чей user_id указан в поле manager_id.
  • direct_report  – видит только себя.

Примечание: Приложение должно иметь авторизацию и запрашивать её для большинства API-вызовов, но мы не будем подробно останавливаться на этом. Guardian сделает всё за нас.

Сценарий использования

Основной сценарий использования нашего API – осуществление выборки пользователей по следующим критериям:

  • Показать всех пользователей;
  • Показать всех пользователей в определённой должности;
  • Показать пользователей одного пола;
  • Показать пользователей из одного штата (области);
  • Показать пользователей по должностям.

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

Если вы хотели бы построить собственное решение на основе примера из данной статьи, то каркас проекта ищите здесь. Данная ветка содержит всё необходимое для начала работы: установку Guardian, начальные данные и набор инструментов тестирования Postman. С таким набором вы сможете войти в систему под одним из тестовых пользователей и проверить работоспособность кода.

Шаг 1. Настройка контроллера

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

index(conn, params) do
  users = Account.list_users(conn.assigns.current_user, params)
  render(conn, "index.json", users: users)
end

Контекстом для пользователей является Account. Будем использовать шаблонную функцию list_users, но наряду с параметрами передавать в неё информацию об авторизированном пользователе.

Если вы ещё не знакомы с контекстами Феникса 1.3, видео с выступлением Криса МакКорда на конференции Lonestar Elixir поможет в них разобраться.

Шаг 2: Сопоставление с образцом в действии

Многим разработчикам рано или поздно приходит в голову мысль:

И снова оператор if. Что-то я явно делаю не так…

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

list_users(user, params) do
  User.list_users(user, user.role, params)
end

Прежде чем вызвать функцию list_users модуля Users, добавим дополнительный параметр – user.role, который можно использовать при сопоставлении с образцом для сортировки списка пользователей.

Теперь попробуйте написать изменённую версию этой функцию, добавив сопоставление с образцом по admin_manager. Эта функция должна выбираться пользователей из той же компании. Основываясь на схеме выше.

А я подожду.

list_users(user, "admin_manager", params) do
  User
  |> join(:inner, [u], c in assoc(u, :company))
  |> where([_u, company], company.id == ^user.company_id)
  |> Repo.all()
end
|> join(:inner, [u], c in assoc(u, :company))

Производя внутреннее соединение, мы получаем возможность вывести только тех пользователей, которые раотают в компании вошедшего в систему пользователя. Привязка запроса [u] указывает на users, а c in assoc(u, :company) выводит соответствующую таблицу.

|> where([_u, company], company.id == ^user.company_id)

Данное выражение where содержит в себе две привязки данных. Первая из них более не актуальна, поэтому можно добавить нижнее подчёркивание перед u. А привязкой запроса компании мы можем обеспечить сопоставление company.id и user.company_id. Обратите внимание на префикс ^. Это пин-оператор, который обеспечивает интерполяцию значения user.company_id внутри запроса.

|> Repo.all()

Теперь можно передать запрос в функцию Repo.all() и получить результат. Отлично.

Написать функции для ролей manager и direct_report не составит труда. Реализовав эти функции, можно отфильтровать пользователей по штату. API-вызов будет выглядеть следующим способом:

http://localhost:4000/api/v1/users?state=Indiana

Сгруппируем все полученные знания и создадим функцию внутри модуля User для тех же целей.

def by_state(query, %{"state" => state}) do 
  query 
  |> join(:inner, [u], address in assoc(u, :address)) 
  |> where([u, address], address.state == ^state) 
end

Сопоставляя второй аргумент с образцом, мы достаём значение state и выполняем соответствующий запрос. Для создания запроса по title, нужно просто осуществить сопоставление с образцом по этому параметру и добавить необходимое условие where.

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

def by_state(query, _params), do: query

Расположив подобную версию функции после остальных (запомните – порядок важен), которые ожидают найти определённые параметры, мы позволим пройти запросу через них нетронутым.

Обновлённая функция list_users

Имея функцию by_state, мы можем обновить изначальную роль admin так, чтобы выборка происходила на основе решения администратора.

https://gist.github.com/658fadebc23d3570157c308cb962ded4

Если администратор подтверждает API-запрос с параметром state или без него, мы можем ответить соответствующе. Такой подход позволяет строить запросы по частям, но вместе с этим возникает одна проблема. Как быть, если пользователь захочет применить несколько фильтров, что повлечёт за собой слияние нескольких таблиц? К примеру, руководитель отдела запросил вывести пользователей из штата Огайо.

Для меня, как для менеджера-администратора, список пользователей будет ограничен с помощью объединения пользователей и компаний. Текущая версия функции by_state поддерживает только две привязки. Как же сообщить Ecto количество привязок, если запрос создаётся динамически? Именованные привязки в Ecto пока не поддерживаются.

Вместо этого нужно добавить в запросы использование многоточия. При составлении запроса нам будет известна первая привязка (для User) и последняя – та, которую мы добавляем в функцию. А что будет находиться между ними – загадка. Может, ничего, а может, ещё пара привязок…

by_state(query, %{"state" => state}) do
  query
  |> join(:inner, [u], address in assoc(u, :address))
  |> where([u, ..., address], address.state == ^state)
end

Хотите узнать, откуда взялось это решение? Читайте здесь

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

Например, мы могли бы составить запрос на поиск пользователей какой-нибудь компании, состоящих в должности «инженер», которые отчитывались перед менеджером с ID 1. Данные подход позволяет создать фильтр, который будет сокращать выборку до тех пор. пока мы не получим необходимый результат.

Предлагаю вам поразмыслить над следующими задачами:

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

Заключение

Чем сложнее задача, тем проще должно быть её решение. В нашем случае запрос был разобран на более простые части и превращён в составной. В случае если пользователей необходимо будет отфильтровать по какому-либо другому признаку, достаточно будет добавить две функции (одна для осуществления сопоставления по параметру и фильтрации и пустая вторая) и вызвать их из функции, отвечающей за фильтрацию.

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

Полный исходник проекта доступен по ссылке.

© 2020 / Россия Любые мысли и вопросы пишите на elixir@wunsh.ru.