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.

ReturnEffect
{: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.
elixir
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.

elixir
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.

ReturnEffect
{: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 elseTreated as {:ok, attrs}.
elixir
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.

elixir
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