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.
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
endWhat a validator returns
| Return | Effect |
|---|---|
true | The value passes. |
false | The 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 value | Used 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.
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:
config :guarded_struct, derive_extensions: [MyApp.Derives]The per-module option decides how the global list is used.
| Option | Extensions used |
|---|---|
| Not set | The 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 actiontype. - 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=[...]andeither=[...], as invalidate(each=[even]). The compiler knows the ops of the module's extensions there. When avalidate_deriveorsanitize_derivemodule 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
eachandoptional. Avalidateop fails at run time with the actiontype, and an unknownsanitizeop leaves the value unchanged. - GuardedStruct caches the global extension list in
:persistent_termand refreshes it when the config value changes. CallGuardedStruct.Derive.Extension.clear_cache/0in 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.
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.
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
- Validate ops and Sanitize ops: the built-in names you cannot reuse.
- Configuration: every application config key.
- Validate input without building a struct: run a derive string on one value.