How the builder works
v0.1.4
A guardedstruct block does its work in two stages. When the module compiles, GuardedStruct reads your declaration and generates code. When you call builder/1, that code runs a fixed series of steps over the input. This page explains both stages, the order of the steps, and how the builder treats keys, so you can predict which error you get and why.
What happens at compile time
The macros are built on Spark. Spark collects your field, sub_field, conditional_field, virtual_field and dynamic_field entries, then GuardedStruct's transformers run over them:
- Derive strings are parsed into lists of ops, and operands such as
enum=String[a::b]are evaluated once. - The paths of
from:andon:and the rules ofdomain:are parsed. - Each
sub_fieldbecomes its own module, compiled in parallel with its siblings. - The struct, its
@type t(),builder/1,2,keys,enforce_keys,example/0and the metadata functions are generated.
Three verifiers then check the finished module: every validator: {Module, :function} must exist with two arguments, every auto: function must exist, and two modules must not refer to each other with struct: or structs:. The compiler prints a failed check as a warning, and the module still compiles. A duplicate field name raises an ArgumentError, a regex field mixed with atom fields raises a Spark.Error.DslError, and so does a derive parameter of the wrong type, such as max_len=foo. A derive group that is not sanitize(...) or validate(...), or has no closing ), raises one too.
What the builder does at run time
The builder reads the metadata that compilation baked in. It does not parse any string. A call to builder/1 passes through these steps in order.
- Normalize the input. A struct becomes a map. String keys become the atoms of your fields.
- Reject unknown keys, only when
authorized_fields: trueis set. Otherwise the struct drops them later. - Check required keys. Each missing key listed in
enforce_keysis arequired_fieldserror. - Run
auto. Functions fill their fields. - Check
domainrules, thenonpaths. - Apply
from. Values are copied from other paths. - Build nested values. Each
sub_field,struct:,structs:andconditional_fieldvalue goes through the same steps in its own module. - Run field validators, the
validator:functions and the module'svalidator/2. - Run the main validator. It is the
main_validatoroption of the section, ormain_validator/1defined in the module when the option is not set. Asub_fielduses its own option. - Run the derive strings. Virtual fields first, then every other field, on the built struct. Sanitized values are what the struct keeps.
Steps 2 to 5 stop the build as soon as one of them has errors, and return only that step's errors. Steps 7 to 10 collect their errors together, so a nested error, a validator error and a derive error can appear in one result. If the main validator returns an error, the derive strings do not run.
Because the derive strings run last, validators and the main validator see raw input, and the derive strings see the struct after defaults are applied. A default value is therefore checked like any other value. A field whose value is nil is skipped by the derive strings, except for sanitize(default_when_nil=...), which fills it.
How keys are handled
Input often comes from JSON, where keys are strings. GuardedStruct converts a string key to an atom only when that atom already exists, using String.to_existing_atom/1. The declared field names exist, so they convert. A key whose atom does not exist stays a string, matches no field and is dropped. An attacker cannot grow the atom table by sending new keys.
Two field kinds go further. The value of a dynamic_field keeps its keys exactly as sent, at every depth. The keys of a pattern-keyed module stay strings. See Use virtual, dynamic and pattern-keyed fields.
A struct inside the input, such as Date, Time or URI, stays a struct, so validate(time_struct) and validate(struct) accept it.
Where the pieces live
Every guarded module and every generated sub-module has the same functions, so a nested value is not a special case. __information__/0 and __fields__/0 hold the baked metadata, and GuardedStruct.Info reads them. The API reference lists them.