Собираем сложные запросы в Ecto
При выполнении запросов приложения предоставляют пользователю ту информацию, к которой у него имеется доступ. В данной статье речь пойдёт о реализации приложения с таким поведением на Фениксе версии 1.3 с использованием Ecto.
Структура приложения
Рассмотрим простой пример приложения для организации управления предприятием. Схема база данных включает в себя следующие таблицы:
companies
;users
;addresses
;appointments
.
Связи между таблицами таковы:
Company->Users
– has_many
;
User->User
(менеджер) – belongs_to
;
User->Address
– has_one
;
User->Appointments
– has_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
.
Заключение
Чем сложнее задача, тем проще должно быть её решение. В нашем случае запрос был разобран на более простые части и превращён в составной. В случае если пользователей необходимо будет отфильтровать по какому-либо другому признаку, достаточно будет добавить две функции (одна для осуществления сопоставления по параметру и фильтрации и пустая вторая) и вызвать их из функции, отвечающей за фильтрацию.
Сочетание сопоставления с образцом и составных запросов оказалось действительно выигрышным.
Полный исходник проекта доступен по ссылке.