Backpex filters support changeset-based validation to ensure that URL parameters are validated before being applied to database queries. This provides type safety, prevents crashes from malformed data, and enables user-friendly error messages.
How Validation Works
When a user applies a filter (via the UI or URL parameters), Backpex:
- Builds a Changeset: Creates an Ecto schemaless changeset from the URL parameters
- Casts Values: Converts string values to the appropriate types based on
type/1 - Runs Validations: Applies any custom validations defined in
changeset/3 - Extracts Valid Values: Only filters that pass validation are applied to the query
- Shows Errors: Invalid filters display inline validation errors in the UI
Invalid filters are not applied to the query - the user sees unfiltered results for that attribute while the error is displayed.
The type/1 Callback
The type/1 callback specifies the Ecto type for your filter's value. This is used for casting URL string parameters to the correct type.
@impl Backpex.Filter
def type(_assigns), do: :stringCommon Types
| Type | Use Case | Example Values |
|---|---|---|
:string | Single value select/text filters | "active", "pending" |
{:array, :string} | Multi-select, boolean (checkbox) filters | ["a", "b"] |
:map | Range filters with start/end | %{"start" => "1", "end" => "100"} |
:integer | Numeric filters (whole numbers) | 42 |
:float | Numeric filters (decimals) | 3.14 |
Type Examples
Select filter (single value):
def type(_assigns), do: :stringBoolean filter (multiple checkboxes):
def type(_assigns), do: {:array, :string}Range filter:
def type(_assigns), do: :mapNumeric filter:
def type(_assigns), do: :integerThe changeset/3 Callback
The changeset/3 callback allows you to add custom validations to your filter. It receives the changeset being built, the field atom, and assigns.
@impl Backpex.Filter
def changeset(changeset, field, _assigns) do
changeset
|> Ecto.Changeset.validate_number(field, greater_than: 0, less_than: 1000)
endDefault Implementation
If you don't implement changeset/3, the default implementation returns the changeset unchanged. Type casting still occurs based on type/1.
Validation Examples
Validate numeric range:
def changeset(changeset, field, _assigns) do
Ecto.Changeset.validate_number(changeset, field,
greater_than_or_equal_to: 0,
less_than_or_equal_to: 100
)
endValidate allowed values:
def changeset(changeset, field, _assigns) do
Ecto.Changeset.validate_inclusion(changeset, field, ["active", "pending", "closed"])
endValidate format:
def changeset(changeset, field, _assigns) do
Ecto.Changeset.validate_format(changeset, field, ~r/^[A-Z]{2,3}-\d+$/)
endValidate based on options (for select filters):
def changeset(changeset, field, assigns) do
valid_values = Enum.map(options(assigns), fn {_label, value} -> to_string(value) end)
Ecto.Changeset.validate_inclusion(changeset, field, valid_values)
endBuilt-in Filter Validation
The built-in filters automatically validate their values:
Boolean Filter
Validates that all selected checkbox keys exist in the options/1 list.
# If options returns:
[
%{label: "Published", key: "published", predicate: ...},
%{label: "Draft", key: "draft", predicate: ...}
]
# Valid: ["published"], ["draft"], ["published", "draft"]
# Invalid: ["unknown_key"]Select Filter
Validates that the selected value exists in the options/1 list.
# If options returns:
[{"Active", "active"}, {"Inactive", "inactive"}]
# Valid: "active", "inactive"
# Invalid: "unknown"MultiSelect Filter
Validates that all selected values exist in the options/1 list.
# If options returns:
[{"John", "user-1"}, {"Jane", "user-2"}]
# Valid: ["user-1"], ["user-1", "user-2"]
# Invalid: ["user-1", "invalid-uuid"]Range Filter
Validates based on the range type:
Number ranges:
- Values must be valid integers or floats
- Start must be less than or equal to end (when both provided)
Date ranges:
- Values must be valid ISO 8601 dates (YYYY-MM-DD)
- Start date must be on or before end date (when both provided)
Datetime ranges:
- Values must be valid ISO 8601 dates
- Start must be on or before end
- Time boundaries are automatically added (00:00:00 for start, 23:59:59 for end)
The validate/2 Callback
The validate/2 callback provides a public API for programmatic validation and testing:
@impl Backpex.Filter
def validate(value, assigns) do
# Returns {:ok, casted_value} or {:error, errors}
endThe default implementation builds a mini-changeset and validates it using type/1 and changeset/3. You typically don't need to override this.
Testing Validation
defmodule MyFilterTest do
use ExUnit.Case
alias MyApp.Filters.StatusFilter
test "validates allowed values" do
assert {:ok, "active"} = StatusFilter.validate("active", %{})
assert {:error, _} = StatusFilter.validate("invalid", %{})
end
test "validates numeric ranges" do
assert {:ok, 50} = MyApp.Filters.AmountFilter.validate("50", %{})
assert {:error, _} = MyApp.Filters.AmountFilter.validate("-10", %{})
end
endQuery Receives Validated Values
The query/4 callback receives already-validated and casted values:
def query(query, attribute, value, _assigns) do
# value is already an integer
where(query, [x], field(x, ^attribute) == ^value)
endComplete Custom Filter Example
Here's a complete example of a custom filter with validation:
defmodule MyAppWeb.Filters.PriceRange do
use BackpexWeb, :filter
import Ecto.Query
@impl Backpex.Filter
def label, do: "Price Range"
@impl Backpex.Filter
def type(_assigns), do: :map
@impl Backpex.Filter
def changeset(changeset, field, _assigns) do
Ecto.Changeset.validate_change(changeset, field, fn _field, value ->
validate_price_range(value, field)
end)
end
defp validate_price_range(%{"min" => min_str, "max" => max_str}, field) do
errors = []
min = parse_price(min_str)
max = parse_price(max_str)
errors = if min_str != "" and is_nil(min) do
[{field, "minimum price is invalid"}]
else
errors
end
errors = if max_str != "" and is_nil(max) do
[{field, "maximum price is invalid"} | errors]
else
errors
end
errors = if min && max && min > max do
[{field, "minimum cannot exceed maximum"} | errors]
else
errors
end
errors
end
defp validate_price_range(_value, _field), do: []
defp parse_price(""), do: nil
defp parse_price(str) do
case Float.parse(str) do
{value, ""} when value >= 0 -> value
_ -> nil
end
end
@impl Backpex.Filter
def query(query, attribute, %{"min" => min, "max" => max}, _assigns) do
query
|> maybe_filter_min(attribute, parse_price(min))
|> maybe_filter_max(attribute, parse_price(max))
end
def query(query, _attribute, _value, _assigns), do: query
defp maybe_filter_min(query, _attr, nil), do: query
defp maybe_filter_min(query, attr, min), do: where(query, [x], field(x, ^attr) >= ^min)
defp maybe_filter_max(query, _attr, nil), do: query
defp maybe_filter_max(query, attr, max), do: where(query, [x], field(x, ^attr) <= ^max)
@impl Backpex.Filter
def render(assigns) do
min = assigns.value["min"]
max = assigns.value["max"]
~H"""
<span :if={@value["max"] == ""}>≥ <%= min %></span>
<span :if={@value["min"] == ""}>≤ <%= max %></span>
<span :if={@value["min"] != "" and @value["max"] != ""}><%= min %> — <%= max %></span>
"""
end
@impl Backpex.Filter
def render_form(assigns) do
~H"""
<div class="mt-2 space-y-2">
<label class={["input input-sm", @errors != [] && "input-error bg-error/10"]}>
<span class="text-base-content/50">Min</span>
<input
type="number"
name={@form[@field].name <> "[min]"}
value={@value["min"]}
min="0"
step="0.01"
/>
</label>
<label class={["input input-sm", @errors != [] && "input-error bg-error/10"]}>
<span class="text-base-content/50">Max</span>
<input
type="number"
name={@form[@field].name <> "[max]"}
value={@value["max"]}
min="0"
step="0.01"
/>
</label>
</div>
<.error :for={msg <- @errors} class="mt-1">{msg}</.error>
"""
end
endError Display
When validation fails, the filter shows an error state in the UI:
- The filter input shows an error border/styling
- An error message appears below the input
- The filter badge does not appear (filter is not applied)
- Results show unfiltered data for that attribute
This provides immediate feedback while keeping the application stable.
Displaying Errors in Custom Filters
The @errors assign is passed to your filter's render_form/1 callback, containing a list of translated error messages. Built-in filters automatically display these errors with appropriate styling. For custom filters, you can display errors using the .error component (available via use BackpexWeb, :filter):
@impl Backpex.Filter
def render_form(assigns) do
~H"""
<input
type="text"
name={@form[@field].name}
value={@value}
class={["input input-sm mt-2", @errors != [] && "input-error bg-error/10"]}
/>
<.error :for={msg <- @errors} class="mt-1">{msg}</.error>
"""
endBest Practices
- Always implement
type/1: Even if using the default:string, be explicit - Validate against options: For select/multi-select filters, validate values exist in your options list
- Handle partial values: For range filters, allow empty start or end values
- Keep
query/4simple: Since values are validated, focus on the query logic - Test validation: Write unit tests for your
validate/2to ensure edge cases are handled - Use Ecto validations: Leverage
Ecto.Changesetvalidation functions for consistency - Display errors in custom filters: Use
@errorsinrender_form/1to show validation feedback to users