Write a field validator and a main validator
v0.1.4
Two hooks run your own Elixir functions inside the builder: a validator for one field, and a main validator for the whole map. Use them for rules that a derive string cannot express, such as a check against your data or a rule that compares two fields. This guide shows both, the values they return and when they run.
Before you begin
You need a guarded module. Rules that fit a derive op, such as length or format, are simpler there. See Validate ops.
Check one field with a validator
Set validator: {Module, :function} on a field. The builder calls Module.function(field, value) and expects one of two results.
| Return | Effect |
|---|---|
{:ok, field, value} | The field continues with value, which can differ from the input. |
{:error, field, message} | The builder adds an error with the action validator and your message. |
defmodule Checks do
def not_disposable(field, value) when is_binary(value) do
if value |> String.trim() |> String.downcase() |> String.ends_with?("@mailinator.com"),
do: {:error, field, "disposable addresses are not allowed"},
else: {:ok, field, value}
end
def not_disposable(field, _value), do: {:error, field, "must be text"}
end
defmodule Member do
use GuardedStruct
guardedstruct do
field :email, String.t(),
enforce: true,
validator: {Checks, :not_disposable},
derives: "sanitize(trim, downcase) validate(email_r)"
end
end
Member.builder(%{email: " [email protected] "})
# {:error,
# [%{field: :email, action: :validator, message: "disposable addresses are not allowed"}]}
Member.builder(%{email: " [email protected] "})
# {:ok, %Member{email: "[email protected]"}}The validator runs only for keys that are present in the input. The compiler warns when the function is not exported with two arguments. Return one of the two shapes above; the builder supports no other return value.
Check every field from the module
Define validator/2 in the module and the builder calls it for each present field that has no validator: of its own. Add a last clause that accepts everything else, or fields you did not plan for fail.
defmodule Post do
use GuardedStruct
guardedstruct do
field :title, String.t()
field :slug, String.t()
end
def validator(:title, value) when is_binary(value), do: {:ok, :title, String.trim(value)}
def validator(:title, _value), do: {:error, :title, "title must be text"}
def validator(field, value), do: {:ok, field, value}
end
Post.builder(%{title: " Hello ", slug: "hello"})
# {:ok, %Post{title: "Hello", slug: "hello"}}The builder also calls validator/2 with the name of a sub_field and its raw map, unless that sub_field has its own validator:.
Check the whole map with main_validator
Define main_validator/1 in the module to check fields against each other. It receives a map of the values after the field validators ran, with atom keys, and it runs once.
| Return | Effect |
|---|---|
{:ok, map} | The builder continues with map, which can change values. |
{:error, [error_map]} | Each map is added to the errors. A map needs field, action and message. |
{:error, error_map} | The same, for a single map. |
| Anything else | Treated as {:ok, attrs}. |
defmodule Window do
use GuardedStruct
guardedstruct do
field :min, integer(), derives: "validate(integer)"
field :max, integer(), derives: "validate(integer)"
end
def main_validator(%{min: min, max: max}) when min > max do
{:error, [%{field: :max, action: :range_order, message: "max must not be less than min"}]}
end
def main_validator(attrs), do: {:ok, attrs}
end
Window.builder(%{min: 5, max: 1})
# {:error, [%{field: :max, action: :range_order, message: "max must not be less than min"}]}
Window.builder(%{min: 1})
# {:ok, %Window{min: 1, max: nil}}The main validator runs even when a field validator failed, and the failed field is missing from the map it receives. Keep a clause that accepts maps with missing keys, as the last clause above does.
The section option main_validator: {Module, :function} does the same without a function in the module. The builder calls Module.function(attrs) with the same return values, and a main_validator/1 defined in the module is not called when the option is set.
defmodule Limits do
def ordered(%{min: min, max: max}) when min > max do
{:error, [%{field: :max, action: :range_order, message: "max must not be less than min"}]}
end
def ordered(attrs), do: {:ok, attrs}
end
defmodule Span do
use GuardedStruct
guardedstruct main_validator: {Limits, :ordered} do
field :min, integer(), derives: "validate(integer)"
field :max, integer(), derives: "validate(integer)"
end
end
Span.builder(%{min: 5, max: 1})
# {:error, [%{field: :max, action: :range_order, message: "max must not be less than min"}]}A sub_field takes the same option. Its function receives the attributes of that nested map, after the field validators of the nested struct ran. A main_validator/1 defined in the parent module does not run for a sub_field.
Know when they run
Validators run before the derive strings. A validator and a main validator see the value as the caller sent it, before sanitize ops trim or change case. That is why not_disposable above trims and lower-cases the value itself. If a validator returns a changed value, the derive string works on that value. The How the builder works page lists the order of every step.
Next steps
- Add your own derive ops: reusable checks you call from a derive string.
- Accept more than one shape with conditional_field: validators that choose a child.
- Validate input without building a struct: run a field's validator on its own.