How derives work

v0.1.4

A derive string is a short text on a field that lists the sanitizing and validation to apply to its value. "sanitize(trim, downcase) validate(string, email_r)" cleans an email and then checks it. This page explains what a derive string means, the order its ops run in, how errors come out, and where ops are found. The exact syntax is on Derive string syntax.

Two kinds of op

A derive string holds sanitize(...) groups and validate(...) groups. A sanitize op changes the value and never fails. A validate op checks the value and reports an error when the check fails. Validate ops leave the value alone, with one exception: the value that the first validate op of a field returns becomes the field's value. A custom validator can use this to convert a value, as Add your own derive ops describes, and the built-in location and geo_url ops return their geo: URL the same way.

A string can hold more than one group of each kind. GuardedStruct merges the groups of one kind in the order you wrote them, and then runs all sanitize ops before all validate ops, wherever each group sits in the string.

elixir
defmodule Tag do
  use GuardedStruct

  guardedstruct do
    field :slug, String.t(),
      derives:
        "validate(string, not_empty, max_len=5) sanitize(trim, upcase) validate(min_len=2) sanitize(downcase)"
  end
end

Tag.builder(%{slug: "  AbC "})
# {:ok, %Tag{slug: "abc"}}

Tag.builder(%{slug: " A "})
# {:error, [%{field: :slug, action: :min_len, message: "The minimum number of characters in the slug field is 2 and you have sent less than this number of entries"}]}

The parsed string is sanitize: [:trim, :upcase, :downcase] and validate: [:string, :not_empty, {:max_len, 5}, {:min_len, 2}]. The second call trims " A " to "a" before it checks the length, so the length check fails on one character.

All validate ops run

The ops of a field do not stop at the first failure. Each failing op adds its own error, and the errors of one field come out with the last op first.

elixir
defmodule Pin do
  use GuardedStruct

  guardedstruct do
    field :code, String.t(), derives: "validate(string, min_len=5, max_len=2)"
  end
end

{:error, errors} = Pin.builder(%{code: "abc"})
Enum.map(errors, & &1.action)
# [:max_len, :min_len]

With each=[...] a failing list item produces its errors with an __index__ key, the zero-based position of the item. validate(each=[string, not_empty]) on ["a", "", 3] reports not_empty for index 1, and string and not_empty for index 2.

Nil is skipped

A field whose value is nil, including a field the caller left out, is skipped. No op runs on it, so validate(not_nil_value) never fails a missing field. Use enforce: true to require a value and default: to supply one. The skip is why optional=[...] matters inside each: it lets a list item be nil.

One op lifts the skip. sanitize(default_when_nil=value) replaces a nil or missing field with the value, and then the derive string runs on that value, sanitize and validate alike.

elixir
defmodule Counter do
  use GuardedStruct

  guardedstruct do
    field :count, integer(), derives: "sanitize(default_when_nil=0) validate(integer, min_len=0)"
  end
end

Counter.builder(%{})
# {:ok, %Counter{count: 0}}

Where an op comes from

The builder looks for an op in this order and uses the first match.

  1. The built-in ops, listed on Validate ops and Sanitize ops.
  2. The derive extensions that apply to the module, as Add your own derive ops explains.
  3. The fallback modules: the validate_derive and sanitize_derive modules of the guardedstruct section first, then the ones in the application config.

If nothing matches, a validate op reports an error with the action type and a sanitize op leaves the value as it is.

When the string is read

GuardedStruct reads the string when the module compiles. A parameter of the wrong type, such as max_len=foo, raises a Spark.Error.DslError that names the field. A group that is not sanitize(...) or validate(...) raises one too, and so does a group with no closing ). So does an inner op of each=[...] or optional=[...] that no built-in op or derive extension defines.