Add your own derive ops

v0.1.4

The derive strings of a field, such as sanitize(trim) and validate(email_r), call named ops. You can add ops of your own and call them from any derive string. This guide defines an extension module, registers it for one module or for the whole app, and shows the other way to supply ops: fallback modules set in the application config.

Before you begin

You should know how a derive string is written. How derives work explains it. For a rule that needs the field name or other fields, a field validator fits better.

Define an extension module

Write a module that uses GuardedStruct.Derive.Extension and declare ops in a derives block. A validator and a sanitizer each take a name and a function of one argument.

elixir
defmodule MyApp.Derives do
  use GuardedStruct.Derive.Extension

  derives do
    validator :even, fn input -> is_integer(input) and rem(input, 2) == 0 end

    validator :lowercase_only, fn input ->
      if is_binary(input) and input == String.downcase(input),
        do: true,
        else: {:error, :slug, :lowercase_only, "must be lower case"}
    end

    sanitizer :slugify, fn input when is_binary(input) ->
      input
      |> String.downcase()
      |> String.replace(~r/[^a-z0-9]+/u, "-")
      |> String.trim("-")
    end
  end
end

What a validator returns

ReturnEffect
trueThe value passes.
falseThe value fails. The error has the op name as its action and the message Invalid format in the <field> field (<op>).
{:error, field, action, message}The value fails with your action and message. The error carries the real field name, whatever atom you put in the second place.
Any other valueUsed as the validated value. It replaces the input only when the op is the first op in the field's validate group.

A sanitizer returns the new value. Sanitizers run before validators.

Register the extension

Name it for one module with the derive_extensions: option of use GuardedStruct.

elixir
defmodule Post do
  use GuardedStruct, derive_extensions: [MyApp.Derives]

  guardedstruct do
    field :slug, String.t(), derives: "sanitize(trim, slugify) validate(lowercase_only)"
    field :seats, integer(), derives: "validate(even)"
  end
end

Post.builder(%{slug: "  Hello, World! ", seats: 4})
# {:ok, %Post{slug: "hello-world", seats: 4}}

Post.builder(%{slug: "x", seats: 3})
# {:error, [%{field: :seats, action: :even, message: "Invalid format in the seats field (even)"}]}

Or register it for every module in config/config.exs:

elixir
config :guarded_struct, derive_extensions: [MyApp.Derives]

The per-module option decides how the global list is used.

OptionExtensions used
Not setThe global list.
[]None.
[A, B]A and B only. The global list is ignored.
[:config, A]The global list, then A.
[A, :config]A, then the global list.
[A, :config, B]A, the global list, then B.

The first extension in the resulting order that defines an op name handles it. :config may appear once. Anything else in the list must be a module.

Know the limits

  • A custom op is called by its bare name. validate(even=3) does not reach your function and fails with the action type.
  • The built-in ops win. If you declare an op named like a built-in one, such as validator :string, the compiler warns that your function will never be called.
  • A custom op can be named inside each=[...], optional=[...] and either=[...], as in validate(each=[even]). The compiler knows the ops of the module's extensions there. When a validate_derive or sanitize_derive module is set, it accepts any inner op name.
  • An op name that no built-in, extension or fallback module defines is not an error at compile time outside each and optional. A validate op fails at run time with the action type, and an unknown sanitize op leaves the value unchanged.
  • GuardedStruct caches the global extension list in :persistent_term and refreshes it when the config value changes. Call GuardedStruct.Derive.Extension.clear_cache/0 in test setup if you change the config at run time.

Use fallback modules instead

Ops that no built-in clause and no extension handles go to fallback modules. A fallback module is a plain module, with no DSL. Set validate_derive and sanitize_derive to one module or a list of modules, in the application config or in the guardedstruct section.

elixir
defmodule MyApp.MoreValidators do
  def validate(:positive, input, field) do
    if is_integer(input) and input > 0,
      do: input,
      else: {:error, field, :positive, "must be positive"}
  end
end

defmodule MyApp.MoreSanitizers do
  def sanitize(input, :shout) when is_binary(input), do: String.upcase(input) <> "!"
  def sanitize(input, _op), do: input
end

Application.put_env(:guarded_struct, :validate_derive, MyApp.MoreValidators)
Application.put_env(:guarded_struct, :sanitize_derive, MyApp.MoreSanitizers)

# field :count, integer(), derives: "validate(positive)"
# field :label, String.t(), derives: "sanitize(shout)"
#
# builder(%{count: -5, label: "hi"})
# {:error, [%{field: :count, action: :positive, message: "must be positive"}]}

A fallback validate/3 receives the op, the value and the field name. It returns the value to pass, or {:error, field, action, message} to fail. A fallback sanitize/2 receives the value first and the op second, and returns the new value. The modules of the section options serve that module and its sub_fields only. They are tried before the application config, which still serves the ops they leave unhandled. Validate.field/4 and Validate.partial/2 use them too.

elixir
defmodule Stock do
  use GuardedStruct

  guardedstruct validate_derive: MyApp.MoreValidators do
    field :count, integer(), derives: "validate(positive)"
  end
end

Stock.builder(%{count: -5})
# {:error, [%{field: :count, action: :positive, message: "must be positive"}]}

Next steps