Создание 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 .