Defines reusable filters that compile to Ecto dynamic expressions.

use Fltr turns a module into a filter parser and compiler. The module declares the filter names it accepts, validates untrusted input with the generated parse/1, and converts validated filters with the generated to_expr/1.

Defining filters

Declare each filter with the number of arguments it accepts, then implement a matching to_expr clause:

defmodule TeamFilter do
  use Fltr, filters: [active: 0, id: 1, name: 1]

  def to_expr(:active), do: dynamic([team], team.active)
  def to_expr(:id, id), do: dynamic([team], team.id == ^id)
  def to_expr(:name, name), do: dynamic([team], team.name == ^name)
end

The declared argument count does not include the filter name. In the example, :active requires to_expr/1, while :id and :name require to_expr/2. Fltr checks that every required callback arity exists when the module compiles.

use Fltr also imports Ecto.Query.dynamic/2 into the filter module.

Parsing external input

String-named lists and tuples are external input. parse/1 checks their names and argument counts, converts names to the declared atoms without creating new atoms, and returns a canonical tuple:

TeamFilter.parse(["id", 7])
#=> {:ok, {:id, 7}}

Arguments pass through unchanged by default. Define parse/2 clauses when a filter needs validation or normalization:

def parse(:id, id) when is_binary(id) do
  case Integer.parse(id) do
    {id, ""} when id > 0 -> {:ok, id}
    _other -> :error
  end
end

def parse(:id, _id), do: :error

For a one-argument filter, parse/2 receives and returns the argument directly. For a filter with two or more arguments, it receives a tuple and must return a tuple of the declared size. Filters with no arguments do not invoke parse/2.

Parsed leaves preserve the declared shape: {:active}, {:id, id}, or {:score_between, minimum, maximum}.

Atom-named tuples are considered canonical expressions. Fltr checks their name and argument count, but deliberately does not pass their values through parse/2. Always call parse/1 on external input before converting it into an atom-named tuple.

Boolean groups

:all joins child expressions with and; :any joins them with or. Groups require at least one child and may be nested:

{:any, [{:id, id}, {:active}]}
{:all, [{:name, name}, {:active}]}

External groups use the equivalent list form with string names:

["any", [["id", id], ["active"]]]

Building a query

Parse external input, compile the canonical filter, and interpolate the resulting dynamic expression into an Ecto query:

import Ecto.Query

{:ok, filter} = TeamFilter.parse(["name", "Rovers"])
filter = TeamFilter.to_expr(filter)

Team
|> where(^filter)
|> Repo.all()