lib/ecto/dynamic_filters.ex

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