defmodule PhoenixApiToolkit.Ecto.DynamicFilters do
@moduledoc """
Dynamic filtering of Ecto queries is useful for creating list/index functions,
and ultimately list/index endpoints, that accept a map of filters to apply to the query.
Such a map can be based on HTTP query parameters, naturally.
Several filtering types are so common that they have been implemented using standard filter
macro's. This way, you only have to define which fields are filterable in what way.
Documentation for such filters can be autogenerated using `generate_filter_docs/2`.
## Example without standard filters
import Ecto.Query
require Ecto.Query
def list_without_standard_filters(filters \\\\ %{}) do
base_query = from(user in "users", as: :user)
filters
|> Enum.reduce(base_query, fn
{:order_by, {field, direction}}, query ->
order_by(query, [user: user], [{^direction, field(user, ^field)}])
{filter, value}, query when filter in [:id, :name, :residence, :address] ->
where(query, [user: user], field(user, ^filter) == ^value)
_, query ->
query
end)
end
# filtering is optional
iex> list_without_standard_filters()
#Ecto.Query<from u0 in "users", as: :user>
# multiple equal_to matches can be combined
iex> list_without_standard_filters(%{residence: "New York", address: "Main Street"})
#Ecto.Query<from u0 in "users", as: :user, where: u0.address == ^"Main Street", where: u0.residence == ^"New York">
# equal_to matches and sorting can be combined
iex> list_without_standard_filters(%{residence: "New York", order_by: {:name, :desc}})
#Ecto.Query<from u0 in "users", as: :user, where: u0.residence == ^"New York", order_by: [desc: u0.name]>
# other fields are ignored / passed through
iex> list_without_standard_filters(%{number_of_arms: 3})
#Ecto.Query<from u0 in "users", as: :user>
## Example with standard filters and autogenerated docs
Standard filters can be applied using the `standard_filters/6` macro. It supports various filtering styles:
equal_to matches, set membership, smaller/greater than comparisons, ordering and pagination. These filters must
be configured at compile time. Standard filters can be combined with non-standard custom filters.
Documentation can be autogenerated.
@filter_definitions [
atom_keys: true,
string_keys: true,
limit: true,
offset: true,
order_by: true,
order_by_aliases: [
role_name: {:role, :name},
username_last_letter: &__MODULE__.order_by_username_last_letter/2
],
equal_to: [:id, :username, :address, :balance, role_name: {:role, :name}],
equal_to_any: [:address],
string_starts_with: [username_prefix: {:user, :username}],
string_contains: [username_search: :username],
list_contains: [:roles],
list_contains_any: [:roles],
list_contains_all: [all_roles: :roles],
smaller_than: [
inserted_before: :inserted_at,
balance_lt: :balance,
role_inserted_before: {:role, :inserted_at}
],
greater_than_or_equal_to: [
inserted_at_or_after: :inserted_at,
balance_gte: :balance
],
filter_by: [
group_name_alternative: &__MODULE__.by_group_name/2
]
]
@doc \"\"\"
Custom filter function
\"\"\"
def by_group_name(query, group_name) do
where(query, [user: user], user.group_name == ^group_name)
end
@doc \"\"\"
Custom order_by handler
\"\"\"
def order_by_username_last_letter(query, direction) do
order_by(query, [user: user], [{^direction, fragment("right(?, 1)", user.username)}])
end
@doc \"\"\"
Function to resolve named bindings by dynamically joining them into the query.
\"\"\"
def resolve_binding(query, named_binding) do
if has_named_binding?(query, named_binding) do
query
else
case named_binding do
:role -> join(query, :left, [user: user], role in "roles", as: :role)
_ -> query
end
end
end
@doc \"\"\"
My awesome list function. You can filter it, you know! And we guarantee the docs are up-to-date!
\#{generate_filter_docs(@filter_definitions, equal_to: [:group_name])}
\"\"\"
def list_with_standard_filters(filters \\\\ %{}) do
from(user in "users", as: :user)
|> standard_filters filters, :user, @filter_definitions, &resolve_binding/2 do
# Add custom filters first and fall back to standard filters
{:group_name, value}, query -> by_group_name(query, value)
end)
end
# filtering is optional
iex> list_with_standard_filters()
#Ecto.Query<from u0 in "users", as: :user>
# let's do some filtering
iex> list_with_standard_filters(%{username: "Peter", balance_lt: 50.00, address: "sesame street"})
#Ecto.Query<from u0 in "users", as: :user, where: u0.address == ^"sesame street", where: u0.balance < ^50.0, where: u0.username == ^"Peter">
# associations can be dynamically joined into the query, only when necessary
iex> list_with_standard_filters(%{role_name: "admin"})
#Ecto.Query<from u0 in "users", as: :user, left_join: r1 in "roles", as: :role, on: true, where: r1.name == ^"admin">
# limit, offset, and order_by are supported
iex> list_with_standard_filters(%{"limit" => 10, offset: 1, order_by: [desc: :address]})
#Ecto.Query<from u0 in "users", as: :user, order_by: [desc: u0.address], limit: ^10, offset: ^1>
# order_by can use association fields as well, which are dynamically joined in that case
iex> list_with_standard_filters(%{order_by: [asc: {:role, :name}]})
#Ecto.Query<from u0 in "users", as: :user, left_join: r1 in "roles", as: :role, on: true, order_by: [asc: r1.name]>
# order_by can use aliases defined in `order_by_aliases`, without breaking dynamic joining
iex> list_with_standard_filters(%{order_by: [asc: :role_name]})
#Ecto.Query<from u0 in "users", as: :user, left_join: r1 in "roles", as: :role, on: true, order_by: [asc: r1.name]>
# order_by can use function aliases
iex> list_with_standard_filters(%{order_by: [desc: :username_last_letter]})
#Ecto.Query<from u0 in \"users\", as: :user, order_by: [desc: fragment(\"right(?, 1)\", u0.username)]>
# complex custom filters can be combined with the standard filters
iex> list_with_standard_filters(%{group_name: "admins", balance_gte: 50.00})
#Ecto.Query<from u0 in "users", as: :user, where: u0.balance >= ^50.0, where: u0.group_name == ^"admins">
# a custom filter function may be passed into :filter_by as well
iex> list_with_standard_filters(%{group_name_alternative: "admins"})
#Ecto.Query<from u0 in "users", as: :user, where: u0.group_name == ^"admins">
# unsupported filters raise, but nonexistent order_by fields do not (although Ecto will raise, naturally)
iex> list_with_standard_filters(%{number_of_arms: 3})
** (RuntimeError) list filter {:number_of_arms, 3} not recognized
iex> list_with_standard_filters(%{order_by: [:number_of_arms]})
#Ecto.Query<from u0 in "users", as: :user, order_by: [asc: u0.number_of_arms]>
# filtering on lists of values, string prefixes and string-contains filters
iex> list_with_standard_filters(%{address: ["sesame street"], username_prefix: "foo", username_search: "bar"})
#Ecto.Query<from u0 in "users", as: :user, where: u0.address in ^["sesame street"], where: ilike(u0.username, ^"foo%"), where: ilike(u0.username, ^"%bar%")>
# filtering on array-type fields
iex> list_with_standard_filters(%{roles: "admin"})
#Ecto.Query<from u0 in "users", as: :user, where: fragment("? && ?", u0.roles, ^["admin"])>
iex> list_with_standard_filters(%{roles: ["admin", "superadmin"], all_roles: ["creator", "user"]})
#Ecto.Query<from u0 in "users", as: :user, where: fragment("? @> ?", u0.roles, ^["creator", "user"]), where: fragment("? && ?", u0.roles, ^["admin", "superadmin"])>
# you can order by multiple fields and specify bindings
iex> list_with_standard_filters(%{"balance" => 12, "order_by" => [asc: {:user, :username}, desc: :role]})
#Ecto.Query<from u0 in "users", as: :user, where: u0.balance == ^12, order_by: [asc: u0.username], order_by: [desc: u0.role]>
Note that the aim is not to emulate GraphQL in a REST API. It is not possible for the API client to specify which fields the API should return or how deep the nesting should be: it is still necessary to develop different REST resources for differently-shaped responses (for example, `/api/users` or `/api/users_with_groups` etc). In a REST API, simple filtering and sorting functionality can be supported, however, without going the full GraphQL route. We will not discuss the pro's and cons of GraphQL versus REST here, but we maintain that GraphQL is not a drop-in replacement for REST API's in every situation and there is still a place for (flexible) REST API's, for example when caching on anything other than the client itself is desired or when development simplicity trumps complete flexibility and the number of different clients is limited.
## Generating documentation
The call to `generate_filter_docs/2` for the filter definitions as defined above will generate the following (rendered) docs:
> ## Filter key types
>
> Filter keys may be both atoms and strings, e.g. %{username: "Dave123", "first_name" => "Dave"}
>
> ## Equal-to filters
>
> The field's value must be equal to the filter value.
> The equivalent Ecto code is
> ```
> where(query, [binding: bd], bd.field == ^filter_value)
> ```
> The following filter names are supported:
> * `address`
> * `balance`
> * `group_name`
> * `id`
> * `role_name` (actual field is `role.name)`
> * `username`
>
> ## Equal-to-any filters
>
> The field's value must be equal to any of the filter values.
> The equivalent Ecto code is
> ```
> where(query, [binding: bd], bd.field in ^filter_value)
> ```
> The following filter names are supported:
> * `address`
>
> ## Smaller-than filters
>
> The field's value must be smaller than the filter's value.
> The equivalent Ecto code is
> ```
> where(query, [binding: bd], bd.field < ^filter_value)
> ```
> The following filter names are supported:
>
> Filter name | Must be smaller than
> --- | ---
> `balance_lt` | `balance`
> `inserted_before` | `inserted_at`
> `role_inserted_before` | `role.inserted_at`
>
> ## Greater-than-or-equal-to filters
>
> The field's value must be greater than or equal to the filter's value.
> The equivalent Ecto code is
> ```
> where(query, [binding: bd], bd.field >= ^filter_value)
> ```
> The following filter names are supported:
>
> Filter name | Must be greater than or equal to
> --- | ---
> `balance_gte` | `balance`
> `inserted_at_or_after` | `inserted_at`
>
> ## String-starts-with filters
>
> The string-type field's value must start with the filter's value.
> The equivalent Ecto code is
> ```
> where(query, [binding: bd], ilike(bd.field, ^(val <> "%")))
> ```
> The following filter names are supported:
> * `username_prefix` (actual field is `user.username)`
>
> ## String-contains filters
>
> The string-type field's value must contain the filter's value.
> The equivalent Ecto code is
> ```
> where(query, [binding: bd], ilike(bd.field, ^("%" <> val <> "%")))
> ```
> The following filter names are supported:
> * `username_search` (actual field is `username)`
>
> ## List-contains filters
>
> The array-type field's value must contain the filter's value (set membership).
> The equivalent Ecto code is
> ```
> where(query, [binding: bd], fragment("? && ?", bd.field, ^[val]))
> ```
> The following filter names are supported:
> * `roles`
>
> ## List-contains-any filters
>
> The array-type field's value must contain any of the filter's values (set intersection).
> The equivalent Ecto code is
> ```
> where(query, [binding: bd], fragment("? && ?", bd.field, ^val))
> ```
> The following filter names are supported:
> * `roles`
>
> ## List-contains-all filters
>
> The array-type field's value must contain all of the filter's values (subset).
> The equivalent Ecto code is
> ```
> where(query, [binding: bd], fragment("? @> ?", bd.field, ^val))
> ```
> The following filter names are supported:
> * `all_roles` (actual field is `roles)`
>
> ## Order-by sorting
>
> Order-by filters do not actually filter the result set, but sort it according to the filter's value(s). The supported directions can be found in the docs of `Ecto.Query.order_by/3`.
>
> Order-by filters take a list argument, that can consist of the following elements:
> - `field` will sort on the specified field of the default binding in ascending order
> - `{:direction, :field}` will sort on the specified field of the default binding in the specified direction
> - `{:direction, {:binding, :field}}` will sort on the specified field of the specified binding in the specified direction.
>
> Note that the value of `order_by` filters must consist of atoms, even with `string_keys` enabled.
>
> All fields present in the query on any named binding are supported.
> Additionally, aliases for fields in non-default bindings can be defined in `order_by_aliases`. The alias can then be used in `order_by` filters. The following aliases are supported:
> * `role_name` (actual field is `role.name`)
> * `username_last_letter` (opague)
>
> ## Limit filter
>
> The `limit` filter sets a maximum for the number of rows in the result set and may be used for pagination.
>
> ## Offset filter
>
> The `offset` filter skips a number of rows in the result set and may be used for pagination.
>
> ## Filter-by-function filters
>
> The filter applies a function to the query.
>
> The following filter names are supported:
> * `group_name_alternative` (opague)
"""
alias PhoenixApiToolkit.Internal
alias Ecto.Query
import Ecto.Query
require Ecto.Query
@typedoc "Format of a filter that can be applied to a query to narrow it down"
@type filter :: {atom() | String.t(), any()}
@typedoc """
Definition used to generate a filter for `standard_filters/6`.
May take the following forms:
- `atom` filter name and name of field of default binding
- `{filter_name, actual_field}` if the filter name is different from the name of the field of the default binding
- `{filter_name, {binding, actual_field}}` if the field is a field of another named binding
"""
@type filter_definition :: atom | {atom, atom} | {atom, {atom, atom}}
@typedoc """
Filter definitions supported by `standard_filters/6`.
A keyword list of filter types and the filter definitions for which they should be generated.
"""
@type filter_definitions :: [
atom_keys: boolean(),
string_keys: boolean(),
limit: boolean(),
offset: boolean(),
order_by: boolean(),
order_by_aliases: [filter_definition() | (Query.t(), atom -> Query.t())],
equal_to: [filter_definition()],
equal_to_any: [filter_definition()],
smaller_than: [filter_definition()],
smaller_than_or_equal_to: [filter_definition()],
greater_than: [filter_definition()],
greater_than_or_equal_to: [filter_definition()],
string_starts_with: [filter_definition()],
string_contains: [filter_definition()],
list_contains: [filter_definition()],
list_contains_any: [filter_definition()],
list_contains_all: [filter_definition()]
]
@typedoc """
Extra filters supported by a function, for which documentation should be generated by `generate_filter_docs/2`.
A keyword list of filter types and the fields for which documentation should be generated.
"""
@type extra_filter_definitions :: [
order_by_aliases: [filter_definition()],
equal_to: [filter_definition()],
equal_to_any: [filter_definition()],
smaller_than: [filter_definition()],
smaller_than_or_equal_to: [filter_definition()],
greater_than: [filter_definition()],
greater_than_or_equal_to: [filter_definition()],
string_starts_with: [filter_definition()],
string_contains: [filter_definition()],
list_contains: [filter_definition()],
list_contains_any: [filter_definition()],
list_contains_all: [filter_definition()]
]
@doc """
Applies standard filters to the query. Standard
filters include filters for equal_to matches, set membership, smaller/greater than comparisons,
ordering and pagination.
See the module docs `#{__MODULE__}` for details and examples.
Mandatory parameters:
- `query`: the Ecto query that is narrowed down
- `filter`: the current filter that is being applied to `query`
- `default_binding`: the named binding of the Ecto model that generic queries are applied to, unless specified otherwise
- `filter_definitions`: keyword list of filter types and the filter definitions for which they should be generated
Optional parameters:
- resolve_binding: a function that can be passed in to dynamically join the query to resolve named bindings requested in filters
The options supported by the `filter_definitions` parameter are:
- `atom_keys`: supports filter keys as atoms, e.g. `%{username: "Dave"}`
- `string_keys`: supports filter keys as strings, e.g. `%{"username" => "Dave"}`. Note that order_by VALUES must always be atoms: `%{"order_by" => :username}` will work but `%{order_by: "username"}` will not.
- `limit`: enables limit filter
- `offset`: enables offset filter
- `order_by`: enables order_by filter
- `order_by_aliases`: set an alias for a non-default-binding-field, e.g. `{:role_name, {:role, :name}}` which enables `order_by: [desc: :role_name]` OR provide an ordering function that takes the query and direction as arguments
- `equal_to`: field must be equal to filter.
- `equal_to_any`: field must be equal to any value of filter, e.g. `user.id in [1, 2, 3]`. Filter names can be the same as `equal_to` filters.
- `smaller_than`: field must be smaller than filter value, e.g. `user.score < value`
- `smaller_than_or_equal_to`: field must be smaller than or equal to filter value, e.g. `user.score <= value`
- `greater_than`: field must be greater than filter value, e.g. `user.score > value`
- `greater_than_or_equal_to`: field must be greater than or equal to filter value, e.g. `user.score >= value`
- `string_starts_with`: string field must start with case-insensitive string prefix, e.g. `user.name` starts with "dav"
- `string_contains`: string field must contain case-insensitive string, e.g. `user.name` contains "av"
- `list_contains`: array field must contain filter value, e.g. `"admin" in user.roles` (equivalent to set membership)
- `list_contains_any`: array field must contain any filter value, e.g. `user.roles` contains any of ["admin", "creator"] (equivalent to set intersection). Filter names can be the same as `list_contains` filters.
- `list_contains_all`: array field must contain all filter values, e.g. `user.roles` contains all of ["admin", "creator"] (equivalent to subset). Filter names can be the same as `list_contains` filters.
- `filter_by`: filter with a custom function
"""
@spec standard_filters(
Query.t(),
filter,
atom,
filter_definitions,
(Query.t(), atom() -> Query.t()),
any()
) :: any
defmacro standard_filters(
query,
filters,
default_binding,
filter_definitions,
resolve_binding,
overrides \\ nil
)
defmacro standard_filters(
query,
filters,
def_bnd,
filter_definitions,
res_binding,
overrides
) do
# Call Macro.expand/2 in case filter_definitions is a module attribute
definitions = filter_definitions |> Macro.expand(__CALLER__)
# create clauses for the eventual case statement (as raw AST!)
# create clauses for the eventual case statement (as raw AST!)
clauses =
[]
|> maybe_support_limit(definitions)
|> maybe_support_offset(definitions)
|> maybe_support_order_by(definitions, def_bnd, res_binding)
# filters for equal-to-any-of-the-filters-values matches
|> add_clause_for_each(definitions[:equal_to_any], def_bnd, fn {filt, bnd, fld}, clauses ->
clauses ++
quote do
{flt, val}, query
when flt in unquote(create_keylist(definitions, filt)) and is_list(val) ->
query
|> unquote(res_binding).(unquote(bnd))
|> where([{^unquote(bnd), bd}], field(bd, unquote(fld)) in ^val)
end
end)
# filters for equality matches
|> add_clause_for_each(definitions[:equal_to], def_bnd, fn {filt, bnd, fld}, clauses ->
clauses ++
quote do
{flt, val}, query when flt in unquote(create_keylist(definitions, filt)) ->
query
|> unquote(res_binding).(unquote(bnd))
|> where([{^unquote(bnd), bd}], field(bd, unquote(fld)) == ^val)
end
end)
# filters for prefix searches using ilike
|> add_clause_for_each(definitions[:string_starts_with], def_bnd, fn {filt, bnd, fld},
clauses ->
clauses ++
quote do
{flt, val}, query when flt in unquote(create_keylist(definitions, filt)) ->
query
|> unquote(res_binding).(unquote(bnd))
|> where([{^unquote(bnd), bd}], ilike(field(bd, unquote(fld)), ^(val <> "%")))
end
end)
# filters for searches using ilike
|> add_clause_for_each(definitions[:string_contains], def_bnd, fn {filt, bnd, fld},
clauses ->
clauses ++
quote do
{flt, val}, query when flt in unquote(create_keylist(definitions, filt)) ->
query
|> unquote(res_binding).(unquote(bnd))
|> where(
[{^unquote(bnd), bd}],
ilike(field(bd, unquote(fld)), ^("%" <> val <> "%"))
)
end
end)
# filters for set intersection matches
|> add_clause_for_each(definitions[:list_contains_any], def_bnd, fn {filt, bnd, fld},
clauses ->
clauses ++
quote do
{flt, val}, query
when flt in unquote(create_keylist(definitions, filt)) and is_list(val) ->
query
|> unquote(res_binding).(unquote(bnd))
|> where([{^unquote(bnd), bd}], fragment("? && ?", field(bd, unquote(fld)), ^val))
end
end)
# filters for subset-of matches
|> add_clause_for_each(definitions[:list_contains_all], def_bnd, fn {filt, bnd, fld},
clauses ->
clauses ++
quote do
{flt, val}, query
when flt in unquote(create_keylist(definitions, filt)) and is_list(val) ->
query
|> unquote(res_binding).(unquote(bnd))
|> where([{^unquote(bnd), bd}], fragment("? @> ?", field(bd, unquote(fld)), ^val))
end
end)
# filters for set membership matches
|> add_clause_for_each(definitions[:list_contains], def_bnd, fn {filt, bnd, fld}, clauses ->
clauses ++
quote do
{flt, val}, query when flt in unquote(create_keylist(definitions, filt)) ->
query
|> unquote(res_binding).(unquote(bnd))
|> where([{^unquote(bnd), bd}], fragment("? && ?", field(bd, unquote(fld)), ^[val]))
end
end)
# filters for smaller-than matches
|> add_clause_for_each(definitions[:smaller_than], def_bnd, fn {filt, bnd, fld}, clauses ->
clauses ++
quote do
{flt, val}, query when flt in unquote(create_keylist(definitions, filt)) ->
query
|> unquote(res_binding).(unquote(bnd))
|> where([{^unquote(bnd), bd}], field(bd, unquote(fld)) < ^val)
end
end)
# filters for smaller-than-or-equal-to matches
|> add_clause_for_each(definitions[:smaller_than_or_equal_to], def_bnd, fn {filt, bnd, fld},
clauses ->
clauses ++
quote do
{flt, val}, query when flt in unquote(create_keylist(definitions, filt)) ->
query
|> unquote(res_binding).(unquote(bnd))
|> where([{^unquote(bnd), bd}], field(bd, unquote(fld)) <= ^val)
end
end)
# filters for greater-than matches
|> add_clause_for_each(definitions[:greater_than], def_bnd, fn {filt, bnd, fld}, clauses ->
clauses ++
quote do
{flt, val}, query when flt in unquote(create_keylist(definitions, filt)) ->
query
|> unquote(res_binding).(unquote(bnd))
|> where([{^unquote(bnd), bd}], field(bd, unquote(fld)) > ^val)
end
end)
# filters for greater-than-or-equal-to matches
|> add_clause_for_each(definitions[:greater_than_or_equal_to], def_bnd, fn {filt, bnd, fld},
clauses ->
clauses ++
quote do
{flt, val}, query when flt in unquote(create_keylist(definitions, filt)) ->
query
|> unquote(res_binding).(unquote(bnd))
|> where([{^unquote(bnd), bd}], field(bd, unquote(fld)) >= ^val)
end
end)
# filter_by function filters
|> add_clause_for_each(definitions[:filter_by], def_bnd, fn {filt, _bnd, func}, clauses ->
clauses ++
quote do
{flt, val}, query when flt in unquote(create_keylist(definitions, filt)) ->
unquote(func).(query, val)
end
end)
overrides =
case overrides do
[do: {:__block__, _, overrides}] -> overrides
[do: overrides] -> overrides
_ -> []
end
clauses =
clauses ++
overrides ++
quote do
other_filter, _query -> raise "list filter #{inspect(other_filter)} not recognized"
end
function_statement = {:fn, [], clauses}
quote do
Enum.reduce(unquote(filters), unquote(query), unquote(function_statement))
end
end
@doc """
Generate a markdown docstring from filter definitions, as passed to `standard_filters/6`,
as defined by `t:filter_definitions/0`. By specifying `extras`, documentation can be generated
for any custom filters supported by your function as well.
See the module docs `#{__MODULE__}` for details and examples.
"""
@spec generate_filter_docs(filter_definitions(), extra_filter_definitions()) :: binary
def generate_filter_docs(filters, extras \\ []) do
equal_to = get_filters(:equal_to, filters, extras)
equal_to_any = get_filters(:equal_to_any, filters, extras)
smaller_than = get_filters(:smaller_than, filters, extras)
smaller_than_or_equal_to = get_filters(:smaller_than_or_equal_to, filters, extras)
greater_than = get_filters(:greater_than, filters, extras)
greater_than_or_equal_to = get_filters(:greater_than_or_equal_to, filters, extras)
string_starts_with = get_filters(:string_starts_with, filters, extras)
string_contains = get_filters(:string_contains, filters, extras)
list_contains = get_filters(:list_contains, filters, extras)
list_contains_any = get_filters(:list_contains_any, filters, extras)
list_contains_all = get_filters(:list_contains_all, filters, extras)
order_by_aliases = get_filters(:order_by_aliases, filters, extras)
key_type_docs(filters) <>
equal_to_docs(equal_to) <>
equal_to_any_docs(equal_to_any) <>
smaller_than_docs(smaller_than) <>
smaller_than_or_equal_to_docs(smaller_than_or_equal_to) <>
greater_than_docs(greater_than) <>
greater_than_or_equal_to_docs(greater_than_or_equal_to) <>
string_starts_with_docs(string_starts_with) <>
string_contains_docs(string_contains) <>
list_contains_docs(list_contains) <>
list_contains_any_docs(list_contains_any) <>
list_contains_all_docs(list_contains_all) <>
order_by_docs(filters[:order_by], order_by_aliases) <>
limit_docs(filters[:limit]) <>
offset_docs(filters[:offset]) <>
filter_by_docs(filters[:filter_by])
end
############
# Privates #
############
# creates a list for use in a guard (only in macro) depending on the caller's key-types opt-ins
defp create_keylist(definitions, key) do
cond do
definitions[:atom_keys] && definitions[:string_keys] -> [key, "#{key}"]
definitions[:atom_keys] -> [key]
definitions[:string_keys] -> ["#{key}"]
true -> raise "One of :atom_keys or :string_keys must be enabled"
end
end
# returns a filter definitions match key as {filter_name, binding_name, field_name}
defp parse_filter_definition_key({filter, {binding, field}}, _default_binding) do
{filter, binding, field}
end
defp parse_filter_definition_key({filter, field_or_func}, default_binding) do
{filter, default_binding, field_or_func}
end
defp parse_filter_definition_key(filter, default_binding) do
{filter, default_binding, filter}
end
# adds support for a limit-filter if enabled by the caller
defp maybe_support_limit(clauses, definitions) do
if definitions[:limit] do
clauses ++
quote do
{flt, val}, query when flt in unquote(create_keylist(definitions, :limit)) ->
limit(query, ^val)
end
else
clauses
end
end
# adds support for an offset-filter if enabled by the caller
defp maybe_support_offset(clauses, definitions) do
if definitions[:offset] do
clauses ++
quote do
{flt, val}, query when flt in unquote(create_keylist(definitions, :offset)) ->
offset(query, ^val)
end
else
clauses
end
end
# adds support for an order_by-filter if enabled by the caller
# the order_by filter supports multiple order-by fields
defp maybe_support_order_by(clauses, definitions, def_bnd, resolve_binding) do
if definitions[:order_by] do
aliases =
(definitions[:order_by_aliases] || [])
|> Enum.reject(&is_atom/1)
|> Enum.map(&parse_filter_definition_key(&1, def_bnd))
|> Enum.map(fn {filter_name, bnd, fld} -> {filter_name, {bnd, fld}} end)
clauses ++
quote do
{flt, val}, query
when flt in unquote(create_keylist(definitions, :order_by)) and is_list(val) ->
resolve_binding = unquote(resolve_binding)
Enum.reduce(val, query, fn elem, query ->
{dir, bnd, fld} = Internal.parse_order_by(elem, unquote(def_bnd), unquote(aliases))
if is_function(fld) do
query |> resolve_binding.(bnd) |> fld.(dir)
else
query
|> resolve_binding.(bnd)
|> order_by([{^bnd, bd}], [{^dir, field(bd, ^fld)}])
end
end)
end
else
clauses
end
end
# add a new clause to the clauses list for each filter definition in the enumerable
# the reductor must create a new clause from every filter definition
defp add_clause_for_each(clauses, enumerable, default_binding, reductor) do
(enumerable || [])
|> Enum.map(&parse_filter_definition_key(&1, default_binding))
|> Enum.reduce(clauses, reductor)
end
###################################
# Documentation generator helpers #
###################################
defp get_filters(type, filters, extras) do
([] ++ maybe_get_filters(filters, type) ++ maybe_get_filters(extras, type))
|> Enum.map(&parse_filter/1)
|> Enum.sort_by(fn
{filter_name, _field} -> filter_name
filter -> filter
end)
end
defp maybe_get_filters(nil, _type), do: []
defp maybe_get_filters(enum, type), do: enum[type] || []
defp parse_filter({filter_name, {binding, func}}) when is_function(func),
do: {filter_name, "#{binding}.<opague>"}
defp parse_filter({filter_name, {binding, field}}), do: {filter_name, "#{binding}.#{field}"}
defp parse_filter(func) when is_function(func), do: "opague"
defp parse_filter(filter), do: filter
defp key_type_docs(filters) do
cond do
filters[:atom_keys] && filters[:string_keys] ->
"""
## Filter key types
Filter keys may be both atoms and strings, e.g. %{username: "Dave123", "first_name" => "Dave"}
"""
filters[:atom_keys] ->
"""
## Filter key types
Filter keys may only be atoms, e.g. %{username: "Dave123"}
"""
filters[:string_keys] ->
"""
## Filter key types
Filter keys may only be strings, e.g. %{"first_name" => "Dave"}
"""
end
end
defp equal_to_docs([]), do: ""
defp equal_to_docs(equal_to) do
"""
## Equal-to filters
The field's value must be equal to the filter value.
The equivalent Ecto code is
```
where(query, [binding: bd], bd.field == ^filter_value)
```
The following filter names are supported:
#{equal_to |> to_list()}
"""
end
defp equal_to_any_docs([]), do: ""
defp equal_to_any_docs(equal_to_any) do
"""
## Equal-to-any filters
The field's value must be equal to any of the filter values.
The equivalent Ecto code is
```
where(query, [binding: bd], bd.field in ^filter_value)
```
The following filter names are supported:
#{equal_to_any |> to_list()}
"""
end
defp list_contains_docs([]), do: ""
defp list_contains_docs(list_contains) do
"""
## List-contains filters
The array-type field's value must contain the filter's value (set membership).
The equivalent Ecto code is
```
where(query, [binding: bd], fragment("? && ?", bd.field, ^[val]))
```
The following filter names are supported:
#{list_contains |> to_list()}
"""
end
defp list_contains_any_docs([]), do: ""
defp list_contains_any_docs(list_contains_any) do
"""
## List-contains-any filters
The array-type field's value must contain any of the filter's values (set intersection).
The equivalent Ecto code is
```
where(query, [binding: bd], fragment("? && ?", bd.field, ^val))
```
The following filter names are supported:
#{list_contains_any |> to_list()}
"""
end
defp list_contains_all_docs([]), do: ""
defp list_contains_all_docs(list_contains_all) do
"""
## List-contains-all filters
The array-type field's value must contain all of the filter's values (subset).
The equivalent Ecto code is
```
where(query, [binding: bd], fragment("? @> ?", bd.field, ^val))
```
The following filter names are supported:
#{list_contains_all |> to_list()}
"""
end
defp smaller_than_docs([]), do: ""
defp smaller_than_docs(smaller_than) do
"""
## Smaller-than filters
The field's value must be smaller than the filter's value.
The equivalent Ecto code is
```
where(query, [binding: bd], bd.field < ^filter_value)
```
The following filter names are supported:
Filter name | Must be smaller than
--- | ---
#{smaller_than |> to_table()}
"""
end
defp smaller_than_or_equal_to_docs([]), do: ""
defp smaller_than_or_equal_to_docs(smaller_than_or_equal_to) do
"""
## Smaller-than-or-equal-to filters
The field's value must be smaller than or equal to the filter's value.
The equivalent Ecto code is
```
where(query, [binding: bd], bd.field <= ^filter_value)
```
The following filter names are supported:
Filter name | Must be smaller than or equal to
--- | ---
#{smaller_than_or_equal_to |> to_table()}
"""
end
defp greater_than_docs([]), do: ""
defp greater_than_docs(greater_than) do
"""
## Greater-than filters
The field's value must be greater than the filter's value.
The equivalent Ecto code is
```
where(query, [binding: bd], bd.field > ^filter_value)
```
The following filter names are supported:
Filter name | Must be greater than
--- | ---
#{greater_than |> to_table()}
"""
end
defp greater_than_or_equal_to_docs([]), do: ""
defp greater_than_or_equal_to_docs(greater_than_or_equal_to) do
"""
## Greater-than-or-equal-to filters
The field's value must be greater than or equal to the filter's value.
The equivalent Ecto code is
```
where(query, [binding: bd], bd.field >= ^filter_value)
```
The following filter names are supported:
Filter name | Must be greater than or equal to
--- | ---
#{greater_than_or_equal_to |> to_table()}
"""
end
defp order_by_docs(true, aliases) do
"""
## Order-by sorting
Order-by filters do not actually filter the result set, but sort it according to the filter's value(s). The supported directions can be found in the docs of `Ecto.Query.order_by/3`.
Order-by filters take a list argument, that can consist of the following elements:
- `field` will sort on the specified field of the default binding in ascending order
- `{:direction, :field}` will sort on the specified field of the default binding in the specified direction
- `{:direction, {:binding, :field}}` will sort on the specified field of the specified binding in the specified direction.
Note that the value of `order_by` filters must consist of atoms, even with `string_keys` enabled.
All fields present in the query on any named binding are supported.
Additionally, aliases for fields in non-default bindings can be defined in `order_by_aliases`. The alias can then be used in `order_by` filters.
""" <>
if aliases != [] do
"""
The following aliases are supported:
#{aliases |> to_list()}
"""
else
"\n"
end
end
defp order_by_docs(_, _), do: ""
defp limit_docs(true) do
"""
## Limit filter
The `limit` filter sets a maximum for the number of rows in the result set and may be used for pagination.
"""
end
defp limit_docs(_), do: ""
defp offset_docs(true) do
"""
## Offset filter
The `offset` filter skips a number of rows in the result set and may be used for pagination.
"""
end
defp offset_docs(_), do: ""
defp string_starts_with_docs([]), do: ""
defp string_starts_with_docs(string_starts_with) do
"""
## String-starts-with filters
The string-type field's value must start with the filter's value.
The equivalent Ecto code is
```
where(query, [binding: bd], ilike(bd.field, ^(val <> "%")))
```
The following filter names are supported:
#{string_starts_with |> to_list()}
"""
end
defp string_contains_docs([]), do: ""
defp string_contains_docs(string_contains) do
"""
## String-contains filters
The string-type field's value must contain the filter's value.
The equivalent Ecto code is
```
where(query, [binding: bd], ilike(bd.field, ^("%" <> val <> "%")))
```
The following filter names are supported:
#{string_contains |> to_list()}
"""
end
defp filter_by_docs(filter_by) when is_list(filter_by) and length(filter_by) > 0 do
"""
## Filter-by-function filters
The filter applies a function to the query.
The following filter names are supported:
#{filter_by |> to_list()}
"""
end
defp filter_by_docs(_), do: ""
defp to_list(list) do
Enum.reduce(list, "", fn
{filter_name, func}, acc when is_function(func) -> "#{acc}* `#{filter_name}` (opague)\n"
{filter_name, field}, acc -> "#{acc}* `#{filter_name}` (actual field is `#{field}`)\n"
filter, acc -> "#{acc}* `#{filter}`\n"
end)
|> String.trim_trailing("\n")
end
defp to_table(keyword) do
Enum.reduce(keyword, "", fn {k, v}, acc -> acc <> "`#{k}` | `#{v}`\n" end)
|> String.trim_trailing("\n")
end
end