defmodule QueryBuilder do require Ecto.Query alias Ecto.Query defmacro __using__(opts) do quote do require QueryBuilder.Schema QueryBuilder.Schema.__using__(unquote(opts)) end end def new(ecto_query) do %QueryBuilder.Query{ecto_query: ensure_query_has_binding(ecto_query)} end def paginate(%QueryBuilder.Query{} = query, repo, opts \\ []) do page_size = Keyword.get(opts, :page_size, default_page_size()) cursor_direction = Keyword.get(opts, :direction, :after) unless cursor_direction in [:after, :before] do raise ArgumentError, "cursor direction #{inspect cursor_direction} is invalid" end page_size = if max_page_size = Keyword.get(opts, :max_page_size) do min(max_page_size, page_size) else page_size end cursor = case Keyword.get(opts, :cursor) || %{} do cursor when is_map(cursor) -> cursor cursor -> with {:ok, decoded_string} <- Base.url_decode64(cursor), {:ok, cursor} <- Jason.decode(decoded_string) do cursor else _ -> %{} end end query = limit(query, page_size + 1) already_sorting_on_id? = Enum.any?(query.operations, fn %{type: :order_by, args: [keyword_list]} -> Enum.member?(Keyword.values(keyword_list), :id) _ -> false end) query = if already_sorting_on_id? do query else order_by(query, asc: :id) end # reverse sorting order if direction is before operations = if cursor_direction == :before do query.operations |> Enum.map(fn %{type: :order_by, args: [keyword_list]} = operation -> updated_keyword_list = Enum.map(keyword_list, fn {direction, field} -> if String.contains?(to_string(field), "@") do {direction, field} else case direction do :asc -> {:desc, field} :desc -> {:asc, field} end end end) Map.put(operation, :args, [updated_keyword_list]) operation -> operation end) else query.operations end query = Map.put(query, :operations, operations) order_by_list = query.operations |> Enum.filter(&match?(%{type: :order_by}, &1)) |> Enum.reverse() |> Enum.flat_map(&Map.fetch!(&1, :args)) |> Enum.flat_map(&Enum.reject(&1, fn {_direction, field} -> String.contains?(to_string(field), "@") end)) |> Enum.uniq_by(fn {_direction, field} -> field end) cursor_fields = Map.keys(cursor) valid_cursor? = Enum.all?(Keyword.values(order_by_list), &Enum.member?(cursor_fields, to_string(&1))) query = if valid_cursor? do {_, filters} = Enum.reduce(order_by_list, {[], []}, fn {order_direction, field}, {prev_fields, filters} -> operator = cond do order_direction == :desc && cursor_direction == :after -> :lt order_direction == :asc && cursor_direction == :after -> :gt # we reversed the sorting order when the cursor direction is :before order_direction == :desc && cursor_direction == :before -> :lt order_direction == :asc && cursor_direction == :before -> :gt end filter = Enum.map(prev_fields, &{&1, cursor[to_string(&1)]}) |> Enum.concat([{field, operator, cursor[to_string(field)]}]) {prev_fields ++ [field], filters ++ [filter]} end) [first_filter | rest_filters] = filters or_filters = Enum.map(rest_filters, &{:or, &1}) where(query, [], first_filter, or_filters) else query end entries = repo.all(query) entries = if cursor_direction == :before do Enum.reverse(entries) else entries end has_more? = length(entries) == page_size + 1 entries = if has_more? do case cursor_direction do :before -> tl(entries) :after -> List.delete_at(entries, -1) end else entries end first_entry = List.first(entries) last_entry = List.last(entries) build_cursor = fn entry -> if entry do order_by_list |> Enum.map(fn {_, field} -> {field, Map.get(entry, field)} end) |> Enum.into(%{}) |> Jason.encode!() |> Base.url_encode64() end end %{ pagination: %{ cursor_direction: cursor_direction, cursor_for_entries_before: build_cursor.(first_entry), cursor_for_entries_after: build_cursor.(last_entry), has_more_entries: has_more?, max_page_size: page_size }, paginated_entries: entries } end def default_page_size() do Application.get_env(:query_builder, :default_page_size, 100) end @doc ~S""" Preloads the associations. Bindings are automatically set if joins have been made, or if it is preferable to join (i.e. one-to-one associations are preferable to include into the query result rather than emitting separate DB queries). Example: ``` QueryBuilder.preload(query, [role: :permissions, articles: [:stars, comments: :user]]) ``` """ def preload(%QueryBuilder.Query{} = query, assoc_fields) do %{query | operations: [%{type: :preload, assocs: assoc_fields, args: []} | query.operations]} end def preload(ecto_query, assoc_fields) do ecto_query = ensure_query_has_binding(ecto_query) preload(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields) end @doc ~S""" An AND where query expression. Example: ``` QueryBuilder.where(query, firstname: "John") ``` """ def where(query, filters) do where(query, [], filters) end @doc ~S""" An AND where query expression. Associations are passed in second argument; fields from these associations can then be referenced by writing the field name, followed by the "@" character and the association name, as an atom. For example: `:name@users`. Example: ``` QueryBuilder.where(query, [role: :permissions], name@permissions: :write) ``` OR clauses may be passed through last argument `opts`. For example: ```elixir QueryBuilder.where(query, [], [firstname: "John"], or: [firstname: "Alice", lastname: "Doe"], or: [firstname: "Bob"]) ``` """ def where(query, assoc_fields, filters, or_filters \\ []) def where(%QueryBuilder.Query{} = query, _assoc_fields, [], []) do query end def where(%QueryBuilder.Query{} = query, assoc_fields, filters, or_filters) do %{query | operations: [%{type: :where, assocs: assoc_fields, args: [filters, or_filters]} | query.operations]} end def where(ecto_query, assoc_fields, filters, or_filters) do ecto_query = ensure_query_has_binding(ecto_query) where(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields, filters, or_filters) end @doc ~S""" Run `QueryBuilder.where/2` only if given condition is met. """ def maybe_where(query, true, filters) do where(query, [], filters) end def maybe_where(query, false, _), do: query def maybe_where(query, condition, assoc_fields, filters, or_filters \\ []) @doc ~S""" Run `QueryBuilder.where/4` only if given condition is met. """ def maybe_where(query, true, assoc_fields, filters, or_filters) do where(query, assoc_fields, filters, or_filters) end def maybe_where(query, false, _, _, _), do: query @doc ~S""" An order by query expression. Example: ``` QueryBuilder.order_by(query, asc: :lastname, asc: :firstname) ``` """ def order_by(query, value) do order_by(query, [], value) end @doc ~S""" An order by query expression. For more about the second argument, see `where/3`. Example: ``` QueryBuilder.order_by(query, :articles, asc: :title@articles) ``` """ def order_by(%QueryBuilder.Query{} = query, _assoc_fields, []) do query end def order_by(%QueryBuilder.Query{} = query, assoc_fields, value) do %{query | operations: [%{type: :order_by, assocs: assoc_fields, args: [value]} | query.operations]} end def order_by(ecto_query, assoc_fields, value) do ecto_query = ensure_query_has_binding(ecto_query) order_by(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields, value) end @doc ~S""" Run `QueryBuilder.order_by/2` only if given condition is met. """ def maybe_order_by(query, true, value) do order_by(query, [], value) end def maybe_order_by(query, false, _), do: query @doc ~S""" Run `QueryBuilder.order_by/3` only if given condition is met. """ def maybe_order_by(query, true, assoc_fields, value) do order_by(query, assoc_fields, value) end def maybe_order_by(query, false, _, _), do: query @doc ~S""" A limit query expression. If multiple limit expressions are provided, the last expression is evaluated Example: ``` QueryBuilder.limit(query, 10) ``` """ def limit(%QueryBuilder.Query{} = query, value) do # Limit order must be maintained, similar to Ecto: # - https://hexdocs.pm/ecto/Ecto.Query-macro-limit.html %{query | operations: [%{type: :limit, assocs: [], args: [value]} | query.operations]} end def limit(ecto_query, value) do limit(%QueryBuilder.Query{ecto_query: ecto_query}, value) end @doc ~S""" A offset query expression. If multiple offset expressions are provided, the last expression is evaluated Example: ``` QueryBuilder.offset(query, 10) ``` """ def offset(%QueryBuilder.Query{} = query, value) do # Offset order must be maintained, similar to Ecto: # - https://hexdocs.pm/ecto/Ecto.Query.html#offset/3 %{query | operations: [%{type: :offset, assocs: [], args: [value]} | query.operations]} end def offset(ecto_query, value) do offset(%QueryBuilder.Query{ecto_query: ecto_query}, value) end @doc ~S""" A join query expression. Example: ``` QueryBuilder.left_join(query, :articles, title@articles: "Foo", or: [title@articles: "Bar"]) ``` """ def left_join(query, assoc_fields, filters \\ [], or_filters \\ []) def left_join(%QueryBuilder.Query{} = query, assoc_fields, filters, or_filters) do %{query | operations: [%{type: :left_join, assocs: assoc_fields, join_filters: [List.wrap(filters), List.wrap(or_filters)]} | query.operations]} end def left_join(ecto_query, assoc_fields, filters, or_filters) do ecto_query = ensure_query_has_binding(ecto_query) left_join(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields, filters, or_filters) end @doc ~S""" Allows to pass a list of operations through a keyword list. Example: ``` QueryBuilder.from_list(query, [ where: [name: "John", city: "Anytown"], preload: [articles: :comments] ]) ``` """ def from_list(query, []), do: query def from_list(query, [{operation, arguments} | tail]) do arguments = cond do is_tuple(arguments) -> Tuple.to_list(arguments) is_list(arguments) -> [arguments] true -> List.wrap(arguments) end apply(__MODULE__, operation, [query | arguments]) |> from_list(tail) end defp ensure_query_has_binding(query) do schema = QueryBuilder.Utils.root_schema(query) unless Query.has_named_binding?(query, schema._binding()) do schema._query() else query end end end