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: and on: and the rules of domain: are parsed.
  • Each sub_field becomes its own module, compiled in parallel with its siblings.
  • The struct, its @type t(), builder/1,2, keys, enforce_keys, example/0 and 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.

  1. Normalize the input. A struct becomes a map. String keys become the atoms of your fields.
  2. Reject unknown keys, only when authorized_fields: true is set. Otherwise the struct drops them later.
  3. Check required keys. Each missing key listed in enforce_keys is a required_fields error.
  4. Run auto. Functions fill their fields.
  5. Check domain rules, then on paths.
  6. Apply from. Values are copied from other paths.
  7. Build nested values. Each sub_field, struct:, structs: and conditional_field value goes through the same steps in its own module.
  8. Run field validators, the validator: functions and the module's validator/2.
  9. Run the main validator. It is the main_validator option of the section, or main_validator/1 defined in the module when the option is not set. A sub_field uses its own option.
  10. 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.