Fltr lets you define and compose reusable filters for Ecto queries, with optional parsing for external input.

Usage

Define the supported filters and their argument counts, then implement one to_expr clause for each filter:

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 argument count does not include the filter name. Here, :active maps to to_expr/1, while :id and :name map to to_expr/2. Fltr verifies the required callback arities when the module compiles.

A single filter

Pass a canonical filter directly to to_expr/1, then interpolate the resulting dynamic expression into an Ecto query:

import Ecto.Query

filter = TeamFilter.to_expr({:active})

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

Boolean groups

Use :all to require every child filter and :any to require at least one. Groups can contain other groups:

filter =
  {:all,
   [
     {:active},
     {:any, [{:id, 7}, {:name, "Rovers"}]}
   ]}

filter = TeamFilter.to_expr(filter)

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

External filters

When a filter comes from outside the application, pass it through the parse/1 function generated by use Fltr before compiling it:

import Ecto.Query

def list_teams(filter) do
  with {:ok, filter} <- TeamFilter.parse(filter) do
    filter = TeamFilter.to_expr(filter)

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

External filters use strings and lists:

list_teams([
  "all",
  [
    ["active"],
    ["any", [["id", 7], ["name", "Rovers"]]]
  ]
])

Arguments pass through unchanged by default. Define parse/2 in the filter module when an argument needs validation or normalization:

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

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

Lists and string-named tuples are treated as external input and invoke parse/2. Atom-named tuples are trusted canonical expressions: Fltr checks their argument count but does not parse their values again. Always call parse/1 before converting external input into an atom-named tuple.

parse/1 returns an error describing invalid input:

{:error, {:invalid_filter, input}}
{:error, {:unknown_filter, name}}
{:error, {:invalid_arguments, name, arguments}}