Создание API запросов в стиле MongoDB для Postgres с Ecto
Проблемы возникают практически в каждом проекте веб-приложения. Одна из этих проблем - найти способ позволить пользователям запрашивать вашу базу данных или фильтровать определенный набор данных.
Снова и снова вам придется создавать пользовательский интерфейс или API, в котором пользователи могут составлять запросы и указывать параметры сортировки.
В зависимости от вашего варианта использования это может быстро превратиться в сложную задачу. Как вы вообще можете разработать такой API запросов? И что еще более важно: как безопасно составлять эти запросы?
Эта статья познакомит вас с малоизвестной функцией Ecto и покажет, как ее использовать для создания API запросов в стиле MongoDB.
Несколько слов о разработке API запросов
Есть много способов разработать API запросов. На самом деле, существует так много способов, что это легко может стать проблемой навешивания велосипеда.
В последние годы GraphQL стал популярным среди многих. В Elixir с Absinthe есть хорошая поддержка GraphQL, но GraphQL требует специальных инструментов, и это действительно может сделать вещи более сложными, чем они должны быть.
Другой вариант - создать RESTful API с поддержкой фильтров. Но для этого нет общепринятого стандарта, и работа с более сложными условиями фильтрации может быстро стать обременительной. Так что впереди по этой дороге еще больше сбрасывают велосипеды - лучше избегайте этого!
К счастью, изобретать велосипед здесь не нужно. Люди, стоящие за MongoDB, разработали стандарт запросов на основе JSON: Документы запросов.
Вот пример из документации MongoDB:
db.inventory.find( { $or: [ { status: "A" }, { qty: { $lt: 30 } } ] } )
Это довольно легко понять. Мы ищем записи инвентаря со статусом "A"
или количеством меньше 30
.
Это представление JSON хорошо подходит даже для самых сложных запросов, оно расширяемо и не требует специальных инструментов. Все, что нам нужно, это парсер JSON.
И хотя изначально он был разработан для базы данных noSQL MongoDB, в этой статье я покажу вам, как реализовать его в Elixir с помощью Ecto for Postgres (или MariaDB / MySQL).
Динамические запросы с Ecto
Вы, наверное, уже знаете, что запросы Ecto можно составлять. Простые запросы по небольшому количеству полей можно очень легко реализовать следующим образом:
filter = %{"artist" => "ABBA", "year" => 1974}
def filter(filter) do from(s in Songs) |> maybe_filter_artist(filter) |> maybe_filter_year(filter) |> Repo.all() end
defp maybe_filter_artist(query, %{"artist" => artist}), do: where(query, [s], s.artist == ^artist)
defp maybe_filter_artist(query, _), do: query
defp maybe_filter_year(query, %{"year" => year}), do: where(query, [s], s.year == ^year)
defp maybe_filter_year(query, _), do: query
поле / 2
Приведенный выше пример уже является достойным решением в некоторых случаях использования.
Но по мере добавления дополнительных полей он становится очень многословным. К счастью, есть способ сделать это проще: функция field/2
в Ecto.Query. Это позволяет нам использовать поля без жесткого кодирования их имени при написании запроса.
С помощью этой функции мы можем упростить код из приведенного выше примера и сделать его гораздо более гибким.
filter = %{ "artist" => "ABBA", "year" => 1974, "language" => "English" }
def filter(filter) do from(s in Songs) |> maybe_filter("artist", filter["artist"]) |> maybe_filter("year", filter["year"]) |> maybe_filter("language", filter["language"]) |> Repo.all() end
@allowed_fields ~w{artist year language} defp maybe_filter(query, _field, nil), do: query
defp maybe_filter(query, field, value) when field in @allowed_fields do field = to_existing_atom(field)
query |> where([s], field(s, ^field) == ^value) end
динамический / 3
Допустим, мы хотим получить все песни, выпущенные ABBA в 1974 или на шведском языке. Наш фильтр в стиле MongoDB будет выглядеть примерно так:
filter = %{
"$or" => [
%{"artist" => "ABBA", "year" => 1974},
%{"language" => "Swedish"}
]
}
При объединении Ecto.Query.where/3
вызовов в цепочку нелегко создать более сложный запрос с OR
и NOT
условиями.
Вот где появляется следующая интересная функция Ecto: Ecto.Query.dynamic/3
. Вместо того, чтобы напрямую работать со структурой Ecto.Query
, как это делает where
, эта функция позволяет независимо строить полностью динамический запрос.
Вот как работает dynamic
в принципе:
condition1 = dynamic([s], s.artist == "ABBA" and s.year == 1974) condition2 = dynamic([s], s.language == "Swedish") or_condition = dynamic([s], ^condition1 or ^condition2)
from(s in Songs) |> where([s], ^or_condition) |> Repo.all()
По иронии судьбы, этот пример использования dynamic/3
очень статичен.
Давайте попробуем создать наши условия динамически на основе пользовательского ввода, объединив то, что мы узнали о field/2
и dynamic/3
:
filter = %{ "$or" => [ %{"artist" => "ABBA", "year" => 1974}, %{"language" => "Swedish"} ] }
def filter(filter) do from(s in Songs) |> filter(filter) |> Repo.all() end
defp filter(query, filter) do # The top level of the query is always an AND condition conditions = build_and(filter) from(q in query, where: ^conditions)) end
# Building a group of AND-connected conditions defp build_and(filter) do Enum.reduce(filter, nil, fn {k, v}, nil -> build_condition(k, v)
{k, v}, conditions -> dynamic([c], ^build_condition(k, v) and ^conditions) end) end
# Building a group of OR-connected conditions defp build_or(filter) do Enum.reduce(filter, nil, fn filter, nil -> build_and(filter)
filter, conditions -> dynamic([c], ^build_and(filter) or ^conditions) end) end
@allowed_fields ~w{artist year language} defp build_condition(field_or_operator, filter)
defp build_condition("$or", filter), do: build_or(filter)
defp build_condition(field, filter) when field in @allowed_fields, do: build_condition(String.to_existing_atom(field), filter)
defp build_condition(field, value) when is_atom(value), do: dynamic([c], field(c, ^field) == ^value)
Теперь у нас есть структура для создания динамических запросов, которую очень легко расширить.
Вы хотите добавить дополнительные операторы, например оператор больше или in? Просто добавьте следующие функции:
defp build_condition(field, %{"$gt" => value}), do: dynamic([c], field(c, ^field) > ^value)
defp build_condition(field, %{"$in" => value}) when is_list(value), do: dynamic([c], field(c, ^field) in ^value)
Изучение dynamic/3
может радикально упростить создание API-интерфейсов с помощью Ecto. Если вы хотите увидеть более подробную и хорошо прокомментированную реализацию принципа, показанного в этой статье, взгляните на модуль запросов в репозиторий Keila GitHub .