diff --git a/.formatter.exs b/.formatter.exs
index d2cda26..965b304 100644
--- a/.formatter.exs
+++ b/.formatter.exs
@@ -1,4 +1,50 @@
-# Used by "mix format"
+spark_locals_without_parens = [
+ atomic: 1,
+ authorized_fields: 1,
+ auto: 1,
+ auto_wire: 1,
+ conditional_field: 2,
+ conditional_field: 3,
+ default: 1,
+ derive: 1,
+ derives: 1,
+ domain: 1,
+ dynamic_field: 1,
+ dynamic_field: 2,
+ enforce: 1,
+ error: 1,
+ field: 2,
+ field: 3,
+ from: 1,
+ hint: 1,
+ json: 1,
+ main_validator: 1,
+ module: 1,
+ on: 1,
+ opaque: 1,
+ priority: 1,
+ sanitize_derive: 1,
+ sanitizer: 2,
+ sanitizer: 3,
+ struct: 1,
+ structs: 1,
+ sub_field: 2,
+ sub_field: 3,
+ type: 1,
+ validate_derive: 1,
+ validator: 1,
+ validator: 2,
+ validator: 3,
+ virtual_field: 2,
+ virtual_field: 3
+]
+
[
- inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
+ import_deps: [:spark, :ash],
+ inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
+ plugins: [Spark.Formatter],
+ locals_without_parens: spark_locals_without_parens,
+ export: [
+ locals_without_parens: spark_locals_without_parens
+ ]
]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3080280..503922d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,18 +1,142 @@
+# Changelog for GuardedStruct 0.1.0
+
+> We are delighted to introduce v0.1.0 — a from-scratch rewrite of the macro core on top of [Spark](https://hex.pm/packages/spark). Every existing 0.0.x public API is preserved. Bump the dep, run `mix deps.get`, and existing tests stay green.
+>
+> See [`OPTIONS-0.1.0.md`](./OPTIONS-0.1.0.md) for every new option with examples.
+
+**Tracking PR**: [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+
+### Features:
+
+- Rewrite the 2,910-LOC `defmacro` core on `Spark.Dsl.Extension` with one `:guardedstruct` section, five entities (`field`, `sub_field`, `conditional_field`, `virtual_field`, `dynamic_field`), six transformers, three verifiers [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `Pattern-keyed maps` — `field` whose name is a regex declares a free-form map shape (closes [#11](https://github.com/mishka-group/guarded_struct/issues/11)) [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `virtual_field` — validated through the full pipeline but excluded from `defstruct` (closes [#5](https://github.com/mishka-group/guarded_struct/issues/5)) [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `dynamic_field` — free-form map with passthrough; atom-attack-safe (string keys stay strings) [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add Erlang `Record` support via `validate(record)` and `validate(record=tag)` (closes [#6](https://github.com/mishka-group/guarded_struct/issues/6)) [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `GuardedStruct.Validate` standalone API — `Validate.run/2`, `Validate.field/3,4`, `Validate.partial/2` (closes [#2](https://github.com/mishka-group/guarded_struct/issues/2)) [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add Spark-native custom derive DSL — `use GuardedStruct.Derive.Extension` + `derives do validator/2, sanitizer/2 end` for declarative custom ops [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add per-module `derive_extensions:` opt with `:config` sentinel for in-position merge with global registry [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add compile-time shadow warning when a custom op-name collides with a built-in registered in `Derive.Registry` [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `Splode` error wrapping — `GuardedStruct.Errors.from_tuple/1`, `traverse_errors/2`, `to_class/1`, JSON-serializable shape (opt-in) [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `GuardedStruct.AshResource` extension — same DSL inside `Ash.Resource`; generates `__guarded_change__/1`, `__guarded_information__/0`, `__guarded_fields__/0` under the prefixed namespace [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `GuardedStruct.AshResource.Change` — ready-made `Ash.Resource.Change` module bridging `__guarded_change__/1` into the changeset pipeline [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `auto_wire: true` section option — Spark transformer injects the change into the resource's `changes` section via `Ash.Resource.Builder.add_change/3`; no manual wiring needed [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `batch_change/3` on the Ash change — `Ash.bulk_create/3` and `Ash.bulk_update/3` (with `strategy: :stream`) work end-to-end [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add auto-map cascade for the Ash extension — every nested `sub_field` returns a plain map at every depth (matches Ash's `:map` attribute type) [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `atomic: true` section option — compile-time `VerifyAtomic` verifier rejects (with `Spark.Error.DslError`) any derive op that can't translate to atomic SQL (DNS validators, MFA callbacks, custom Extension ops, `main_validator/1`, cross-field options) [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `GuardedStruct.AtomicClassifier` — one pattern-match clause per atomic-safe op; contributors extend by adding a single clause [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add typo-aware diagnostic in `VerifyAtomic` — distinguishes "built-in but not atomic-safe" from "unknown op (typo or custom Extension)" with a different actionable message [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `json: true` section option — auto-derives `Jason.Encoder` (if `:jason` in deps) with fallback to built-in `JSON.Encoder` on Elixir 1.18+ [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `GuardedStruct.Info` — full introspection API: `describe/1`, `field_kind/2`, `enforce?/1,2`, `virtual?/2`, `dynamic?/2`, `sub_module/2`, `conditional_children/2`, collection helpers, section-option shorthands [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `GuardedStruct.Diff` — `diff/2`, `apply/2`, `equal?/2` for audit-log-friendly struct diffing [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `MyStruct.example/0` — REPL helper returning a struct populated with defaults / type placeholders [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add telemetry events — `[:guarded_struct, :builder, :start | :stop | :exception]` on every top-level `builder/1` call [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `@derives` decorator attribute — alternative to inline `derives:` for keeping fields short [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add editor autocomplete inside `guardedstruct do … end` via Spark's ElixirSense plugin (closes [#1](https://github.com/mishka-group/guarded_struct/issues/1)) [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add igniter installer — `mix igniter.install guarded_struct` [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+
+### Refactors:
+
+- Move every static-string parse to compile time — derive op-strings, `from:`/`on:` paths, `domain:` patterns are now parsed once during compilation; the runtime reads pre-built op-maps from `__fields__/0` [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Pre-evaluate `enum=Map[…]` / `enum=Tuple[…]` / `equal=Map::…` operands at compile time — zero `Code.eval_string` on the runtime hot path [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Replace plain-macro `validator/2` and `sanitizer/2` with proper Spark entities under `derives do ... end` block — Spark.Formatter handles paren-stripping consistently with the rest of the DSL [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Replace `IO.warn/2` with `Spark.Warning.warn/3` — shadow warnings point at the user's source line via the entity's `__spark_metadata__.anno` [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Rename `__guarded_validate__/1` → `__guarded_change__/1` on the Ash extension — name reflects that the function transforms (sanitize, auto-fill) as well as validates [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Rename `derive:` option to `derives:` (plural) — aligns with the `@derives` decorator; legacy `derive:` still works but emits a compile-time deprecation warning via `Spark.Warning.warn_deprecated/4` [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Rename `jason: true` section option to `json: true` — option now derives whichever JSON encoder is available (Jason or built-in) [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Extract test fixtures (Ash resources + custom-derive modules) to top-level modules in `test/support/` so Spark.Formatter applies paren-removal and section-ordering rules uniformly [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+
+### Bugs:
+
+- Fix nested `conditional_field` — works to arbitrary depth via `recursive_as: :conditional_fields` (closes [#7](https://github.com/mishka-group/guarded_struct/issues/7), [#8](https://github.com/mishka-group/guarded_struct/issues/8), [#25](https://github.com/mishka-group/guarded_struct/issues/25)) [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Restore i18n via `GuardedStruct.Messages.translated_message/1,2` for orchestration-layer errors (`authorized_fields`, `required_fields`, `:on` / `:domain` core keys, list-builder errors) — all 14 message callbacks reachable again [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Fix `__information__/0` to populate `conditional_keys` with actual conditional-field names (was always `[]`) [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Fix `MyStruct.Error.message/1` to match master's format and use `translated_message(:message_exception)` for i18n [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Unblock the legacy `Parser` `raise` sites that prevented nested `conditional_field` from compiling [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Surface malformed `derives:` strings as `Spark.Error.DslError` with file:line — previously swallowed by a `rescue _ -> nil` and silently produced no validation [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Fix re-entrancy in the auto-map cascade — process-dict flag is saved+restored across nested `validate/3` calls so a validator callback can recursively validate without clobbering outer state [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Fix `Logger.configure(level: :warning)` global side-effect in `test_helper.exs` — replaced with `@moduletag capture_log: true` on Ash test modules [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+
+### Tests:
+
+- Add 743+ tests (up from 146 in 0.0.4), including 6 property-based tests via `stream_data` [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add real Ash 3.x integration suite — ETS data layer, end-to-end `Ash.create/1`, `Ash.update/1`, `Ash.bulk_create/3`, `Ash.bulk_update/3` [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `test/atomic_verifier_test.exs` — 30 tests covering the atomic-safety classifier and verifier in isolation [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `test/ash_integration_test.exs` atomic describe blocks — 29 tests covering happy path + compile-time rejection for every blocker category [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `test/info_test.exs` — 38 tests covering every introspection helper including `describe/1` consolidated dump [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `test/derive_extension_shadow_warning_test.exs` — 9 tests for compile-time shadow detection [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `test/derive_extensions_per_module_test.exs` — 19 tests for per-module opt resolution including the `:config` sentinel [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `test/jason_encoder_test.exs` — Jason + built-in JSON encoder coverage with nested sub_field [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `test/telemetry_test.exs` — start/stop/exception event coverage, including nested-build inheritance [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+
+### Docs:
+
+- Add full LiveBook walkthrough at [`guidance/guarded-struct.livemd`](./guidance/guarded-struct.livemd) with runnable end-to-end examples [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add [`OPTIONS-0.1.0.md`](./OPTIONS-0.1.0.md) — every new option in v0.1.0 with worked examples [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add auto-generated DSL cheat sheets at `documentation/dsls/` via `mix spark.cheat_sheets` [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `mix lint` and `mix cheat` aliases — wrap `spark.formatter` + `format` and `spark.cheat_sheets` [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add "Atom-attack safety" section to the `GuardedStruct` module @moduledoc covering the dynamic_field / pattern-keyed map threat model [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+
+### Internals dropped:
+
+- Remove `builder/4` `@doc false` form (with `(actions, key, type, error)` arity) — replaced by an internal runtime helper [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Remove `register_struct/4`, `__field__/6`, `__type__/2`, `delete_temporary_revaluation/1`, `create_builder/1`, `create_error_module/0` [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Remove the 12 `gs_*` accumulator module attributes (`gs_fields`, `gs_types`, `gs_enforce_keys`, etc.) — replaced by Spark DSL state [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Remove `parser/3` (the conditional variant of `Parser.parser`), `elements_unification/2`, `find_node_tags/1`, `add_parent_tags/3`, `conds_list/2`, `find_conds_children_recursive/2` [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Remove `Derive.pre_derives_check/3`, `get_derives_from_success_conditional_data/1`, `error_handler/2`, `halt_errors/1`, the alternate-shape `derive/1` clauses [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Remove `Messages.unsupported_conditional_field/0` and `Messages.parser_field_value/0` callbacks (dead code after the nested-conditional fix) [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+
+### Dependencies:
+
+- Add `{:spark, "~> 2.7"}` (runtime — DSL framework) [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `{:splode, "~> 0.3"}` (runtime — error class hierarchy for opt-in wrapper) [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `{:telemetry, "~> 1.0"}` (runtime — builder events) [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `{:sourceror, "~> 1.7", only: [:dev, :test]}` (required by Spark.Formatter) [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `{:igniter, "~> 0.8", only: [:dev, :test]}` (installer mix task) [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- Add `{:ash, "~> 3.0", only: [:dev, :test]}` (real Ash integration suite — not a runtime dep) [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+- All optional deps unchanged (`html_sanitize_ex`, `email_checker`, `ex_url`, `ex_phone_number`, `sweet_xml`) [#13](https://github.com/mishka-group/guarded_struct/pull/13)
+
+---
+
# Changelog for GuardedStruct 0.0.4
+### Bugs:
+
- Fix deprecated code from Elixir 1.18
+
+### Features:
+
- Support overridable messages for the `GuardedStruct` module with support for multiple languages
+---
+
# Changelog for GuardedStruct 0.0.3
+### Bugs:
+
- Fix deprecated code from Elixir 1.18.0-rc.0
+---
+
# Changelog for GuardedStruct 0.0.2
-- Fix: Support charlists sigil warning and keep backward compatibility for charlist regex
+### Bugs:
+
+- Support charlists sigil warning and keep backward compatibility for charlist regex
+
+---
# Changelog for GuardedStruct 0.0.1
+> We are delighted to introduce our first standalone release of GuardedStruct — extracted from the Mishka developer tools library.
+>
+> **For more information please see**: https://mishka.tools
+
+### Features:
+
- Detach from the Mishka developer tools library
+
+### Refactors:
+
- Remove optional libraries (must be enabled by the user)
- Improvements in some tests
diff --git a/OPTIONS-0.1.0.md b/OPTIONS-0.1.0.md
new file mode 100644
index 0000000..1b53710
--- /dev/null
+++ b/OPTIONS-0.1.0.md
@@ -0,0 +1,545 @@
+# `guarded_struct` v0.1.0 — what's new
+
+PR [#13](https://github.com/mishka-group/guarded_struct/pull/13) — rewrite on top of [Spark](https://hex.pm/packages/spark). Closes #1, #2, #4, #5, #6, #11, #12. Public API is fully backward-compatible.
+
+Each section: **what it is** · **one real-world example**. For deeper coverage see the corresponding fixture under `test/support/fixtures/`.
+
+---
+
+## 1 · Editor autocomplete in your IDE — _closes #1_
+
+Type `field` / `sub_field` / `derives` inside a `guardedstruct` block in VSCode (with ElixirLS) or Lexical — completions appear automatically. Free via `Spark.ElixirSense.Plugin`. No setup.
+
+---
+
+## 2 · Single-field validation — _closes #2_
+
+Validate one field of a schema without going through the whole `builder/1`. Perfect for live-as-you-type form validation.
+
+```elixir
+defmodule User do
+ use GuardedStruct
+ guardedstruct do
+ field :email, String.t(), enforce: true, derives: "validate(email_r)"
+ field :age, integer(), derives: "validate(integer)"
+ end
+end
+
+# Validate just :email — useful in a LiveView `phx-change` handler:
+GuardedStruct.Validate.field(User, :email, "bad")
+# => {:error, [%{field: :email, action: :email_r, message: "..."}]}
+
+# Or validate a raw value against an op-string (no module needed):
+GuardedStruct.Validate.run("validate(email_r)", "alice@x.io")
+# => {:ok, "alice@x.io"}
+
+# Or validate a subset (e.g. for PATCH endpoints):
+GuardedStruct.Validate.partial(User, %{email: "alice@x.io"})
+# => {:ok, %{email: "alice@x.io"}} # :age omission ignored
+```
+
+> Fixture: `test/support/fixtures/showcase.ex` (`EnterpriseAccount`)
+
+---
+
+## 3 · `@derives` decorator — cleaner DSL — _part of #4_
+
+Move long `derives:` strings off the `field` line. One-shot, consumed by the next entity.
+
+```elixir
+guardedstruct do
+ @derives "sanitize(trim, downcase) validate(string, email_r, max_len=320)"
+ field :email, String.t(), enforce: true
+
+ @derives "validate(integer, min_len=18, max_len=120)"
+ field :age, integer()
+end
+```
+
+Also works on `sub_field`, `conditional_field`, `virtual_field`, `dynamic_field`. `@derive_rules` is a longer alias for the same thing.
+
+> Fixtures: `decorated.ex`, `decorated_all_entities.ex`, `mixed_decorator_inline.ex`
+
+---
+
+## 4 · `derives:` is the canonical name (legacy `derive:` deprecated) — _part of #4_
+
+The plural form `derives:` is now the canonical option name. `derive:` still works but emits a compile-time deprecation warning. Plural aligns with the `@derives` decorator above.
+
+```elixir
+field :email, String.t(), derives: "validate(email_r)" # ✓ canonical
+field :email, String.t(), derive: "validate(email_r)" # ⚠ deprecated, warns
+```
+
+---
+
+## 5 · `virtual_field` — input-only fields — _closes #5_
+
+For "password confirmation"-style fields: validated but **not stored** on the resulting struct. Useful with `main_validator/1` for cross-field checks.
+
+```elixir
+defmodule Signup do
+ use GuardedStruct
+ guardedstruct do
+ field :email, String.t(), enforce: true, derives: "validate(email_r)"
+ field :password, String.t(), enforce: true, derives: "validate(string, min_len=8)"
+ virtual_field :password_confirmation, String.t(), enforce: true
+ end
+
+ def main_validator(%{password: p, password_confirmation: p} = a), do: {:ok, a}
+ def main_validator(_), do: {:error, [%{field: :password_confirmation, action: :match, message: "doesn't match"}]}
+end
+
+{:ok, %Signup{email: ..., password: ...}} =
+ Signup.builder(%{email: "a@b.io", password: "hunter22", password_confirmation: "hunter22"})
+# Note: %Signup{} doesn't have :password_confirmation — virtual fields are dropped.
+```
+
+> Fixture: `test/support/fixtures/forms.ex`
+
+---
+
+## 6 · Erlang Record support — _closes #6_
+
+For Elixir code that wraps Erlang/OTP returns (Mnesia rows, `:gen_event` notifications, RPC results). Validates that a value is a tagged tuple with the right tag.
+
+```elixir
+require Record
+Record.defrecord(:user, :user, name: nil, age: nil)
+
+defmodule AuditEvent do
+ use GuardedStruct
+ guardedstruct do
+ field :user_record, :tuple, enforce: true, derives: "validate(record=user)"
+ end
+end
+
+AuditEvent.builder(%{user_record: user(name: "Alice", age: 30)})
+# => {:ok, %AuditEvent{user_record: {:user, "Alice", 30}}}
+
+AuditEvent.builder(%{user_record: {:wrong_tag, ...}})
+# => {:error, [%{action: :record, ...}]} # wrong tag rejected
+```
+
+> Fixture: `test/support/fixtures/records.ex`
+
+---
+
+## 7 · `dynamic_field` — open-shape map fields — _part of #11_
+
+Shorthand for "this field is a free-form map" — user can put any keys they want. Perfect for `:metadata`, `:settings`, webhook payloads, third-party integration data.
+
+> **Security note**: `dynamic_field` values are **identity-preserved** — whatever map you submit is exactly what you get back. No key conversion at any depth. This is intentional to prevent atom-table-exhaustion DoS from attacker-controlled keys. Read these values with **string keys** (e.g. `doc.metadata["theme"]`). See the "Atom-attack safety" section of the `GuardedStruct` module @moduledoc for full details.
+
+```elixir
+defmodule UserProfile do
+ use GuardedStruct
+ guardedstruct do
+ field :id, String.t(), enforce: true
+ field :email, String.t(), enforce: true, derives: "validate(email_r)"
+
+ # Open-shape — keys unknown at compile time:
+ dynamic_field :preferences
+ dynamic_field :integration_data, derives: "validate(map, not_empty)"
+ end
+end
+
+UserProfile.builder(%{
+ id: "u1", email: "a@b.io",
+ preferences: %{theme: "dark", custom_xyz_42: "anything"},
+ integration_data: %{stripe_id: "cus_...", salesforce_id: "00Q..."}
+})
+# Each map's KEYS aren't pre-declared. dynamic_field accepts whatever shape.
+```
+
+Supports the same cross-field opts as `field`: `enforce`, `auto`, `from`, `on`, `domain`, `validator`, `derives`.
+
+> Fixture: `test/support/fixtures/dynamic.ex` + `test/fixtures/dynamic_field_full_opts_test.exs`
+
+---
+
+## 8 · Pattern-keyed maps — regex `field` names — _closes #11_
+
+Different from `dynamic_field`: the WHOLE MODULE's `builder/1` returns a typed map (no defstruct). Keys must match the regex; values validated against a referenced struct.
+
+```elixir
+defmodule Shard do
+ use GuardedStruct
+ guardedstruct do
+ field :node, String.t(), enforce: true, derives: "validate(ipv4)"
+ end
+end
+
+defmodule ShardsMap do
+ use GuardedStruct
+ guardedstruct do
+ field ~r/^shard_\d+$/, struct(), struct: Shard
+ end
+end
+
+ShardsMap.builder(%{
+ "shard_1" => %{node: "10.0.0.1"},
+ "shard_2" => %{node: "10.0.0.2"}
+})
+# => {:ok, %{"shard_1" => %Shard{...}, "shard_2" => %Shard{...}}}
+# ^ a plain MAP, not a struct
+
+ShardsMap.builder(%{"banana" => ...}) # key doesn't match → error
+```
+
+> Fixture: `test/support/fixtures/dynamic.ex` (`ShardsMap`, `ClusterPlan`)
+
+---
+
+## 9 · Nested-list validation fix — _closes #12_
+
+Sub_fields with `structs: true` (list-of-shape) inside another `structs: true` now validate each item correctly at every depth. Pre-0.1.0 silently mis-validated nested lists.
+
+```elixir
+defmodule NestedListStruct do
+ use GuardedStruct
+ guardedstruct do
+ sub_field :list, list(struct()), structs: true, enforce: true do
+ field :id, String.t(), enforce: true
+ sub_field :sublist, list(struct()), structs: true, enforce: true do
+ field :id, String.t(), enforce: true
+ end
+ end
+ end
+end
+
+# Now: each nested-list item gets its own validation pass — :id required at every level.
+```
+
+---
+
+## 10 · Nested `conditional_field` — _part of #4_
+
+Conditional inside conditional inside conditional. Was unsupported in 0.0.x.
+
+```elixir
+defmodule Block do
+ use GuardedStruct
+ guardedstruct do
+ conditional_field :content, any() do
+ field :content, String.t(), hint: "paragraph", validator: {V, :is_string}
+ sub_field :content, struct(), hint: "image", validator: {V, :is_map} do
+ field :url, String.t(), enforce: true, derives: "validate(url)"
+ end
+ conditional_field :content, any(), structs: true, hint: "gallery", validator: {V, :is_list} do
+ field :content, String.t()
+ field :content, struct(), struct: Image
+ end
+ end
+ end
+end
+```
+
+> Fixture: `test/support/fixtures/conditionals.ex` (`Block`, 7-level `Document`)
+
+---
+
+## 11 · `json: true` — JSON encoding for API responses
+
+Auto-derive a JSON encoder on the struct (and all sub_field submodules). Precedence: `Jason.Encoder` if `:jason` is in the user's deps, otherwise the built-in `JSON.Encoder` on Elixir 1.18+. No-op if neither is available. For Phoenix/Plug response payloads.
+
+```elixir
+defmodule Order do
+ use GuardedStruct
+ guardedstruct json: true do
+ field :id, String.t(), enforce: true
+ field :total, integer(), enforce: true
+ end
+end
+
+{:ok, o} = Order.builder(%{id: "abc", total: 99})
+Jason.encode!(o) # => ~s({"id":"abc","total":99})
+# or, on Elixir 1.18+ without Jason in deps:
+JSON.encode!(o) # => ~s({"id":"abc","total":99})
+```
+
+---
+
+## 12 · Custom validators / sanitizers — Spark-native DSL
+
+Define your own `validate(slug)`, `sanitize(slugify)` ops as a small extension module.
+
+```elixir
+defmodule MyApp.Derives do
+ use GuardedStruct.Derive.Extension
+ validator :slug, fn s -> is_binary(s) and Regex.match?(~r/^[a-z0-9-]+$/, s) end
+ sanitizer :slugify, fn s -> String.downcase(s) |> String.replace(~r/[^a-z0-9]+/, "-") end
+end
+
+# Activate globally:
+config :guarded_struct, derive_extensions: [MyApp.Derives]
+
+# Or per-module:
+defmodule Post do
+ use GuardedStruct, derive_extensions: [MyApp.Derives]
+ guardedstruct do
+ field :slug, String.t(), derives: "sanitize(slugify) validate(slug)"
+ end
+end
+```
+
+> Fixture: `test/support/fixtures/custom_derives.ex`
+
+---
+
+## 13 · Splode error wrapping (opt-in)
+
+Convert `{:error, errs}` lists into typed Splode exceptions with `traverse_errors`, `set_path`, JSON-encodable shape.
+
+```elixir
+case User.builder(input) do
+ {:error, errs} -> {:error, GuardedStruct.Errors.from_tuple(errs)}
+ ok -> ok
+end
+```
+
+---
+
+## 14 · `Diff` / `Info` / `example/0` helpers
+
+```elixir
+GuardedStruct.Diff.diff(user_v1, user_v2)
+# => %{name: {:changed, "Alice", "Alicia"}} # audit-log-friendly diff
+
+GuardedStruct.Info.field?(User, :email) # => true (compile-time introspection)
+User.example() # => %User{name: "", age: 0, ...} — REPL helper
+```
+
+---
+
+## 15 · Ash resource extension
+
+Use the GuardedStruct DSL inside `Ash.Resource` to add field-level sanitize/validate rules without re-defining `defstruct`. Wire the pipeline into the changeset in one of two ways.
+
+### Manual wiring (Option A — default)
+
+```elixir
+defmodule MyApp.User do
+ use Ash.Resource, domain: MyApp.Domain, extensions: [GuardedStruct.AshResource]
+
+ attributes do
+ uuid_primary_key :id
+ attribute :email, :string, allow_nil?: false, public?: true
+ end
+
+ guardedstruct do
+ field :email, :string, derives: "sanitize(trim, downcase) validate(email_r)"
+ end
+
+ # One line — applies to every :create and :update action.
+ changes do
+ change GuardedStruct.AshResource.Change
+ end
+end
+```
+
+Now `Ash.Changeset.for_create(MyApp.User, :create, %{email: " Alice@X.io "})` sanitizes and validates **before** Ash hits the data layer.
+
+### Auto-wiring (Option B — opt-in)
+
+Set `auto_wire true` inside the section and the change is injected for you:
+
+```elixir
+defmodule MyApp.User do
+ use Ash.Resource, domain: MyApp.Domain, extensions: [GuardedStruct.AshResource]
+
+ attributes do
+ uuid_primary_key :id
+ attribute :email, :string, allow_nil?: false, public?: true
+ end
+
+ guardedstruct do
+ auto_wire true # ← Spark inline setter; no `changes do ... end` needed
+
+ field :email, :string, derives: "sanitize(trim, downcase) validate(email_r)"
+ end
+end
+```
+
+Under the hood this calls `Ash.Resource.Builder.add_change/3` from a Spark transformer, equivalent to writing the `changes do change ... end` block by hand. `auto_wire` defaults to **false** — no magic unless you opt in.
+
+### Direct API
+
+Either wiring mode also exposes a direct API for cases where you want to validate outside an Ash action (e.g. in tests, scripts, or a Phoenix LiveView form):
+
+```elixir
+MyApp.User.__guarded_change__(%{email: " ALICE@X.io "})
+# => {:ok, %{email: "alice@x.io"}}
+```
+
+The function is called `__guarded_change__` (not `__guarded_validate__`) because it can both validate AND transform values — sanitize ops trim/downcase/slugify, derives cast types.
+
+### Update actions — `require_atomic? false`
+
+`GuardedStruct.AshResource.Change` runs an imperative Elixir pipeline (sanitize → validate → derive → main_validator). It cannot be expressed as atomic SQL, so on UPDATE actions you must set `require_atomic? false`:
+
+```elixir
+actions do
+ defaults [:read, :destroy]
+ create :create, accept: [:email, :nickname]
+
+ update :update do
+ accept [:email, :nickname]
+ require_atomic? false # ← required for guardedstruct on updates
+ end
+end
+```
+
+Internally our `Change.atomic/3` callback returns `{:not_atomic, reason}`, but Ash's update planner still requires the action-level flag when `require_atomic?` is the default-`true` setting.
+
+### Bulk operations
+
+The change implements `batch_change/3`, so `Ash.bulk_create/3` and `Ash.bulk_update/3` work end-to-end:
+
+```elixir
+# Bulk create
+result = Ash.bulk_create(
+ [%{email: " Alice@X.io "}, %{email: " Bob@Y.com "}],
+ MyApp.User, :create,
+ return_records?: true, return_errors?: true
+)
+# result.records is a list of %MyApp.User{email: "alice@x.io", ...} structs
+
+# Bulk update — use stream strategy because the pipeline is imperative
+result = Ash.bulk_update(MyApp.User, :update, %{email: " New@X.com "},
+ return_records?: true,
+ strategy: :stream
+)
+```
+
+The pipeline still runs per row (no SQL vectorization is possible for arbitrary Elixir sanitize/validate code), but Ash's batch dispatch is fully supported.
+
+### Why atomic mode is `not_atomic`
+
+Atomic mode would translate the change to a single SQL `UPDATE ... SET email = lower(trim(?)) WHERE ...` statement. Our pipeline runs arbitrary Elixir — `sanitize(trim, downcase, slugify, strip_tags)`, `auto:` MFAs, `main_validator/1` — that can't be safely translated to SQL/`Ash.Expr` in the general case.
+
+Pure validate-only derives (no transformation) could be made atomic. That's the planned `GuardedStruct.AshResource.Validation` companion module — separate from this `Change`, designed for the atomic-friendly path.
+
+### Auto-map cascade — Ash-friendly nested payloads
+
+In the Ash extension, **every** nested `sub_field` returns a plain map, not a struct — at all depths. This is automatic; no flag to set.
+
+```elixir
+defmodule MyApp.User do
+ use Ash.Resource, extensions: [GuardedStruct.AshResource]
+
+ guardedstruct do
+ field :email, :string
+ sub_field :profile, :map do
+ field :name, :string
+ sub_field :address, :map do
+ field :city, :string
+ sub_field :geo, :map do
+ field :lat, :float
+ field :lng, :float
+ end
+ end
+ end
+ end
+end
+
+MyApp.User.__guarded_change__(%{
+ email: "a@b.com",
+ profile: %{name: "Alice", address: %{city: "Berlin", geo: %{lat: 52.5, lng: 13.4}}}
+})
+# => {:ok, %{
+# email: "a@b.com",
+# profile: %{ # plain map, NOT %MyApp.User.Profile{}
+# name: "Alice",
+# address: %{ # plain map, NOT %MyApp.User.Profile.Address{}
+# city: "Berlin",
+# geo: %{lat: 52.5, lng: 13.4} # plain map, all the way down
+# }
+# }
+# }}
+```
+
+Why this matters: Ash's `:map` attribute type expects plain maps. With the cascade, GuardedStruct's validated output drops straight into an Ash changeset's `:map` columns without any post-processing.
+
+Standalone `use GuardedStruct` is unaffected — `builder/1` still returns structs at every level.
+
+**Implementation**: the cascade is implemented via a process-dictionary flag set inside the top-level `__guarded_change__/1` entry. It's process-local (concurrency-safe — sibling processes don't see it), re-entrancy-safe (saved+restored across nested calls), and exception-safe (cleared via `try/after`). Zero overhead for standalone callers.
+
+---
+
+## 16 · Telemetry events — production observability
+
+Every top-level `builder/1` call emits 3 events. Attach a handler to log/measure/trace.
+
+```elixir
+# In your app startup (e.g. application.ex):
+:telemetry.attach("log-builds",
+ [:guarded_struct, :builder, :stop],
+ fn _e, %{duration: d}, %{module: m, result: r}, _ ->
+ Logger.info("#{inspect(m)} #{r} in #{System.convert_time_unit(d, :native, :microsecond)}µs")
+ end, nil)
+```
+
+Events: `[:guarded_struct, :builder, :start | :stop | :exception]`. APM libraries (AppSignal, Datadog, Honeycomb) auto-consume.
+
+---
+
+## 17 · `mix igniter.install guarded_struct`
+
+One-command project setup:
+
+```sh
+mix igniter.install guarded_struct
+# 1. Adds {:guarded_struct, "~> 0.1.0"} to mix.exs
+# 2. Registers `lint` alias (mix spark.formatter + mix format)
+# 3. Seeds `config :guarded_struct, derive_extensions: []`
+```
+
+---
+
+## 18 · `mix lint` alias
+
+Run `mix lint` after editing a guardedstruct module — it updates `.formatter.exs`'s `spark_locals_without_parens` (so the DSL keywords stay paren-free) and then runs `mix format`.
+
+---
+
+## App env keys
+
+| Key | What it does |
+|---|---|
+| `derive_extensions: [Mod, ...]` | Globally register custom-op modules (see §12) |
+| `message_backend: Mod` | i18n backend module (Gettext, Cldr, or custom) |
+
+```elixir
+config :guarded_struct,
+ derive_extensions: [MyApp.Derives],
+ message_backend: MyApp.GuardedStructMessages
+```
+
+---
+
+## Dependencies added
+
+| Dep | Scope | Why |
+|---|---|---|
+| `:spark` ~> 2.7 | runtime | DSL framework |
+| `:splode` ~> 0.3 | runtime | Error class hierarchy (§13) |
+| `:telemetry` ~> 1.0 | runtime | Builder events (§16) |
+| `:html_sanitize_ex` ~> 1.5 | runtime | for `sanitize(strip_tags, basic_html, html5)` ops |
+| `:igniter` ~> 0.8 | dev/test | Installer mix task (§17) |
+| `:sourceror` ~> 1.7 | dev/test | For `mix spark.formatter` |
+| `:jason` ~> 1.4 | dev/test | Test coverage for `json: true` (§11) |
+| `:stream_data` ~> 1.1 | dev/test | Property-based tests |
+
+Optional deps unchanged: `email_checker`, `ex_url`, `ex_phone_number`, `sweet_xml`.
+
+---
+
+## Bug fixes worth flagging
+
+- Nested-list validation (§9, closes #12)
+- `__information__/0.conditional_keys` now populated (was `[]` in 0.0.x)
+- All 14 `Messages` callbacks reachable again (some were dead in 0.0.x)
+- Parser no longer crashes on invalid UTF-8 — caught by property-based tests
+- `virtual_field`'s `derives:` now actually fires at runtime (two-pass derive in `Runtime`)
+- Pre-evaluated `enum=Map[…]` / `equal=Map::…` operands at compile time — zero `Code.eval_string/1` in the runtime hot path
diff --git a/README.md b/README.md
index c53ee1e..1e70d04 100644
--- a/README.md
+++ b/README.md
@@ -1,177 +1,598 @@
-# GuardedStruct
+
-
-
-
+# 🛡️ GuardedStruct
-## Low Maintenance Warning:
+**Build Elixir structs with validation, sanitization, nested sub-structs, conditional fields, pattern-keyed maps, and a first-class Ash extension — declared once, parsed at compile time, validated on every build.** ✨
-> **This library is in low maintenance mode, which means the author is currently only responding to pull requests.**
+[](https://hex.pm/packages/guarded_struct)
+[](https://hex.pm/packages/guarded_struct)
+[](https://github.com/mishka-group/guarded_struct/blob/master/LICENSE)
+[](https://github.com/sponsors/mishka-group)
+[](https://www.buymeacoffee.com/mishkagroup)
-The creation of this macro will allow you to build `Structs` that provide you with a number of important options, including the following:
+
-1. Validation
-2. Sanitizing
-3. Constructor
-4. It provides the capacity to operate in a nested style simultaneously.
+---
+
+> [!NOTE]
+> **Status — `0.1.0-beta`.** v0.1.0 rewrites the macro core on [Spark](https://hex.pm/packages/spark). Every existing 0.0.x API keeps working unchanged. Track every change in [`CHANGELOG.md`](./CHANGELOG.md).
+
+---
+
+## 📖 Table of contents
+
+- [Why GuardedStruct?](#-why-guardedstruct)
+- [Highlights](#-highlights)
+- [Installation](#-installation)
+- [Quick start](#-quick-start)
+ - [A struct](#-a-struct)
+ - [Nested + conditional](#-nested--conditional)
+ - [Custom validators / sanitizers](#-custom-validators--sanitizers)
+ - [Ash integration](#-ash-integration)
+- [Atomic mode (Ash)](#-atomic-mode-ash)
+- [Introspection](#-introspection)
+- [Architecture](#-architecture)
+- [Compatibility](#-compatibility)
+- [Documentation](#-documentation)
+- [Status & roadmap](#-status--roadmap)
+- [Contributing](#-contributing)
+- [Funding & sponsorship](#-funding--sponsorship)
+- [License](#-license)
-##### Blog post:
+---
-- [Consolidating Input and Output Validation and Sanitization in Elixir with GuardedStruct library](https://mishka.tools/blog/guardedstruct-advanced-elixir-struct-data-validation-and-sanitization)
+## 💭 Why GuardedStruct?
-## Example:
+Defining a "good" struct in Elixir means doing the same boilerplate every time: `defstruct`, `@enforce_keys`, a `@type t()`, a constructor, per-field validation, sanitization, default values, nested structs, error messages, i18n. Each surface ends up subtly different across projects.
+
+**GuardedStruct collapses that into a DSL.** One `guardedstruct do ... end` block declares fields, validation rules, sanitization, nested sub-structs, conditional dispatch, custom callbacks. The library generates `defstruct`, `@type t()`, a `builder/1,2` constructor, introspection functions, and a configurable error pipeline — all parsed once at compile time so the runtime hot path is small.
```elixir
-defmodule ConditionalFieldComplexTest do
+defmodule User do
use GuardedStruct
- alias ConditionalFieldValidatorTestValidators, as: VAL
guardedstruct do
- field(:provider, String.t())
+ field :name, :string, enforce: true,
+ derives: "sanitize(trim, capitalize) validate(string, max_len=80)"
- sub_field(:profile, struct()) do
- field(:name, String.t(), enforce: true)
- field(:family, String.t(), enforce: true)
+ field :email, :string, enforce: true,
+ derives: "sanitize(trim, downcase) validate(email_r)"
- conditional_field(:address, any()) do
- field(:address, String.t(), hint: "address1", validator: {VAL, :is_string_data})
+ field :age, :integer,
+ derives: "validate(integer, min_len=0, max_len=120)"
- sub_field(:address, struct(), hint: "address2", validator: {VAL, :is_map_data}) do
- field(:location, String.t(), enforce: true)
- field(:text_location, String.t(), enforce: true)
- end
+ field :role, :string, default: "user",
+ derives: "validate(enum=String[admin::user::guest])"
+ end
+end
- sub_field(:address, struct(), hint: "address3", validator: {VAL, :is_map_data}) do
- field(:location, String.t(), enforce: true, derive: "validate(string, location)")
- field(:text_location, String.t(), enforce: true)
- field(:email, String.t(), enforce: true)
- end
- end
+User.builder(%{
+ name: " alice ",
+ email: "ALICE@EXAMPLE.COM",
+ age: 30
+})
+# => {:ok, %User{name: "Alice", email: "alice@example.com", age: 30, role: "user"}}
+
+User.builder(%{name: "x", email: "bad", age: -5})
+# => {:error, [
+# %{field: :email, action: :email_r, message: "..."},
+# %{field: :age, action: :min_len, message: "..."}
+# ]}
+```
+
+That's the full surface. No `defstruct`, no `@enforce_keys`, no validator boilerplate, no constructor. 🚀
+
+---
+
+## ✨ Highlights
+
+### 🏗️ Core DSL
+
+- 🧱 **`field`** — typed, optionally enforced, with default, sanitize+validate derive, auto-fill MFA, per-field validator, cross-field `on:`/`from:`/`domain:`.
+- 🌲 **`sub_field`** — recursive nested struct, any depth, generates real submodules with their own `builder/1`.
+- 🎭 **`conditional_field`** — sum-type-like dispatch: same field name resolves to different shapes based on the input (string OR struct OR list). Nestable to arbitrary depth.
+- 👻 **`virtual_field`** — validated through the full pipeline but excluded from `defstruct` (classic `password_confirm` use case).
+- 🌀 **`dynamic_field`** — free-form map with passthrough; atom-attack-safe (string keys stay strings, no `String.to_atom` of attacker input).
+- 🔣 **Pattern-keyed maps** — `field` whose name is a regex declares a map shape with no fixed keys; uniform per-value validation.
+- 🧬 **Erlang Records** — `validate(record=tag)` accepts tagged tuples.
+
+### 🧪 Derive mini-language
+
+```elixir
+field :slug, :string,
+ derives: "sanitize(trim, downcase) validate(string, not_empty, max_len=80) sanitize(slugify)"
+
+# OR
+
+@derives "sanitize(trim, downcase) validate(string, not_empty, max_len=80) sanitize(slugify)"
+field :slug, :string
+```
+
+- 🧼 **Sanitize ops** — `trim`, `upcase`, `downcase`, `capitalize`, `strip_tags`, `basic_html`, `html5`, `tag`, plus user-defined custom ops.
+- ✅ **Validate ops** — `string`, `integer`, `float`, `boolean`, `atom`, `list`, `map`, `tuple`, `record`, `not_empty`, `not_empty_string`, `max_len`, `min_len`, `max`, `min`, `equal`, `uuid`, `email`, `email_r`, `url`, `url_r`, `ipv4`, `ipv6`, `regex`, `enum`, `datetime`, `date`, `time`, `geo`, `location`, plus user-defined.
+- 🎯 **All ops parsed at compile time** — runtime reads pre-built op-maps from `__fields__/0`; zero `Code.eval_string` on the hot path.
+- 🧰 **`@derives` decorator** — alternative to inline `derives:` for keeping fields short.
+
+### 🪝 Custom validators / sanitizers (`Derive.Extension`)
+
+```elixir
+defmodule MyApp.Derives do
+ use GuardedStruct.Derive.Extension
+
+ derives do
+ validator :slug, fn input ->
+ is_binary(input) and Regex.match?(~r/^[a-z0-9-]+$/, input)
end
- conditional_field(:product, any()) do
- field(:product, String.t(), hint: "product1", validator: {VAL, :is_string_data})
-
- sub_field(:product, struct(), hint: "product2", validator: {VAL, :is_map_data}) do
- field(:name, String.t(), enforce: true)
- field(:price, integer(), enforce: true)
-
- sub_field(:information, struct()) do
- field(:creator, String.t(), enforce: true)
- field(:company, String.t(), enforce: true)
-
- conditional_field(:inventory, integer() | struct(), enforce: true) do
- field(:inventory, integer(),
- hint: "inventory1",
- validator: {VAL, :is_int_data},
- derive: "validate(integer, max_len=33)"
- )
-
- sub_field(:inventory, struct(), hint: "inventory2", validator: {VAL, :is_map_data}) do
- field(:count, integer(), enforce: true)
- field(:expiration, integer(), enforce: true)
- end
- end
- end
- end
+ sanitizer :slugify, fn input ->
+ input |> String.downcase() |> String.replace(~r/[^a-z0-9]+/u, "-")
end
end
end
```
+Register globally (`config :guarded_struct, derive_extensions: [MyApp.Derives]`) or per-module (`use GuardedStruct, derive_extensions: [MyApp.Derives]`). Per-module lists support a `:config` sentinel for in-position merge with the global registry. Compile-time shadow warnings if a custom op-name collides with a built-in.
-Suppose you are going to collect a number of pieces of information from the user, and before doing anything else, you are going to sanitize them.
-After that, you are going to validate each piece of data, and if there are no issues, you will either display it in a proper output or save it somewhere else.
-All of the characteristics that are associated with this macro revolve around cleaning and validating the data.
+### 🔌 Ash integration
-The features that we list below are individually based on a particular strategy and requirement, but thankfully, they may be combined and mixed in any way that you see fit.
+```elixir
+defmodule MyApp.User do
+ use Ash.Resource, extensions: [GuardedStruct.AshResource]
-It bestows to you a significant amount of authority in this sphere.
-After the initial version of this macro was obtained from the source of the `typed_struct` library, many sections of it were rewritten, or new concepts were taken from libraries in Rust and Scala and added to this library in the form of Elixir base.
+ guardedstruct do
+ auto_wire true
+ field :email, :string, derives: "sanitize(trim, downcase) validate(email_r)"
+ end
+
+ attributes do
+ uuid_primary_key :id
+ attribute :email, :string, allow_nil?: false, public?: true
+ end
-The initial version of this macro can be found in the `typed_struct` library. Its base is a syntax that is very easy to comprehend, especially for non-technical product managers, and highly straightforward.
+ actions do
+ defaults [:read, :destroy]
+ create :create, accept: [:email]
+ end
+end
+```
+
+- 🌉 **`GuardedStruct.AshResource.Change`** — bridges `__guarded_change__/1` into the Ash changeset pipeline.
+- ⚡ **`auto_wire true`** — Spark transformer injects the change for you; no `changes do ... end` block needed.
+- 📦 **`batch_change/3`** — `Ash.bulk_create/3` and `Ash.bulk_update/3` (with `strategy: :stream`) work end-to-end.
+- 🌊 **Auto-map cascade** — every `sub_field` returns a plain map at every depth (matches Ash's `:map` attribute type).
+- 🔒 **`atomic: true`** — compile-time verifier rejects (with `Spark.Error.DslError` at the offending field) any op that can't translate to atomic SQL.
+
+### 🔮 Standalone validation API
+
+```elixir
+GuardedStruct.Validate.run("validate(email_r)", "alice@x.io")
+# => {:ok, "alice@x.io"}
+
+GuardedStruct.Validate.field(User, :email, "bad")
+# => {:error, [%{field: :email, action: :email_r, ...}]}
+
+GuardedStruct.Validate.partial(User, %{name: "Alice"})
+# => {:ok, %{name: "Alice"}} # missing fields skipped, no enforce check
+```
-Before explaining the copyright, I must point out that the primary library, which is `typed_struct`, is no longer supported for a long time, so please pay attention to the following copyright.
+### 📡 Telemetry
-[](https://livebook.dev/run?url=https%3A%2F%2Fgithub.com%2Fmishka-group%2Fguarded_struct%2Fblob%2Fmaster%2Fguidance%2Fguarded-struct.livemd)
+Every top-level `builder/1` emits `[:guarded_struct, :builder, :start | :stop | :exception]`. Attach a handler for logging, metrics, tracing — no manual instrumentation needed.
-## Installation
+### 🪞 Introspection (`GuardedStruct.Info`)
+
+```elixir
+GuardedStruct.Info.describe(User)
+# => %{module: User, keys: [...], enforce_keys: [...],
+# fields: [%{name: :email, kind: :field, ...}, ...],
+# options: %{enforce: true, json: false, atomic: false, ...}}
+
+GuardedStruct.Info.field_kind(User, :email) #=> :field
+GuardedStruct.Info.enforce?(User, :email) #=> true
+GuardedStruct.Info.sub_module(User, :address) #=> User.Address
+GuardedStruct.Info.conditional_children(User, :billing)
+```
+
+### 🛡️ Errors as Splode exceptions (opt-in)
+
+```elixir
+case User.builder(input) do
+ {:ok, _} = ok -> ok
+ {:error, errs} -> {:error, GuardedStruct.Errors.from_tuple(errs)}
+end
+```
+
+Gives `Splode.traverse_errors/2`, `to_class/1`, JSON-serializable errors.
+
+### 📤 JSON encoding (opt-in)
+
+```elixir
+guardedstruct json: true do
+ field :id, :string
+end
+```
+
+Auto-derives `Jason.Encoder` when `:jason` is in deps, falling back to the built-in `JSON.Encoder` on Elixir 1.18+. No-op if neither is present.
+
+### 🌍 Cross-cutting
+
+- 🌐 **i18n** — every error message resolves through `GuardedStruct.Messages`; override callbacks to translate.
+- 🛡️ **Atom-attack safe** — `dynamic_field` and pattern-keyed maps never `String.to_atom` user input.
+- 🧪 **Property-based tested** — 740+ tests including 6 property tests, real Ash integration suite with ETS data layer.
+
+---
+
+## 🚀 Installation
+
+Add to your `mix.exs`:
```elixir
def deps do
[
- {:guarded_struct, "~> 0.0.4"}
+ {:guarded_struct, "~> 0.1.0"}
]
end
```
-## Table of Contents
+Fetch and compile:
-* [Defines a guarded struct](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#defines-a-guarded-struct)
-* [Defining a struct layer without additional options](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#defining-a-struct-layer-without-additional-options)
-* [Define a struct with settings related to essential keys or `opaque` type](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#define-a-struct-with-settings-related-to-essential-keys-or-opaque-type)
-* [Defining the struct by calling the validation module or calling from the module that contains the struct](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#defining-the-struct-by-calling-the-validation-module-or-calling-from-the-module-that-contains-the-struct)
-* [Define the struct by calling the `main_validator` for full access on the output](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#define-the-struct-by-calling-the-main_validator-for-full-access-on-the-output)
-* [Define struct with `derive`](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#define-struct-with-derive)
-* [Extending `derive` section](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#extending-derive-section)
-* [Struct definition with `validator` and `derive` simultaneously](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#struct-definition-with-validator-and-derive-simultaneously)
-* [Define a nested and complex struct](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#define-a-nested-and-complex-struct)
-* [Error and data output sample](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#error-and-data-output-sample)
-* [Set config to show error inside `defexception`](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#error-and-data-output-sample)
-* [Error `defexception` modules](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#error-defexception-modules)
-* [`authorized_fields` option to limit user input](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#authorized_fields-option-to-limit-user-input)
-* [List of structs](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#list-of-structs)
-* [Struct information function](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#struct-information-function)
-* [Transmitting whole output of builder function to its children](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#transmitting-whole-output-of-builder-function-to-its-children)
-* [Auto core key](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#auto-core-key)
-* [On core key](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#on-core-key)
-* [From core key](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#from-core-key)
-* [Domain core key](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#domain-core-key)
-* [Domain core key with `equal` and `either` support](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#domain-core-key-with-equal-and-either-support)
-* [Domain core key with Custom function support](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#domain-core-key-with-custom-function-support)
-* [Conditional fields](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#conditional-fields)
-* [List Conditional fields](https://github.com/mishka-group/guarded_struct/blob/master/guidance/guarded-struct.livemd#list-conditional-fields)
+```sh
+mix deps.get
+mix compile
+```
+Upgrading from `0.0.x`? Existing code keeps working unchanged — see [`CHANGELOG.md`](./CHANGELOG.md) for every change in v0.1.0.
+### Optional deps
-> The docs can be found at https://hexdocs.pm/guarded_struct.
+Pull in only what you need:
+```elixir
+{:jason, "~> 1.4"} # for `json: true` (Elixir < 1.18, otherwise built-in JSON works)
+{:splode, "~> 0.3"} # for Errors wrapper
+{:ash, "~> 3.0"} # for the Ash extension
+{:html_sanitize_ex, "~> 1.5"} # for `sanitize(strip_tags, basic_html, html5)`
+{:email_checker, "~> 0.2"} # for `validate(email)` (DNS lookup; non-atomic)
+{:ex_url, "~> 2.0"} # for `validate(url)` (DNS / port check; non-atomic)
+```
---
-# Donate
+## 🎯 Quick start
-You can support this project through the "[Sponsor](https://github.com/sponsors/mishka-group)" button on GitHub or via cryptocurrency donations. All our projects are **open-source** and **free**, and we rely on community contributions to enhance and improve them further.
+### 📐 A struct
-| **BTC** | **ETH** | **DOGE** | **TRX** |
-| ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
-|
|
|
|
|
-
-
- Donate addresses
+```elixir
+defmodule Order do
+ use GuardedStruct
-**BTC**:
+ guardedstruct enforce: true do
+ field :id, :string, auto: {Ecto.UUID, :generate}
+ field :total, :integer, derives: "validate(integer, min_len=0)"
+ field :currency, :string, default: "USD",
+ derives: "validate(enum=String[USD::EUR::GBP::JPY])"
+ field :placed_at, :string, derives: "validate(datetime)"
+ end
+end
+Order.builder(%{total: 9_900, placed_at: "2026-05-14T10:00:00Z"})
+# => {:ok, %Order{id: "a-uuid", total: 9900, currency: "USD", placed_at: "..."}}
```
-bc1q24pmrpn8v9dddgpg3vw9nld6hl9n5dkw5zkf2c
+
+### 🌳 Nested + conditional
+
+```elixir
+defmodule Account do
+ use GuardedStruct
+
+ guardedstruct do
+ field :name, :string, enforce: true
+
+ sub_field :owner, struct(), enforce: true do
+ field :email, :string, enforce: true, derives: "validate(email_r)"
+ field :role, :string, default: "owner"
+ end
+
+ # Same field name resolves to either a string preset OR a detailed map
+ conditional_field :plan, any() do
+ field :plan, :string, hint: "preset",
+ derives: "validate(enum=String[free::pro::enterprise])"
+
+ sub_field :plan, struct() do
+ field :tier, :string, enforce: true
+ field :seats, :integer, derives: "validate(integer, min_len=1)"
+ end
+ end
+ end
+end
+
+Account.builder(%{name: "Acme", owner: %{email: "z@a.io"}, plan: "pro"})
+# => {:ok, %Account{plan: "pro", ...}}
+
+Account.builder(%{name: "Acme", owner: %{email: "z@a.io"},
+ plan: %{tier: "custom", seats: 50}})
+# => {:ok, %Account{plan: %Account.Plan1{tier: "custom", seats: 50}, ...}}
```
-**ETH**:
+### 🪝 Custom validators / sanitizers
+
+```elixir
+defmodule MyApp.Derives do
+ use GuardedStruct.Derive.Extension
+
+ derives do
+ validator :slug, fn input ->
+ is_binary(input) and Regex.match?(~r/^[a-z0-9-]+$/, input)
+ end
+
+ sanitizer :slugify, fn input when is_binary(input) ->
+ input
+ |> String.downcase()
+ |> String.replace(~r/[^a-z0-9]+/u, "-")
+ |> String.trim("-")
+ end
+
+ validator :positive_int, fn n -> is_integer(n) and n > 0 end
+ end
+end
+
+# Register globally:
+# config :guarded_struct, derive_extensions: [MyApp.Derives]
+
+defmodule Post do
+ use GuardedStruct
+ guardedstruct do
+ field :slug, :string, derives: "sanitize(slugify) validate(slug)"
+ field :views, :integer, derives: "validate(positive_int)"
+ end
+end
```
-0xD99feB9db83245dE8B9D23052aa8e62feedE764D
+
+### 🔌 Ash integration
+
+```elixir
+defmodule MyApp.User do
+ use Ash.Resource, extensions: [GuardedStruct.AshResource]
+
+ guardedstruct do
+ auto_wire true
+
+ field :email, :string,
+ derives: "sanitize(trim, downcase) validate(email_r, max_len=320)"
+
+ field :nickname, :string,
+ derives: "sanitize(trim) validate(string, max_len=20)"
+ end
+
+ attributes do
+ uuid_primary_key :id
+ attribute :email, :string, allow_nil?: false, public?: true
+ attribute :nickname, :string, public?: true
+ end
+
+ actions do
+ defaults [:read, :destroy]
+ create :create, accept: [:email, :nickname]
+
+ update :update do
+ accept [:email, :nickname]
+ require_atomic? false
+ end
+ end
+end
+
+MyApp.User
+|> Ash.Changeset.for_create(:create, %{email: " Alice@X.IO "})
+|> Ash.create()
+# => {:ok, %MyApp.User{email: "alice@x.io", ...}}
```
-**DOGE**:
+---
+
+## 🔒 Atomic mode (Ash)
+For Ash resources where every derive op is SQL-translatable, set `atomic true` to opt into compile-time verification:
+
+```elixir
+guardedstruct do
+ atomic true
+ auto_wire true
+
+ field :email, :string, derives: "sanitize(trim, downcase) validate(email_r, max_len=320)"
+ field :age, :integer, derives: "validate(integer, min_len=0, max_len=150)"
+ field :role, :string, derives: "validate(enum=String[admin::user::guest])"
+ field :tenant_id, :string, derives: "validate(uuid)"
+end
```
-DGGT5PfoQsbz3H77sdJ1msfqzfV63Q3nyH
+
+The compile-time `VerifyAtomic` verifier rejects (with `Spark.Error.DslError` pointing at the offending field's source line) any op that can't translate to atomic SQL:
+
+| ❌ Blocked op | Reason | Fix |
+|---|---|---|
+| `validate(email)` | DNS lookup via `:email_checker` | Use `validate(email_r)` |
+| `validate(url)` | DNS/port via `:ex_url` | Use `validate(url_r)` |
+| `validator: {Mod, :fn}` | Arbitrary Elixir | Move rule into `derives:` |
+| `auto: {Mod, :fn}` | Arbitrary Elixir | Use SQL default or migration |
+| `main_validator/1` | Cross-field Elixir | Express as per-field derive |
+| Custom `Derive.Extension` op | Arbitrary Elixir | Express as built-in |
+| `on:` / `from:` / `domain:` | Cross-field at runtime | Express as per-field rule |
+
+Sanitize ops (`trim`, `downcase`, `strip_tags`, `slugify`, …) are **always allowed** — they run in Elixir before the atomic SQL fires. The error message **distinguishes typos from custom Extension ops** so you don't chase the wrong fix. Full safe-op registry: `GuardedStruct.AtomicClassifier`.
+
+---
+
+## 🪞 Introspection
+
+```elixir
+# Full dump in one call
+GuardedStruct.Info.describe(MyApp.User)
+# %{
+# module: MyApp.User,
+# path: [], key: :root, shape: :struct,
+# keys: [:email, :nickname], enforce_keys: [:email],
+# conditional_keys: [],
+# options: %{enforce: true, json: false, atomic: false, ...},
+# fields: [
+# %{name: :email, kind: :field, enforce?: true,
+# type: "String.t()", derive: "...", auto: nil, ...},
+# ...
+# ]
+# }
+
+# Field-level helpers
+GuardedStruct.Info.field_kind(MyApp.User, :email) #=> :field
+GuardedStruct.Info.enforce?(MyApp.User, :email) #=> true
+GuardedStruct.Info.virtual?(MyApp.User, :password_confirm) #=> true
+GuardedStruct.Info.field_derives(MyApp.User, :email)
+#=> "sanitize(trim, downcase) validate(email_r)"
+
+# Collections by kind
+GuardedStruct.Info.sub_fields(MyApp.User) #=> [:address]
+GuardedStruct.Info.virtual_fields(MyApp.User) #=> [:password_confirm]
+GuardedStruct.Info.conditional_fields(MyApp.User) #=> [:plan]
+
+# Navigation
+GuardedStruct.Info.sub_module(MyApp.User, :address)
+#=> MyApp.User.Address
+GuardedStruct.Info.conditional_children(MyApp.User, :plan)
+#=> [%{kind: :field, ...}, %{kind: :sub_field, ...}]
```
-**TRX**:
+---
+
+## 🏗️ Architecture
```
-TBamHas3wAxSEvtBcWKuT3zphckZo88puz
+ +-------------------------+
+ | guardedstruct do ... end|
+ | (user-facing DSL block) |
+ +------------+------------+
+ |
+ +----------------+----------------+
+ | Spark.Dsl.Extension |
+ | parses entities + section opts |
+ +----------------+----------------+
+ |
+ +---------+-----------+-----------+----------+----------+
+ | | | | | |
+ v v v v v v
+ Transformers Verifiers Codegen AsyncSubmod Decorators AshChange
+ (Derive, (Atomic, (defstruct (Module. (@derives) (bridge to
+ Domain, ValidMFA, builder, create Ash pipeline,
+ Auto, ...) Cycle, ...) keys,...) +async) batch_change)
+ |
+ v
+ +------------+
+ | __fields__ | <-- introspection lives here
+ | __info___ |
+ +-----+------+
+ |
+ v
+ +----------+-----------+
+ | Runtime pipeline |
+ | sanitize → validate |
+ | → derive → main_val |
+ +----------------------+
+```
+
+- 🧠 **DSL layer** — Spark sections + entities define `field`, `sub_field`, `conditional_field`, `virtual_field`, `dynamic_field`. Every op-string parsed at compile time.
+- 🔧 **Transformers** — codegen for `defstruct`/`builder`/`keys`/`__information__`/`__fields__`, async sub_field submodule generation, derive parsing, core-key parsing, Ash-variant codegen, auto-wire injection.
+- 🔍 **Verifiers** — validator MFAs exist, auto MFAs exist, no struct cycles, atomic-safety (when opted in).
+- 🏃 **Runtime** — receives a map, walks pre-parsed op-lists per field, hands back `{:ok, %Struct{}}` or `{:error, [%{field, action, message}]}`.
+
+---
+
+## 🔌 Compatibility
+
+| Dependency | Required version | Required? |
+|---|---|---|
+| Elixir | `~> 1.17` | ✅ |
+| Spark | `~> 2.7` | ✅ |
+| Splode | `~> 0.3` | ✅ (errors module) |
+| Telemetry | `~> 1.0` | ✅ |
+| html_sanitize_ex | `~> 1.5` | ⚪ optional (`sanitize(strip_tags/basic_html/html5)`) |
+| Jason | `~> 1.4` | ⚪ optional (`json: true` on Elixir < 1.18) |
+| email_checker | `~> 0.2` | ⚪ optional (`validate(email)` with DNS) |
+| ex_url | `~> 2.0` | ⚪ optional (`validate(url)` with DNS) |
+| Ash | `~> 3.0` | ⚪ optional (for the `Ash.Resource` extension) |
+
+---
+
+## 📚 Documentation
+
+- 📖 **API docs** — [hexdocs.pm/guarded_struct](https://hexdocs.pm/guarded_struct)
+- 📘 **LiveBook walkthrough** — [`guidance/guarded-struct.livemd`](./guidance/guarded-struct.livemd) — runnable end-to-end examples
+- 📋 **Options reference** — [`OPTIONS-0.1.0.md`](./OPTIONS-0.1.0.md) — every new option in v0.1.0 with examples
+- 📜 **Changelog** — [`CHANGELOG.md`](./CHANGELOG.md)
+- 🔐 **Security policy** — [`SECURITY.md`](./SECURITY.md) — supported versions + how to report a vulnerability
+- 🧱 **DSL reference** — auto-generated cheat sheets in `documentation/dsls/` (published to hexdocs)
+- 📰 **Blog post** — [original motivation and design](https://mishka.tools/blog/guardedstruct-advanced-elixir-struct-data-validation-and-sanitization)
+
+---
+
+## 🛣️ Status & roadmap
+
+| Area | Status |
+|---|---|
+| `0.1.0-beta` rewrite on Spark | 🟡 Beta — feature-complete, API stable |
+| Backward compatibility with `0.0.x` | 🟢 Drop-in — every 0.0.x API preserved |
+| Nested `conditional_field` (closes #7, #8, #25) | 🟢 Shipped |
+| Pattern-keyed maps (closes #11) | 🟢 Shipped |
+| `virtual_field` / `dynamic_field` (closes #5) | 🟢 Shipped |
+| Standalone `Validate` API (closes #2) | 🟢 Shipped |
+| Erlang Records (closes #6) | 🟢 Shipped |
+| Custom validators via Spark DSL | 🟢 Shipped |
+| Ash extension + auto-wire + atomic verifier | 🟢 Shipped |
+| Test coverage | 🟢 743+ tests, real Ash integration suite |
+| `1.0.0` release | 🔵 Pending community feedback on `0.1.0-beta` |
+
+Breaking changes will be flagged in the [CHANGELOG](./CHANGELOG.md).
+
+---
+
+## 🤝 Contributing
+
+Issues, PRs, and design discussions are welcome. 💬
+
+```sh
+git clone https://github.com/mishka-group/guarded_struct.git
+cd guarded_struct
+mix deps.get
+mix test
```
-
+Before opening a PR:
+
+- ✅ `mix test` — full suite green (`mix test --max-failures 1` for fail-fast)
+- ✅ `mix lint` — `spark.formatter` + `format` both pass
+- ✅ `mix cheat` — regenerate DSL cheat sheets if you touched entities
+
+For larger feature work, please open an issue first so we can align on the design.
+
+---
+
+## 💖 Funding & sponsorship
+
+GuardedStruct is open-source software developed by [Mishka Group](https://github.com/mishka-group). If your team or company benefits from this work, please consider supporting continued development:
+
+
+
+[](https://github.com/sponsors/mishka-group)
+
+[](https://www.buymeacoffee.com/mishkagroup)
+
+**☕ Donate / sponsor:**
+[github.com/sponsors/mishka-group](https://github.com/sponsors/mishka-group) · [buymeacoffee.com/mishkagroup](https://www.buymeacoffee.com/mishkagroup)
+
+
+
+Sponsorship directly funds maintenance, new features, and documentation. Thank you. 💚
+
+---
+
+## 📜 License
+
+Apache License 2.0 — see [`LICENSE`](LICENSE).
+
+Copyright © [Mishka Group](https://mishka.tools) and contributors.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..b9040dc
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,11 @@
+# Security Policy
+
+## Supported Versions
+
+| Version | Supported |
+| ------- | ------------------ |
+| >= 0.1.0 | :white_check_mark: |
+
+## Reporting a Vulnerability
+
+Please be sure to contact `info@mishka.tools` email if you find a security bug.
diff --git a/config/config.exs b/config/config.exs
new file mode 100644
index 0000000..1faf86c
--- /dev/null
+++ b/config/config.exs
@@ -0,0 +1,40 @@
+import Config
+
+config :spark,
+ formatter: [
+ remove_parens?: true,
+ "Ash.Resource": [
+ section_order: [
+ :authentication,
+ :tokens,
+ :postgres,
+ :json_api,
+ :graphql,
+ :resource,
+ :code_interface,
+ :actions,
+ :policies,
+ :pub_sub,
+ :preparations,
+ :changes,
+ :validations,
+ :multitenancy,
+ :attributes,
+ :relationships,
+ :calculations,
+ :aggregates,
+ :identities
+ ]
+ ],
+ "Ash.Domain": [
+ section_order: [
+ :json_api,
+ :graphql,
+ :resources,
+ :policies,
+ :authorization,
+ :domain,
+ :execution
+ ]
+ ]
+ ]
diff --git a/documentation/dsls/DSL-GuardedStruct.AshResource.md b/documentation/dsls/DSL-GuardedStruct.AshResource.md
new file mode 100644
index 0000000..0bbf75b
--- /dev/null
+++ b/documentation/dsls/DSL-GuardedStruct.AshResource.md
@@ -0,0 +1,1294 @@
+
+# GuardedStruct.AshResource
+
+A Spark DSL extension that adds the GuardedStruct DSL to an Ash resource.
+
+## Usage
+
+ defmodule MyApp.User do
+ use Ash.Resource,
+ domain: MyApp.MyDomain,
+ extensions: [GuardedStruct.AshResource]
+
+ attributes do
+ uuid_primary_key :id
+ attribute :email, :string, allow_nil?: false, public?: true
+ end
+
+ # GuardedStruct DSL — identical syntax to standalone `use GuardedStruct`.
+ guardedstruct do
+ field :email, :string,
+ derives: "sanitize(trim, downcase) validate(string, not_empty, email_r)"
+
+ field :nickname, :string,
+ derives: "sanitize(strip_tags, trim) validate(string, max_len=20)"
+
+ sub_field :preferences, :map do
+ field :theme, :string, derives: "validate(enum=String[light::dark])"
+ end
+ end
+
+ # Wire the change into Ash's changeset pipeline (Option A — manual).
+ changes do
+ change GuardedStruct.AshResource.Change
+ end
+ end
+
+Now every `:create` and `:update` action runs the GuardedStruct pipeline
+(sanitize → validate → derive → main_validator) before Ash hits the data
+layer. Errors surface as standard `Ash.Changeset.add_error/2` errors.
+
+## Two wiring modes
+
+### Option A — manual (default)
+
+Ship-and-forget: we provide `GuardedStruct.AshResource.Change`; you add
+a one-line `changes do change ... end` block as shown above. Explicit and
+inspectable — `Ash.Resource.Info.changes/1` will show the change.
+
+### Option B — auto-wire
+
+Set `auto_wire: true` on the section and the change is injected for you:
+
+ guardedstruct auto_wire: true do
+ field :email, :string, derives: "sanitize(trim) validate(email_r)"
+ end
+
+ # no `changes do ... end` block needed — the transformer added it
+
+Under the hood this calls `Ash.Resource.Builder.add_change/3` from a Spark
+transformer that runs after our codegen. The result is identical to writing
+the `changes do change ... end` block by hand — Ash's introspection sees
+the change either way. `auto_wire` is `false` by default (no magic).
+
+## What this extension does NOT do
+
+* **It does not generate `defstruct`.** Ash already does that.
+* **It does not generate `builder/2`.** Ash uses changesets.
+* **It does not generate `Error` exception modules.** Ash has its own error
+ classes (`Ash.Error.*`).
+
+Instead, the extension adds a single function — `__guarded_change__/1` —
+that takes a map of attrs and returns `{:ok, transformed_attrs}` or
+`{:error, errors}`. The companion `GuardedStruct.AshResource.Change` module
+wires it into the changeset; `GuardedStruct.AshResource.Info` provides
+introspection.
+
+## Why `__guarded_change__` (not `__guarded_validate__`)
+
+Earlier drafts called the function `__guarded_validate__/1`. We renamed it
+because the function does more than validate — sanitize ops transform
+values (trim, downcase, slugify), `auto:` MFAs fill defaults, derives
+cast types. "Change" matches Ash's own terminology and is honest about
+the side-effect.
+
+## Auto-map cascade
+
+Every nested `sub_field` returns a plain map (not a struct) at every depth
+when called through `__guarded_change__/1`. This is automatic and unique
+to the Ash extension — standalone `use GuardedStruct` callers still get
+structs from `builder/1`.
+
+ MyResource.__guarded_change__(%{
+ profile: %{address: %{geo: %{lat: 1.0, lng: 2.0}}}
+ })
+ # {:ok, %{profile: %{address: %{geo: %{lat: 1.0, lng: 2.0}}}}}
+ # ^^^^ plain map, NOT a struct
+
+This matches Ash's `:map` attribute type, so validated output drops
+directly into `changeset.attributes` without conversion. Implementation
+is a process-local flag — concurrency-safe (sibling processes don't see
+it), re-entrancy-safe (saved+restored across nested calls), zero overhead
+for standalone callers.
+
+## Update actions — `require_atomic? false`
+
+`GuardedStruct.AshResource.Change` runs an imperative Elixir pipeline.
+Ash 3.x's update planner requires changes to declare atomic-safety, and
+ours opts out via `atomic/3` returning `{:not_atomic, reason}`. On any
+UPDATE action that uses this change, set `require_atomic? false`:
+
+ actions do
+ update :update do
+ accept [:email]
+ require_atomic? false
+ end
+ end
+
+CREATE actions don't need this flag — Ash only enforces atomic mode on
+updates.
+
+## sub_field vs Ash relationships
+
+`sub_field` inside an Ash resource creates an **embedded value type**, not
+a related Ash resource. The generated submodule is a standalone
+GuardedStruct (it has `defstruct`, `builder/1`, full GuardedStruct API)
+but it is NOT an Ash resource (no actions, no changesets, no table). Use
+`sub_field` for nested map shapes inside a single resource's attrs. For
+separate tables and relationships, use Ash's own `relationships do
+has_one :preferences, ... end`.
+
+## Companion modules
+
+* `GuardedStruct.AshResource.Change` — the `Ash.Resource.Change` module
+ that bridges `__guarded_change__/1` into the changeset pipeline.
+* `GuardedStruct.AshResource.Info` — runtime introspection for the
+ `__guarded_*` namespace.
+
+## Example: introspect a resource's guarded fields
+
+ GuardedStruct.AshResource.Info.fields(MyApp.User)
+ # => [:email, :nickname, :preferences]
+
+
+## guardedstruct
+
+
+### Nested DSLs
+ * [field](#guardedstruct-field)
+ * [virtual_field](#guardedstruct-virtual_field)
+ * [dynamic_field](#guardedstruct-dynamic_field)
+ * [sub_field](#guardedstruct-sub_field)
+ * conditional_field
+ * field
+ * sub_field
+ * field
+ * sub_field
+ * field
+ * field
+ * [conditional_field](#guardedstruct-conditional_field)
+ * sub_field
+ * conditional_field
+ * field
+ * sub_field
+ * field
+ * sub_field
+ * field
+ * field
+ * conditional_field
+ * field
+ * sub_field
+ * field
+ * field
+
+
+
+
+
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-enforce){: #guardedstruct-enforce } | `boolean` | `false` | |
+| [`opaque`](#guardedstruct-opaque){: #guardedstruct-opaque } | `boolean` | `false` | |
+| [`module`](#guardedstruct-module){: #guardedstruct-module } | `any` | | |
+| [`error`](#guardedstruct-error){: #guardedstruct-error } | `boolean` | `false` | |
+| [`authorized_fields`](#guardedstruct-authorized_fields){: #guardedstruct-authorized_fields } | `boolean` | `false` | |
+| [`main_validator`](#guardedstruct-main_validator){: #guardedstruct-main_validator } | `{atom, atom}` | | |
+| [`validate_derive`](#guardedstruct-validate_derive){: #guardedstruct-validate_derive } | `atom \| list(atom)` | | |
+| [`sanitize_derive`](#guardedstruct-sanitize_derive){: #guardedstruct-sanitize_derive } | `atom \| list(atom)` | | |
+| [`json`](#guardedstruct-json){: #guardedstruct-json } | `boolean` | `false` | When `true`, derives a JSON encoder. Uses `Jason.Encoder` if `:jason` is in the user's deps; otherwise falls back to the built-in `JSON.Encoder` on Elixir 1.18+. No-op if neither is available. |
+| [`auto_wire`](#guardedstruct-auto_wire){: #guardedstruct-auto_wire } | `boolean` | `false` | Only effective inside the `GuardedStruct.AshResource` extension. When `true`, injects `GuardedStruct.AshResource.Change` into the resource's top-level `changes` section so every `:create` and `:update` action automatically runs the GuardedStruct pipeline. Equivalent to writing `changes do change GuardedStruct.AshResource.Change end` by hand. No-op outside the Ash extension. |
+| [`atomic`](#guardedstruct-atomic){: #guardedstruct-atomic } | `boolean` | `false` | Opt into atomic-SQL mode. When `true`, the `VerifyAtomic` verifier rejects at compile time any field whose derive ops, per-field `validator:`, `auto:`, or top-level `main_validator/1` callback can't translate to atomic SQL (e.g. `validate(email)` which does DNS lookup, custom MFAs, custom Derive.Extension ops). Errors point at the offending field with the exact reason. Default `false` keeps the imperative path. |
+
+
+
+### guardedstruct.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-field-name){: #guardedstruct-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-field-type){: #guardedstruct-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-field-enforce){: #guardedstruct-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-field-default){: #guardedstruct-field-default } | `any` | | |
+| [`derives`](#guardedstruct-field-derives){: #guardedstruct-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-field-derive){: #guardedstruct-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-field-validator){: #guardedstruct-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-field-auto){: #guardedstruct-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-field-from){: #guardedstruct-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-field-on){: #guardedstruct-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-field-domain){: #guardedstruct-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-field-struct){: #guardedstruct-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-field-structs){: #guardedstruct-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-field-hint){: #guardedstruct-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-field-priority){: #guardedstruct-field-priority } | `boolean` | | |
+
+
+
+
+
+
+### guardedstruct.virtual_field
+```elixir
+virtual_field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-virtual_field-name){: #guardedstruct-virtual_field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-virtual_field-type){: #guardedstruct-virtual_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-virtual_field-enforce){: #guardedstruct-virtual_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-virtual_field-default){: #guardedstruct-virtual_field-default } | `any` | | |
+| [`derives`](#guardedstruct-virtual_field-derives){: #guardedstruct-virtual_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-virtual_field-derive){: #guardedstruct-virtual_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-virtual_field-validator){: #guardedstruct-virtual_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-virtual_field-auto){: #guardedstruct-virtual_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-virtual_field-from){: #guardedstruct-virtual_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-virtual_field-on){: #guardedstruct-virtual_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-virtual_field-domain){: #guardedstruct-virtual_field-domain } | `String.t` | | |
+| [`hint`](#guardedstruct-virtual_field-hint){: #guardedstruct-virtual_field-hint } | `String.t` | | |
+
+
+
+
+
+
+### guardedstruct.dynamic_field
+```elixir
+dynamic_field name
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-dynamic_field-name){: #guardedstruct-dynamic_field-name .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`type`](#guardedstruct-dynamic_field-type){: #guardedstruct-dynamic_field-type } | `any` | `{:map, [], []}` | |
+| [`enforce`](#guardedstruct-dynamic_field-enforce){: #guardedstruct-dynamic_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-dynamic_field-default){: #guardedstruct-dynamic_field-default } | `any` | `{:%{}, [], []}` | |
+| [`derives`](#guardedstruct-dynamic_field-derives){: #guardedstruct-dynamic_field-derives } | `String.t` | `"validate(map)"` | |
+| [`derive`](#guardedstruct-dynamic_field-derive){: #guardedstruct-dynamic_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-dynamic_field-validator){: #guardedstruct-dynamic_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-dynamic_field-auto){: #guardedstruct-dynamic_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-dynamic_field-from){: #guardedstruct-dynamic_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-dynamic_field-on){: #guardedstruct-dynamic_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-dynamic_field-domain){: #guardedstruct-dynamic_field-domain } | `String.t` | | |
+| [`hint`](#guardedstruct-dynamic_field-hint){: #guardedstruct-dynamic_field-hint } | `String.t` | | |
+
+
+
+
+
+
+### guardedstruct.sub_field
+```elixir
+sub_field name, type
+```
+
+
+
+
+### Nested DSLs
+ * [conditional_field](#guardedstruct-sub_field-conditional_field)
+ * field
+ * sub_field
+ * field
+ * [sub_field](#guardedstruct-sub_field-sub_field)
+ * field
+ * [field](#guardedstruct-sub_field-field)
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-sub_field-name){: #guardedstruct-sub_field-name .spark-required} | `atom` | | |
+| [`type`](#guardedstruct-sub_field-type){: #guardedstruct-sub_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-sub_field-enforce){: #guardedstruct-sub_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-sub_field-default){: #guardedstruct-sub_field-default } | `any` | | |
+| [`derives`](#guardedstruct-sub_field-derives){: #guardedstruct-sub_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-sub_field-derive){: #guardedstruct-sub_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-sub_field-validator){: #guardedstruct-sub_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-sub_field-auto){: #guardedstruct-sub_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-sub_field-from){: #guardedstruct-sub_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-sub_field-on){: #guardedstruct-sub_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-sub_field-domain){: #guardedstruct-sub_field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-sub_field-struct){: #guardedstruct-sub_field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-sub_field-structs){: #guardedstruct-sub_field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-sub_field-hint){: #guardedstruct-sub_field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-sub_field-priority){: #guardedstruct-sub_field-priority } | `boolean` | | |
+| [`error`](#guardedstruct-sub_field-error){: #guardedstruct-sub_field-error } | `boolean` | | |
+| [`authorized_fields`](#guardedstruct-sub_field-authorized_fields){: #guardedstruct-sub_field-authorized_fields } | `boolean` | | |
+| [`main_validator`](#guardedstruct-sub_field-main_validator){: #guardedstruct-sub_field-main_validator } | `{atom, atom}` | | |
+
+
+### guardedstruct.sub_field.conditional_field
+```elixir
+conditional_field name, type
+```
+
+
+
+
+### Nested DSLs
+ * [field](#guardedstruct-sub_field-conditional_field-field)
+ * [sub_field](#guardedstruct-sub_field-conditional_field-sub_field)
+ * field
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-sub_field-conditional_field-name){: #guardedstruct-sub_field-conditional_field-name .spark-required} | `atom` | | |
+| [`type`](#guardedstruct-sub_field-conditional_field-type){: #guardedstruct-sub_field-conditional_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-sub_field-conditional_field-enforce){: #guardedstruct-sub_field-conditional_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-sub_field-conditional_field-default){: #guardedstruct-sub_field-conditional_field-default } | `any` | | |
+| [`derives`](#guardedstruct-sub_field-conditional_field-derives){: #guardedstruct-sub_field-conditional_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-sub_field-conditional_field-derive){: #guardedstruct-sub_field-conditional_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-sub_field-conditional_field-validator){: #guardedstruct-sub_field-conditional_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-sub_field-conditional_field-auto){: #guardedstruct-sub_field-conditional_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-sub_field-conditional_field-from){: #guardedstruct-sub_field-conditional_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-sub_field-conditional_field-on){: #guardedstruct-sub_field-conditional_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-sub_field-conditional_field-domain){: #guardedstruct-sub_field-conditional_field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-sub_field-conditional_field-struct){: #guardedstruct-sub_field-conditional_field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-sub_field-conditional_field-structs){: #guardedstruct-sub_field-conditional_field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-sub_field-conditional_field-hint){: #guardedstruct-sub_field-conditional_field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-sub_field-conditional_field-priority){: #guardedstruct-sub_field-conditional_field-priority } | `boolean` | | |
+
+
+### guardedstruct.sub_field.conditional_field.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-sub_field-conditional_field-field-name){: #guardedstruct-sub_field-conditional_field-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-sub_field-conditional_field-field-type){: #guardedstruct-sub_field-conditional_field-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-sub_field-conditional_field-field-enforce){: #guardedstruct-sub_field-conditional_field-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-sub_field-conditional_field-field-default){: #guardedstruct-sub_field-conditional_field-field-default } | `any` | | |
+| [`derives`](#guardedstruct-sub_field-conditional_field-field-derives){: #guardedstruct-sub_field-conditional_field-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-sub_field-conditional_field-field-derive){: #guardedstruct-sub_field-conditional_field-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-sub_field-conditional_field-field-validator){: #guardedstruct-sub_field-conditional_field-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-sub_field-conditional_field-field-auto){: #guardedstruct-sub_field-conditional_field-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-sub_field-conditional_field-field-from){: #guardedstruct-sub_field-conditional_field-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-sub_field-conditional_field-field-on){: #guardedstruct-sub_field-conditional_field-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-sub_field-conditional_field-field-domain){: #guardedstruct-sub_field-conditional_field-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-sub_field-conditional_field-field-struct){: #guardedstruct-sub_field-conditional_field-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-sub_field-conditional_field-field-structs){: #guardedstruct-sub_field-conditional_field-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-sub_field-conditional_field-field-hint){: #guardedstruct-sub_field-conditional_field-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-sub_field-conditional_field-field-priority){: #guardedstruct-sub_field-conditional_field-field-priority } | `boolean` | | |
+
+
+
+
+
+
+### guardedstruct.sub_field.conditional_field.sub_field
+```elixir
+sub_field name, type
+```
+
+
+
+
+### Nested DSLs
+ * [field](#guardedstruct-sub_field-conditional_field-sub_field-field)
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-sub_field-conditional_field-sub_field-name){: #guardedstruct-sub_field-conditional_field-sub_field-name .spark-required} | `atom` | | |
+| [`type`](#guardedstruct-sub_field-conditional_field-sub_field-type){: #guardedstruct-sub_field-conditional_field-sub_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-sub_field-conditional_field-sub_field-enforce){: #guardedstruct-sub_field-conditional_field-sub_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-sub_field-conditional_field-sub_field-default){: #guardedstruct-sub_field-conditional_field-sub_field-default } | `any` | | |
+| [`derives`](#guardedstruct-sub_field-conditional_field-sub_field-derives){: #guardedstruct-sub_field-conditional_field-sub_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-sub_field-conditional_field-sub_field-derive){: #guardedstruct-sub_field-conditional_field-sub_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-sub_field-conditional_field-sub_field-validator){: #guardedstruct-sub_field-conditional_field-sub_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-sub_field-conditional_field-sub_field-auto){: #guardedstruct-sub_field-conditional_field-sub_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-sub_field-conditional_field-sub_field-from){: #guardedstruct-sub_field-conditional_field-sub_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-sub_field-conditional_field-sub_field-on){: #guardedstruct-sub_field-conditional_field-sub_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-sub_field-conditional_field-sub_field-domain){: #guardedstruct-sub_field-conditional_field-sub_field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-sub_field-conditional_field-sub_field-struct){: #guardedstruct-sub_field-conditional_field-sub_field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-sub_field-conditional_field-sub_field-structs){: #guardedstruct-sub_field-conditional_field-sub_field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-sub_field-conditional_field-sub_field-hint){: #guardedstruct-sub_field-conditional_field-sub_field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-sub_field-conditional_field-sub_field-priority){: #guardedstruct-sub_field-conditional_field-sub_field-priority } | `boolean` | | |
+| [`error`](#guardedstruct-sub_field-conditional_field-sub_field-error){: #guardedstruct-sub_field-conditional_field-sub_field-error } | `boolean` | | |
+| [`authorized_fields`](#guardedstruct-sub_field-conditional_field-sub_field-authorized_fields){: #guardedstruct-sub_field-conditional_field-sub_field-authorized_fields } | `boolean` | | |
+| [`main_validator`](#guardedstruct-sub_field-conditional_field-sub_field-main_validator){: #guardedstruct-sub_field-conditional_field-sub_field-main_validator } | `{atom, atom}` | | |
+
+
+### guardedstruct.sub_field.conditional_field.sub_field.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-sub_field-conditional_field-sub_field-field-name){: #guardedstruct-sub_field-conditional_field-sub_field-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-sub_field-conditional_field-sub_field-field-type){: #guardedstruct-sub_field-conditional_field-sub_field-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-sub_field-conditional_field-sub_field-field-enforce){: #guardedstruct-sub_field-conditional_field-sub_field-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-sub_field-conditional_field-sub_field-field-default){: #guardedstruct-sub_field-conditional_field-sub_field-field-default } | `any` | | |
+| [`derives`](#guardedstruct-sub_field-conditional_field-sub_field-field-derives){: #guardedstruct-sub_field-conditional_field-sub_field-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-sub_field-conditional_field-sub_field-field-derive){: #guardedstruct-sub_field-conditional_field-sub_field-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-sub_field-conditional_field-sub_field-field-validator){: #guardedstruct-sub_field-conditional_field-sub_field-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-sub_field-conditional_field-sub_field-field-auto){: #guardedstruct-sub_field-conditional_field-sub_field-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-sub_field-conditional_field-sub_field-field-from){: #guardedstruct-sub_field-conditional_field-sub_field-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-sub_field-conditional_field-sub_field-field-on){: #guardedstruct-sub_field-conditional_field-sub_field-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-sub_field-conditional_field-sub_field-field-domain){: #guardedstruct-sub_field-conditional_field-sub_field-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-sub_field-conditional_field-sub_field-field-struct){: #guardedstruct-sub_field-conditional_field-sub_field-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-sub_field-conditional_field-sub_field-field-structs){: #guardedstruct-sub_field-conditional_field-sub_field-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-sub_field-conditional_field-sub_field-field-hint){: #guardedstruct-sub_field-conditional_field-sub_field-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-sub_field-conditional_field-sub_field-field-priority){: #guardedstruct-sub_field-conditional_field-sub_field-field-priority } | `boolean` | | |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+### guardedstruct.sub_field.sub_field
+```elixir
+sub_field name, type
+```
+
+
+
+
+### Nested DSLs
+ * [field](#guardedstruct-sub_field-sub_field-field)
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-sub_field-sub_field-name){: #guardedstruct-sub_field-sub_field-name .spark-required} | `atom` | | |
+| [`type`](#guardedstruct-sub_field-sub_field-type){: #guardedstruct-sub_field-sub_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-sub_field-sub_field-enforce){: #guardedstruct-sub_field-sub_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-sub_field-sub_field-default){: #guardedstruct-sub_field-sub_field-default } | `any` | | |
+| [`derives`](#guardedstruct-sub_field-sub_field-derives){: #guardedstruct-sub_field-sub_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-sub_field-sub_field-derive){: #guardedstruct-sub_field-sub_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-sub_field-sub_field-validator){: #guardedstruct-sub_field-sub_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-sub_field-sub_field-auto){: #guardedstruct-sub_field-sub_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-sub_field-sub_field-from){: #guardedstruct-sub_field-sub_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-sub_field-sub_field-on){: #guardedstruct-sub_field-sub_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-sub_field-sub_field-domain){: #guardedstruct-sub_field-sub_field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-sub_field-sub_field-struct){: #guardedstruct-sub_field-sub_field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-sub_field-sub_field-structs){: #guardedstruct-sub_field-sub_field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-sub_field-sub_field-hint){: #guardedstruct-sub_field-sub_field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-sub_field-sub_field-priority){: #guardedstruct-sub_field-sub_field-priority } | `boolean` | | |
+| [`error`](#guardedstruct-sub_field-sub_field-error){: #guardedstruct-sub_field-sub_field-error } | `boolean` | | |
+| [`authorized_fields`](#guardedstruct-sub_field-sub_field-authorized_fields){: #guardedstruct-sub_field-sub_field-authorized_fields } | `boolean` | | |
+| [`main_validator`](#guardedstruct-sub_field-sub_field-main_validator){: #guardedstruct-sub_field-sub_field-main_validator } | `{atom, atom}` | | |
+
+
+### guardedstruct.sub_field.sub_field.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-sub_field-sub_field-field-name){: #guardedstruct-sub_field-sub_field-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-sub_field-sub_field-field-type){: #guardedstruct-sub_field-sub_field-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-sub_field-sub_field-field-enforce){: #guardedstruct-sub_field-sub_field-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-sub_field-sub_field-field-default){: #guardedstruct-sub_field-sub_field-field-default } | `any` | | |
+| [`derives`](#guardedstruct-sub_field-sub_field-field-derives){: #guardedstruct-sub_field-sub_field-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-sub_field-sub_field-field-derive){: #guardedstruct-sub_field-sub_field-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-sub_field-sub_field-field-validator){: #guardedstruct-sub_field-sub_field-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-sub_field-sub_field-field-auto){: #guardedstruct-sub_field-sub_field-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-sub_field-sub_field-field-from){: #guardedstruct-sub_field-sub_field-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-sub_field-sub_field-field-on){: #guardedstruct-sub_field-sub_field-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-sub_field-sub_field-field-domain){: #guardedstruct-sub_field-sub_field-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-sub_field-sub_field-field-struct){: #guardedstruct-sub_field-sub_field-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-sub_field-sub_field-field-structs){: #guardedstruct-sub_field-sub_field-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-sub_field-sub_field-field-hint){: #guardedstruct-sub_field-sub_field-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-sub_field-sub_field-field-priority){: #guardedstruct-sub_field-sub_field-field-priority } | `boolean` | | |
+
+
+
+
+
+
+
+
+
+
+### guardedstruct.sub_field.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-sub_field-field-name){: #guardedstruct-sub_field-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-sub_field-field-type){: #guardedstruct-sub_field-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-sub_field-field-enforce){: #guardedstruct-sub_field-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-sub_field-field-default){: #guardedstruct-sub_field-field-default } | `any` | | |
+| [`derives`](#guardedstruct-sub_field-field-derives){: #guardedstruct-sub_field-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-sub_field-field-derive){: #guardedstruct-sub_field-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-sub_field-field-validator){: #guardedstruct-sub_field-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-sub_field-field-auto){: #guardedstruct-sub_field-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-sub_field-field-from){: #guardedstruct-sub_field-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-sub_field-field-on){: #guardedstruct-sub_field-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-sub_field-field-domain){: #guardedstruct-sub_field-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-sub_field-field-struct){: #guardedstruct-sub_field-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-sub_field-field-structs){: #guardedstruct-sub_field-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-sub_field-field-hint){: #guardedstruct-sub_field-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-sub_field-field-priority){: #guardedstruct-sub_field-field-priority } | `boolean` | | |
+
+
+
+
+
+
+
+
+
+
+### guardedstruct.conditional_field
+```elixir
+conditional_field name, type
+```
+
+
+
+
+### Nested DSLs
+ * [sub_field](#guardedstruct-conditional_field-sub_field)
+ * conditional_field
+ * field
+ * sub_field
+ * field
+ * sub_field
+ * field
+ * field
+ * [conditional_field](#guardedstruct-conditional_field-conditional_field)
+ * field
+ * sub_field
+ * field
+ * [field](#guardedstruct-conditional_field-field)
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-name){: #guardedstruct-conditional_field-name .spark-required} | `atom` | | |
+| [`type`](#guardedstruct-conditional_field-type){: #guardedstruct-conditional_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-enforce){: #guardedstruct-conditional_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-default){: #guardedstruct-conditional_field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-derives){: #guardedstruct-conditional_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-derive){: #guardedstruct-conditional_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-validator){: #guardedstruct-conditional_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-auto){: #guardedstruct-conditional_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-from){: #guardedstruct-conditional_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-on){: #guardedstruct-conditional_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-domain){: #guardedstruct-conditional_field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-struct){: #guardedstruct-conditional_field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-structs){: #guardedstruct-conditional_field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-hint){: #guardedstruct-conditional_field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-priority){: #guardedstruct-conditional_field-priority } | `boolean` | | |
+
+
+### guardedstruct.conditional_field.sub_field
+```elixir
+sub_field name, type
+```
+
+
+
+
+### Nested DSLs
+ * [conditional_field](#guardedstruct-conditional_field-sub_field-conditional_field)
+ * field
+ * sub_field
+ * field
+ * [sub_field](#guardedstruct-conditional_field-sub_field-sub_field)
+ * field
+ * [field](#guardedstruct-conditional_field-sub_field-field)
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-sub_field-name){: #guardedstruct-conditional_field-sub_field-name .spark-required} | `atom` | | |
+| [`type`](#guardedstruct-conditional_field-sub_field-type){: #guardedstruct-conditional_field-sub_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-sub_field-enforce){: #guardedstruct-conditional_field-sub_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-sub_field-default){: #guardedstruct-conditional_field-sub_field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-sub_field-derives){: #guardedstruct-conditional_field-sub_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-sub_field-derive){: #guardedstruct-conditional_field-sub_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-sub_field-validator){: #guardedstruct-conditional_field-sub_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-sub_field-auto){: #guardedstruct-conditional_field-sub_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-sub_field-from){: #guardedstruct-conditional_field-sub_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-sub_field-on){: #guardedstruct-conditional_field-sub_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-sub_field-domain){: #guardedstruct-conditional_field-sub_field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-sub_field-struct){: #guardedstruct-conditional_field-sub_field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-sub_field-structs){: #guardedstruct-conditional_field-sub_field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-sub_field-hint){: #guardedstruct-conditional_field-sub_field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-sub_field-priority){: #guardedstruct-conditional_field-sub_field-priority } | `boolean` | | |
+| [`error`](#guardedstruct-conditional_field-sub_field-error){: #guardedstruct-conditional_field-sub_field-error } | `boolean` | | |
+| [`authorized_fields`](#guardedstruct-conditional_field-sub_field-authorized_fields){: #guardedstruct-conditional_field-sub_field-authorized_fields } | `boolean` | | |
+| [`main_validator`](#guardedstruct-conditional_field-sub_field-main_validator){: #guardedstruct-conditional_field-sub_field-main_validator } | `{atom, atom}` | | |
+
+
+### guardedstruct.conditional_field.sub_field.conditional_field
+```elixir
+conditional_field name, type
+```
+
+
+
+
+### Nested DSLs
+ * [field](#guardedstruct-conditional_field-sub_field-conditional_field-field)
+ * [sub_field](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field)
+ * field
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-sub_field-conditional_field-name){: #guardedstruct-conditional_field-sub_field-conditional_field-name .spark-required} | `atom` | | |
+| [`type`](#guardedstruct-conditional_field-sub_field-conditional_field-type){: #guardedstruct-conditional_field-sub_field-conditional_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-sub_field-conditional_field-enforce){: #guardedstruct-conditional_field-sub_field-conditional_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-sub_field-conditional_field-default){: #guardedstruct-conditional_field-sub_field-conditional_field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-sub_field-conditional_field-derives){: #guardedstruct-conditional_field-sub_field-conditional_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-sub_field-conditional_field-derive){: #guardedstruct-conditional_field-sub_field-conditional_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-sub_field-conditional_field-validator){: #guardedstruct-conditional_field-sub_field-conditional_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-sub_field-conditional_field-auto){: #guardedstruct-conditional_field-sub_field-conditional_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-sub_field-conditional_field-from){: #guardedstruct-conditional_field-sub_field-conditional_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-sub_field-conditional_field-on){: #guardedstruct-conditional_field-sub_field-conditional_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-sub_field-conditional_field-domain){: #guardedstruct-conditional_field-sub_field-conditional_field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-sub_field-conditional_field-struct){: #guardedstruct-conditional_field-sub_field-conditional_field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-sub_field-conditional_field-structs){: #guardedstruct-conditional_field-sub_field-conditional_field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-sub_field-conditional_field-hint){: #guardedstruct-conditional_field-sub_field-conditional_field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-sub_field-conditional_field-priority){: #guardedstruct-conditional_field-sub_field-conditional_field-priority } | `boolean` | | |
+
+
+### guardedstruct.conditional_field.sub_field.conditional_field.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-sub_field-conditional_field-field-name){: #guardedstruct-conditional_field-sub_field-conditional_field-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-conditional_field-sub_field-conditional_field-field-type){: #guardedstruct-conditional_field-sub_field-conditional_field-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-sub_field-conditional_field-field-enforce){: #guardedstruct-conditional_field-sub_field-conditional_field-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-sub_field-conditional_field-field-default){: #guardedstruct-conditional_field-sub_field-conditional_field-field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-sub_field-conditional_field-field-derives){: #guardedstruct-conditional_field-sub_field-conditional_field-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-sub_field-conditional_field-field-derive){: #guardedstruct-conditional_field-sub_field-conditional_field-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-sub_field-conditional_field-field-validator){: #guardedstruct-conditional_field-sub_field-conditional_field-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-sub_field-conditional_field-field-auto){: #guardedstruct-conditional_field-sub_field-conditional_field-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-sub_field-conditional_field-field-from){: #guardedstruct-conditional_field-sub_field-conditional_field-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-sub_field-conditional_field-field-on){: #guardedstruct-conditional_field-sub_field-conditional_field-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-sub_field-conditional_field-field-domain){: #guardedstruct-conditional_field-sub_field-conditional_field-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-sub_field-conditional_field-field-struct){: #guardedstruct-conditional_field-sub_field-conditional_field-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-sub_field-conditional_field-field-structs){: #guardedstruct-conditional_field-sub_field-conditional_field-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-sub_field-conditional_field-field-hint){: #guardedstruct-conditional_field-sub_field-conditional_field-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-sub_field-conditional_field-field-priority){: #guardedstruct-conditional_field-sub_field-conditional_field-field-priority } | `boolean` | | |
+
+
+
+
+
+
+### guardedstruct.conditional_field.sub_field.conditional_field.sub_field
+```elixir
+sub_field name, type
+```
+
+
+
+
+### Nested DSLs
+ * [field](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field)
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-name){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-name .spark-required} | `atom` | | |
+| [`type`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-type){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-enforce){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-default){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-derives){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-derive){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-validator){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-auto){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-from){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-on){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-domain){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-struct){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-structs){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-hint){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-priority){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-priority } | `boolean` | | |
+| [`error`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-error){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-error } | `boolean` | | |
+| [`authorized_fields`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-authorized_fields){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-authorized_fields } | `boolean` | | |
+| [`main_validator`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-main_validator){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-main_validator } | `{atom, atom}` | | |
+
+
+### guardedstruct.conditional_field.sub_field.conditional_field.sub_field.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-name){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-type){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-enforce){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-default){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-derives){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-derive){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-validator){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-auto){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-from){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-on){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-domain){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-struct){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-structs){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-hint){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-priority){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-priority } | `boolean` | | |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+### guardedstruct.conditional_field.sub_field.sub_field
+```elixir
+sub_field name, type
+```
+
+
+
+
+### Nested DSLs
+ * [field](#guardedstruct-conditional_field-sub_field-sub_field-field)
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-sub_field-sub_field-name){: #guardedstruct-conditional_field-sub_field-sub_field-name .spark-required} | `atom` | | |
+| [`type`](#guardedstruct-conditional_field-sub_field-sub_field-type){: #guardedstruct-conditional_field-sub_field-sub_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-sub_field-sub_field-enforce){: #guardedstruct-conditional_field-sub_field-sub_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-sub_field-sub_field-default){: #guardedstruct-conditional_field-sub_field-sub_field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-sub_field-sub_field-derives){: #guardedstruct-conditional_field-sub_field-sub_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-sub_field-sub_field-derive){: #guardedstruct-conditional_field-sub_field-sub_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-sub_field-sub_field-validator){: #guardedstruct-conditional_field-sub_field-sub_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-sub_field-sub_field-auto){: #guardedstruct-conditional_field-sub_field-sub_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-sub_field-sub_field-from){: #guardedstruct-conditional_field-sub_field-sub_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-sub_field-sub_field-on){: #guardedstruct-conditional_field-sub_field-sub_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-sub_field-sub_field-domain){: #guardedstruct-conditional_field-sub_field-sub_field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-sub_field-sub_field-struct){: #guardedstruct-conditional_field-sub_field-sub_field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-sub_field-sub_field-structs){: #guardedstruct-conditional_field-sub_field-sub_field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-sub_field-sub_field-hint){: #guardedstruct-conditional_field-sub_field-sub_field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-sub_field-sub_field-priority){: #guardedstruct-conditional_field-sub_field-sub_field-priority } | `boolean` | | |
+| [`error`](#guardedstruct-conditional_field-sub_field-sub_field-error){: #guardedstruct-conditional_field-sub_field-sub_field-error } | `boolean` | | |
+| [`authorized_fields`](#guardedstruct-conditional_field-sub_field-sub_field-authorized_fields){: #guardedstruct-conditional_field-sub_field-sub_field-authorized_fields } | `boolean` | | |
+| [`main_validator`](#guardedstruct-conditional_field-sub_field-sub_field-main_validator){: #guardedstruct-conditional_field-sub_field-sub_field-main_validator } | `{atom, atom}` | | |
+
+
+### guardedstruct.conditional_field.sub_field.sub_field.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-sub_field-sub_field-field-name){: #guardedstruct-conditional_field-sub_field-sub_field-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-conditional_field-sub_field-sub_field-field-type){: #guardedstruct-conditional_field-sub_field-sub_field-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-sub_field-sub_field-field-enforce){: #guardedstruct-conditional_field-sub_field-sub_field-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-sub_field-sub_field-field-default){: #guardedstruct-conditional_field-sub_field-sub_field-field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-sub_field-sub_field-field-derives){: #guardedstruct-conditional_field-sub_field-sub_field-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-sub_field-sub_field-field-derive){: #guardedstruct-conditional_field-sub_field-sub_field-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-sub_field-sub_field-field-validator){: #guardedstruct-conditional_field-sub_field-sub_field-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-sub_field-sub_field-field-auto){: #guardedstruct-conditional_field-sub_field-sub_field-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-sub_field-sub_field-field-from){: #guardedstruct-conditional_field-sub_field-sub_field-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-sub_field-sub_field-field-on){: #guardedstruct-conditional_field-sub_field-sub_field-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-sub_field-sub_field-field-domain){: #guardedstruct-conditional_field-sub_field-sub_field-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-sub_field-sub_field-field-struct){: #guardedstruct-conditional_field-sub_field-sub_field-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-sub_field-sub_field-field-structs){: #guardedstruct-conditional_field-sub_field-sub_field-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-sub_field-sub_field-field-hint){: #guardedstruct-conditional_field-sub_field-sub_field-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-sub_field-sub_field-field-priority){: #guardedstruct-conditional_field-sub_field-sub_field-field-priority } | `boolean` | | |
+
+
+
+
+
+
+
+
+
+
+### guardedstruct.conditional_field.sub_field.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-sub_field-field-name){: #guardedstruct-conditional_field-sub_field-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-conditional_field-sub_field-field-type){: #guardedstruct-conditional_field-sub_field-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-sub_field-field-enforce){: #guardedstruct-conditional_field-sub_field-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-sub_field-field-default){: #guardedstruct-conditional_field-sub_field-field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-sub_field-field-derives){: #guardedstruct-conditional_field-sub_field-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-sub_field-field-derive){: #guardedstruct-conditional_field-sub_field-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-sub_field-field-validator){: #guardedstruct-conditional_field-sub_field-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-sub_field-field-auto){: #guardedstruct-conditional_field-sub_field-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-sub_field-field-from){: #guardedstruct-conditional_field-sub_field-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-sub_field-field-on){: #guardedstruct-conditional_field-sub_field-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-sub_field-field-domain){: #guardedstruct-conditional_field-sub_field-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-sub_field-field-struct){: #guardedstruct-conditional_field-sub_field-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-sub_field-field-structs){: #guardedstruct-conditional_field-sub_field-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-sub_field-field-hint){: #guardedstruct-conditional_field-sub_field-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-sub_field-field-priority){: #guardedstruct-conditional_field-sub_field-field-priority } | `boolean` | | |
+
+
+
+
+
+
+
+
+
+
+### guardedstruct.conditional_field.conditional_field
+```elixir
+conditional_field name, type
+```
+
+
+
+
+### Nested DSLs
+ * [field](#guardedstruct-conditional_field-conditional_field-field)
+ * [sub_field](#guardedstruct-conditional_field-conditional_field-sub_field)
+ * field
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-conditional_field-name){: #guardedstruct-conditional_field-conditional_field-name .spark-required} | `atom` | | |
+| [`type`](#guardedstruct-conditional_field-conditional_field-type){: #guardedstruct-conditional_field-conditional_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-conditional_field-enforce){: #guardedstruct-conditional_field-conditional_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-conditional_field-default){: #guardedstruct-conditional_field-conditional_field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-conditional_field-derives){: #guardedstruct-conditional_field-conditional_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-conditional_field-derive){: #guardedstruct-conditional_field-conditional_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-conditional_field-validator){: #guardedstruct-conditional_field-conditional_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-conditional_field-auto){: #guardedstruct-conditional_field-conditional_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-conditional_field-from){: #guardedstruct-conditional_field-conditional_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-conditional_field-on){: #guardedstruct-conditional_field-conditional_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-conditional_field-domain){: #guardedstruct-conditional_field-conditional_field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-conditional_field-struct){: #guardedstruct-conditional_field-conditional_field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-conditional_field-structs){: #guardedstruct-conditional_field-conditional_field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-conditional_field-hint){: #guardedstruct-conditional_field-conditional_field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-conditional_field-priority){: #guardedstruct-conditional_field-conditional_field-priority } | `boolean` | | |
+
+
+### guardedstruct.conditional_field.conditional_field.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-conditional_field-field-name){: #guardedstruct-conditional_field-conditional_field-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-conditional_field-conditional_field-field-type){: #guardedstruct-conditional_field-conditional_field-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-conditional_field-field-enforce){: #guardedstruct-conditional_field-conditional_field-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-conditional_field-field-default){: #guardedstruct-conditional_field-conditional_field-field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-conditional_field-field-derives){: #guardedstruct-conditional_field-conditional_field-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-conditional_field-field-derive){: #guardedstruct-conditional_field-conditional_field-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-conditional_field-field-validator){: #guardedstruct-conditional_field-conditional_field-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-conditional_field-field-auto){: #guardedstruct-conditional_field-conditional_field-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-conditional_field-field-from){: #guardedstruct-conditional_field-conditional_field-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-conditional_field-field-on){: #guardedstruct-conditional_field-conditional_field-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-conditional_field-field-domain){: #guardedstruct-conditional_field-conditional_field-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-conditional_field-field-struct){: #guardedstruct-conditional_field-conditional_field-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-conditional_field-field-structs){: #guardedstruct-conditional_field-conditional_field-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-conditional_field-field-hint){: #guardedstruct-conditional_field-conditional_field-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-conditional_field-field-priority){: #guardedstruct-conditional_field-conditional_field-field-priority } | `boolean` | | |
+
+
+
+
+
+
+### guardedstruct.conditional_field.conditional_field.sub_field
+```elixir
+sub_field name, type
+```
+
+
+
+
+### Nested DSLs
+ * [field](#guardedstruct-conditional_field-conditional_field-sub_field-field)
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-conditional_field-sub_field-name){: #guardedstruct-conditional_field-conditional_field-sub_field-name .spark-required} | `atom` | | |
+| [`type`](#guardedstruct-conditional_field-conditional_field-sub_field-type){: #guardedstruct-conditional_field-conditional_field-sub_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-conditional_field-sub_field-enforce){: #guardedstruct-conditional_field-conditional_field-sub_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-conditional_field-sub_field-default){: #guardedstruct-conditional_field-conditional_field-sub_field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-conditional_field-sub_field-derives){: #guardedstruct-conditional_field-conditional_field-sub_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-conditional_field-sub_field-derive){: #guardedstruct-conditional_field-conditional_field-sub_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-conditional_field-sub_field-validator){: #guardedstruct-conditional_field-conditional_field-sub_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-conditional_field-sub_field-auto){: #guardedstruct-conditional_field-conditional_field-sub_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-conditional_field-sub_field-from){: #guardedstruct-conditional_field-conditional_field-sub_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-conditional_field-sub_field-on){: #guardedstruct-conditional_field-conditional_field-sub_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-conditional_field-sub_field-domain){: #guardedstruct-conditional_field-conditional_field-sub_field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-conditional_field-sub_field-struct){: #guardedstruct-conditional_field-conditional_field-sub_field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-conditional_field-sub_field-structs){: #guardedstruct-conditional_field-conditional_field-sub_field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-conditional_field-sub_field-hint){: #guardedstruct-conditional_field-conditional_field-sub_field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-conditional_field-sub_field-priority){: #guardedstruct-conditional_field-conditional_field-sub_field-priority } | `boolean` | | |
+| [`error`](#guardedstruct-conditional_field-conditional_field-sub_field-error){: #guardedstruct-conditional_field-conditional_field-sub_field-error } | `boolean` | | |
+| [`authorized_fields`](#guardedstruct-conditional_field-conditional_field-sub_field-authorized_fields){: #guardedstruct-conditional_field-conditional_field-sub_field-authorized_fields } | `boolean` | | |
+| [`main_validator`](#guardedstruct-conditional_field-conditional_field-sub_field-main_validator){: #guardedstruct-conditional_field-conditional_field-sub_field-main_validator } | `{atom, atom}` | | |
+
+
+### guardedstruct.conditional_field.conditional_field.sub_field.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-conditional_field-sub_field-field-name){: #guardedstruct-conditional_field-conditional_field-sub_field-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-conditional_field-conditional_field-sub_field-field-type){: #guardedstruct-conditional_field-conditional_field-sub_field-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-conditional_field-sub_field-field-enforce){: #guardedstruct-conditional_field-conditional_field-sub_field-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-conditional_field-sub_field-field-default){: #guardedstruct-conditional_field-conditional_field-sub_field-field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-conditional_field-sub_field-field-derives){: #guardedstruct-conditional_field-conditional_field-sub_field-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-conditional_field-sub_field-field-derive){: #guardedstruct-conditional_field-conditional_field-sub_field-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-conditional_field-sub_field-field-validator){: #guardedstruct-conditional_field-conditional_field-sub_field-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-conditional_field-sub_field-field-auto){: #guardedstruct-conditional_field-conditional_field-sub_field-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-conditional_field-sub_field-field-from){: #guardedstruct-conditional_field-conditional_field-sub_field-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-conditional_field-sub_field-field-on){: #guardedstruct-conditional_field-conditional_field-sub_field-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-conditional_field-sub_field-field-domain){: #guardedstruct-conditional_field-conditional_field-sub_field-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-conditional_field-sub_field-field-struct){: #guardedstruct-conditional_field-conditional_field-sub_field-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-conditional_field-sub_field-field-structs){: #guardedstruct-conditional_field-conditional_field-sub_field-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-conditional_field-sub_field-field-hint){: #guardedstruct-conditional_field-conditional_field-sub_field-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-conditional_field-sub_field-field-priority){: #guardedstruct-conditional_field-conditional_field-sub_field-field-priority } | `boolean` | | |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+### guardedstruct.conditional_field.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-field-name){: #guardedstruct-conditional_field-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-conditional_field-field-type){: #guardedstruct-conditional_field-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-field-enforce){: #guardedstruct-conditional_field-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-field-default){: #guardedstruct-conditional_field-field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-field-derives){: #guardedstruct-conditional_field-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-field-derive){: #guardedstruct-conditional_field-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-field-validator){: #guardedstruct-conditional_field-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-field-auto){: #guardedstruct-conditional_field-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-field-from){: #guardedstruct-conditional_field-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-field-on){: #guardedstruct-conditional_field-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-field-domain){: #guardedstruct-conditional_field-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-field-struct){: #guardedstruct-conditional_field-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-field-structs){: #guardedstruct-conditional_field-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-field-hint){: #guardedstruct-conditional_field-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-field-priority){: #guardedstruct-conditional_field-field-priority } | `boolean` | | |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/documentation/dsls/DSL-GuardedStruct.Derive.Extension.md b/documentation/dsls/DSL-GuardedStruct.Derive.Extension.md
new file mode 100644
index 0000000..72f3f2d
--- /dev/null
+++ b/documentation/dsls/DSL-GuardedStruct.Derive.Extension.md
@@ -0,0 +1,95 @@
+
+# GuardedStruct.Derive.Extension
+
+
+
+## derives
+Container for custom validator and sanitizer ops.
+
+#### Example
+
+defmodule MyApp.Derives do
+use GuardedStruct.Derive.Extension
+
+derives do
+validator :slug, fn input ->
+is_binary(input) and Regex.match?(~r/^[a-z0-9-]+$/, input)
+end
+
+sanitizer :slugify, fn input when is_binary(input) ->
+input |> String.downcase() |> String.replace(~r/[^a-z0-9-]+/u, "-")
+end
+end
+end
+
+
+### Nested DSLs
+ * [validator](#derives-validator)
+ * [sanitizer](#derives-sanitizer)
+
+
+
+
+
+### derives.validator
+```elixir
+validator name, fun
+```
+
+
+Declare a custom validator op callable as `validate()` from
+any GuardedStruct module that has this extension wired in.
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#derives-validator-name){: #derives-validator-name .spark-required} | `atom` | | Op name. Used as `validate()` in derive strings. |
+| [`fun`](#derives-validator-fun){: #derives-validator-fun .spark-required} | `any` | | Single-arg function. Return value semantics: * `true` — input passes * `false` — input fails (default error message) * `{:error, field, action, message}` — explicit error * any other value — used as the validated (coerced) output |
+
+
+
+
+
+
+
+### derives.sanitizer
+```elixir
+sanitizer name, fun
+```
+
+
+Declare a custom sanitizer op callable as `sanitize()`. Runs
+before validation in the derive pipeline; the return value replaces
+the input.
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#derives-sanitizer-name){: #derives-sanitizer-name .spark-required} | `atom` | | Op name. Used as `sanitize()` in derive strings. |
+| [`fun`](#derives-sanitizer-fun){: #derives-sanitizer-fun .spark-required} | `any` | | Single-arg function. Return value replaces the input. |
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/documentation/dsls/DSL-GuardedStruct.md b/documentation/dsls/DSL-GuardedStruct.md
new file mode 100644
index 0000000..c965116
--- /dev/null
+++ b/documentation/dsls/DSL-GuardedStruct.md
@@ -0,0 +1,1156 @@
+
+# GuardedStruct
+
+
+
+## guardedstruct
+
+
+### Nested DSLs
+ * [field](#guardedstruct-field)
+ * [virtual_field](#guardedstruct-virtual_field)
+ * [dynamic_field](#guardedstruct-dynamic_field)
+ * [sub_field](#guardedstruct-sub_field)
+ * conditional_field
+ * field
+ * sub_field
+ * field
+ * sub_field
+ * field
+ * field
+ * [conditional_field](#guardedstruct-conditional_field)
+ * sub_field
+ * conditional_field
+ * field
+ * sub_field
+ * field
+ * sub_field
+ * field
+ * field
+ * conditional_field
+ * field
+ * sub_field
+ * field
+ * field
+
+
+
+
+
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-enforce){: #guardedstruct-enforce } | `boolean` | `false` | |
+| [`opaque`](#guardedstruct-opaque){: #guardedstruct-opaque } | `boolean` | `false` | |
+| [`module`](#guardedstruct-module){: #guardedstruct-module } | `any` | | |
+| [`error`](#guardedstruct-error){: #guardedstruct-error } | `boolean` | `false` | |
+| [`authorized_fields`](#guardedstruct-authorized_fields){: #guardedstruct-authorized_fields } | `boolean` | `false` | |
+| [`main_validator`](#guardedstruct-main_validator){: #guardedstruct-main_validator } | `{atom, atom}` | | |
+| [`validate_derive`](#guardedstruct-validate_derive){: #guardedstruct-validate_derive } | `atom \| list(atom)` | | |
+| [`sanitize_derive`](#guardedstruct-sanitize_derive){: #guardedstruct-sanitize_derive } | `atom \| list(atom)` | | |
+| [`json`](#guardedstruct-json){: #guardedstruct-json } | `boolean` | `false` | When `true`, derives a JSON encoder. Uses `Jason.Encoder` if `:jason` is in the user's deps; otherwise falls back to the built-in `JSON.Encoder` on Elixir 1.18+. No-op if neither is available. |
+| [`auto_wire`](#guardedstruct-auto_wire){: #guardedstruct-auto_wire } | `boolean` | `false` | Only effective inside the `GuardedStruct.AshResource` extension. When `true`, injects `GuardedStruct.AshResource.Change` into the resource's top-level `changes` section so every `:create` and `:update` action automatically runs the GuardedStruct pipeline. Equivalent to writing `changes do change GuardedStruct.AshResource.Change end` by hand. No-op outside the Ash extension. |
+| [`atomic`](#guardedstruct-atomic){: #guardedstruct-atomic } | `boolean` | `false` | Opt into atomic-SQL mode. When `true`, the `VerifyAtomic` verifier rejects at compile time any field whose derive ops, per-field `validator:`, `auto:`, or top-level `main_validator/1` callback can't translate to atomic SQL (e.g. `validate(email)` which does DNS lookup, custom MFAs, custom Derive.Extension ops). Errors point at the offending field with the exact reason. Default `false` keeps the imperative path. |
+
+
+
+### guardedstruct.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-field-name){: #guardedstruct-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-field-type){: #guardedstruct-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-field-enforce){: #guardedstruct-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-field-default){: #guardedstruct-field-default } | `any` | | |
+| [`derives`](#guardedstruct-field-derives){: #guardedstruct-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-field-derive){: #guardedstruct-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-field-validator){: #guardedstruct-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-field-auto){: #guardedstruct-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-field-from){: #guardedstruct-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-field-on){: #guardedstruct-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-field-domain){: #guardedstruct-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-field-struct){: #guardedstruct-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-field-structs){: #guardedstruct-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-field-hint){: #guardedstruct-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-field-priority){: #guardedstruct-field-priority } | `boolean` | | |
+
+
+
+
+
+
+### guardedstruct.virtual_field
+```elixir
+virtual_field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-virtual_field-name){: #guardedstruct-virtual_field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-virtual_field-type){: #guardedstruct-virtual_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-virtual_field-enforce){: #guardedstruct-virtual_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-virtual_field-default){: #guardedstruct-virtual_field-default } | `any` | | |
+| [`derives`](#guardedstruct-virtual_field-derives){: #guardedstruct-virtual_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-virtual_field-derive){: #guardedstruct-virtual_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-virtual_field-validator){: #guardedstruct-virtual_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-virtual_field-auto){: #guardedstruct-virtual_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-virtual_field-from){: #guardedstruct-virtual_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-virtual_field-on){: #guardedstruct-virtual_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-virtual_field-domain){: #guardedstruct-virtual_field-domain } | `String.t` | | |
+| [`hint`](#guardedstruct-virtual_field-hint){: #guardedstruct-virtual_field-hint } | `String.t` | | |
+
+
+
+
+
+
+### guardedstruct.dynamic_field
+```elixir
+dynamic_field name
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-dynamic_field-name){: #guardedstruct-dynamic_field-name .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`type`](#guardedstruct-dynamic_field-type){: #guardedstruct-dynamic_field-type } | `any` | `{:map, [], []}` | |
+| [`enforce`](#guardedstruct-dynamic_field-enforce){: #guardedstruct-dynamic_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-dynamic_field-default){: #guardedstruct-dynamic_field-default } | `any` | `{:%{}, [], []}` | |
+| [`derives`](#guardedstruct-dynamic_field-derives){: #guardedstruct-dynamic_field-derives } | `String.t` | `"validate(map)"` | |
+| [`derive`](#guardedstruct-dynamic_field-derive){: #guardedstruct-dynamic_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-dynamic_field-validator){: #guardedstruct-dynamic_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-dynamic_field-auto){: #guardedstruct-dynamic_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-dynamic_field-from){: #guardedstruct-dynamic_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-dynamic_field-on){: #guardedstruct-dynamic_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-dynamic_field-domain){: #guardedstruct-dynamic_field-domain } | `String.t` | | |
+| [`hint`](#guardedstruct-dynamic_field-hint){: #guardedstruct-dynamic_field-hint } | `String.t` | | |
+
+
+
+
+
+
+### guardedstruct.sub_field
+```elixir
+sub_field name, type
+```
+
+
+
+
+### Nested DSLs
+ * [conditional_field](#guardedstruct-sub_field-conditional_field)
+ * field
+ * sub_field
+ * field
+ * [sub_field](#guardedstruct-sub_field-sub_field)
+ * field
+ * [field](#guardedstruct-sub_field-field)
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-sub_field-name){: #guardedstruct-sub_field-name .spark-required} | `atom` | | |
+| [`type`](#guardedstruct-sub_field-type){: #guardedstruct-sub_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-sub_field-enforce){: #guardedstruct-sub_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-sub_field-default){: #guardedstruct-sub_field-default } | `any` | | |
+| [`derives`](#guardedstruct-sub_field-derives){: #guardedstruct-sub_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-sub_field-derive){: #guardedstruct-sub_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-sub_field-validator){: #guardedstruct-sub_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-sub_field-auto){: #guardedstruct-sub_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-sub_field-from){: #guardedstruct-sub_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-sub_field-on){: #guardedstruct-sub_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-sub_field-domain){: #guardedstruct-sub_field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-sub_field-struct){: #guardedstruct-sub_field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-sub_field-structs){: #guardedstruct-sub_field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-sub_field-hint){: #guardedstruct-sub_field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-sub_field-priority){: #guardedstruct-sub_field-priority } | `boolean` | | |
+| [`error`](#guardedstruct-sub_field-error){: #guardedstruct-sub_field-error } | `boolean` | | |
+| [`authorized_fields`](#guardedstruct-sub_field-authorized_fields){: #guardedstruct-sub_field-authorized_fields } | `boolean` | | |
+| [`main_validator`](#guardedstruct-sub_field-main_validator){: #guardedstruct-sub_field-main_validator } | `{atom, atom}` | | |
+
+
+### guardedstruct.sub_field.conditional_field
+```elixir
+conditional_field name, type
+```
+
+
+
+
+### Nested DSLs
+ * [field](#guardedstruct-sub_field-conditional_field-field)
+ * [sub_field](#guardedstruct-sub_field-conditional_field-sub_field)
+ * field
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-sub_field-conditional_field-name){: #guardedstruct-sub_field-conditional_field-name .spark-required} | `atom` | | |
+| [`type`](#guardedstruct-sub_field-conditional_field-type){: #guardedstruct-sub_field-conditional_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-sub_field-conditional_field-enforce){: #guardedstruct-sub_field-conditional_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-sub_field-conditional_field-default){: #guardedstruct-sub_field-conditional_field-default } | `any` | | |
+| [`derives`](#guardedstruct-sub_field-conditional_field-derives){: #guardedstruct-sub_field-conditional_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-sub_field-conditional_field-derive){: #guardedstruct-sub_field-conditional_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-sub_field-conditional_field-validator){: #guardedstruct-sub_field-conditional_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-sub_field-conditional_field-auto){: #guardedstruct-sub_field-conditional_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-sub_field-conditional_field-from){: #guardedstruct-sub_field-conditional_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-sub_field-conditional_field-on){: #guardedstruct-sub_field-conditional_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-sub_field-conditional_field-domain){: #guardedstruct-sub_field-conditional_field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-sub_field-conditional_field-struct){: #guardedstruct-sub_field-conditional_field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-sub_field-conditional_field-structs){: #guardedstruct-sub_field-conditional_field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-sub_field-conditional_field-hint){: #guardedstruct-sub_field-conditional_field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-sub_field-conditional_field-priority){: #guardedstruct-sub_field-conditional_field-priority } | `boolean` | | |
+
+
+### guardedstruct.sub_field.conditional_field.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-sub_field-conditional_field-field-name){: #guardedstruct-sub_field-conditional_field-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-sub_field-conditional_field-field-type){: #guardedstruct-sub_field-conditional_field-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-sub_field-conditional_field-field-enforce){: #guardedstruct-sub_field-conditional_field-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-sub_field-conditional_field-field-default){: #guardedstruct-sub_field-conditional_field-field-default } | `any` | | |
+| [`derives`](#guardedstruct-sub_field-conditional_field-field-derives){: #guardedstruct-sub_field-conditional_field-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-sub_field-conditional_field-field-derive){: #guardedstruct-sub_field-conditional_field-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-sub_field-conditional_field-field-validator){: #guardedstruct-sub_field-conditional_field-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-sub_field-conditional_field-field-auto){: #guardedstruct-sub_field-conditional_field-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-sub_field-conditional_field-field-from){: #guardedstruct-sub_field-conditional_field-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-sub_field-conditional_field-field-on){: #guardedstruct-sub_field-conditional_field-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-sub_field-conditional_field-field-domain){: #guardedstruct-sub_field-conditional_field-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-sub_field-conditional_field-field-struct){: #guardedstruct-sub_field-conditional_field-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-sub_field-conditional_field-field-structs){: #guardedstruct-sub_field-conditional_field-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-sub_field-conditional_field-field-hint){: #guardedstruct-sub_field-conditional_field-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-sub_field-conditional_field-field-priority){: #guardedstruct-sub_field-conditional_field-field-priority } | `boolean` | | |
+
+
+
+
+
+
+### guardedstruct.sub_field.conditional_field.sub_field
+```elixir
+sub_field name, type
+```
+
+
+
+
+### Nested DSLs
+ * [field](#guardedstruct-sub_field-conditional_field-sub_field-field)
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-sub_field-conditional_field-sub_field-name){: #guardedstruct-sub_field-conditional_field-sub_field-name .spark-required} | `atom` | | |
+| [`type`](#guardedstruct-sub_field-conditional_field-sub_field-type){: #guardedstruct-sub_field-conditional_field-sub_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-sub_field-conditional_field-sub_field-enforce){: #guardedstruct-sub_field-conditional_field-sub_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-sub_field-conditional_field-sub_field-default){: #guardedstruct-sub_field-conditional_field-sub_field-default } | `any` | | |
+| [`derives`](#guardedstruct-sub_field-conditional_field-sub_field-derives){: #guardedstruct-sub_field-conditional_field-sub_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-sub_field-conditional_field-sub_field-derive){: #guardedstruct-sub_field-conditional_field-sub_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-sub_field-conditional_field-sub_field-validator){: #guardedstruct-sub_field-conditional_field-sub_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-sub_field-conditional_field-sub_field-auto){: #guardedstruct-sub_field-conditional_field-sub_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-sub_field-conditional_field-sub_field-from){: #guardedstruct-sub_field-conditional_field-sub_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-sub_field-conditional_field-sub_field-on){: #guardedstruct-sub_field-conditional_field-sub_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-sub_field-conditional_field-sub_field-domain){: #guardedstruct-sub_field-conditional_field-sub_field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-sub_field-conditional_field-sub_field-struct){: #guardedstruct-sub_field-conditional_field-sub_field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-sub_field-conditional_field-sub_field-structs){: #guardedstruct-sub_field-conditional_field-sub_field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-sub_field-conditional_field-sub_field-hint){: #guardedstruct-sub_field-conditional_field-sub_field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-sub_field-conditional_field-sub_field-priority){: #guardedstruct-sub_field-conditional_field-sub_field-priority } | `boolean` | | |
+| [`error`](#guardedstruct-sub_field-conditional_field-sub_field-error){: #guardedstruct-sub_field-conditional_field-sub_field-error } | `boolean` | | |
+| [`authorized_fields`](#guardedstruct-sub_field-conditional_field-sub_field-authorized_fields){: #guardedstruct-sub_field-conditional_field-sub_field-authorized_fields } | `boolean` | | |
+| [`main_validator`](#guardedstruct-sub_field-conditional_field-sub_field-main_validator){: #guardedstruct-sub_field-conditional_field-sub_field-main_validator } | `{atom, atom}` | | |
+
+
+### guardedstruct.sub_field.conditional_field.sub_field.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-sub_field-conditional_field-sub_field-field-name){: #guardedstruct-sub_field-conditional_field-sub_field-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-sub_field-conditional_field-sub_field-field-type){: #guardedstruct-sub_field-conditional_field-sub_field-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-sub_field-conditional_field-sub_field-field-enforce){: #guardedstruct-sub_field-conditional_field-sub_field-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-sub_field-conditional_field-sub_field-field-default){: #guardedstruct-sub_field-conditional_field-sub_field-field-default } | `any` | | |
+| [`derives`](#guardedstruct-sub_field-conditional_field-sub_field-field-derives){: #guardedstruct-sub_field-conditional_field-sub_field-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-sub_field-conditional_field-sub_field-field-derive){: #guardedstruct-sub_field-conditional_field-sub_field-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-sub_field-conditional_field-sub_field-field-validator){: #guardedstruct-sub_field-conditional_field-sub_field-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-sub_field-conditional_field-sub_field-field-auto){: #guardedstruct-sub_field-conditional_field-sub_field-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-sub_field-conditional_field-sub_field-field-from){: #guardedstruct-sub_field-conditional_field-sub_field-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-sub_field-conditional_field-sub_field-field-on){: #guardedstruct-sub_field-conditional_field-sub_field-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-sub_field-conditional_field-sub_field-field-domain){: #guardedstruct-sub_field-conditional_field-sub_field-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-sub_field-conditional_field-sub_field-field-struct){: #guardedstruct-sub_field-conditional_field-sub_field-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-sub_field-conditional_field-sub_field-field-structs){: #guardedstruct-sub_field-conditional_field-sub_field-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-sub_field-conditional_field-sub_field-field-hint){: #guardedstruct-sub_field-conditional_field-sub_field-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-sub_field-conditional_field-sub_field-field-priority){: #guardedstruct-sub_field-conditional_field-sub_field-field-priority } | `boolean` | | |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+### guardedstruct.sub_field.sub_field
+```elixir
+sub_field name, type
+```
+
+
+
+
+### Nested DSLs
+ * [field](#guardedstruct-sub_field-sub_field-field)
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-sub_field-sub_field-name){: #guardedstruct-sub_field-sub_field-name .spark-required} | `atom` | | |
+| [`type`](#guardedstruct-sub_field-sub_field-type){: #guardedstruct-sub_field-sub_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-sub_field-sub_field-enforce){: #guardedstruct-sub_field-sub_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-sub_field-sub_field-default){: #guardedstruct-sub_field-sub_field-default } | `any` | | |
+| [`derives`](#guardedstruct-sub_field-sub_field-derives){: #guardedstruct-sub_field-sub_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-sub_field-sub_field-derive){: #guardedstruct-sub_field-sub_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-sub_field-sub_field-validator){: #guardedstruct-sub_field-sub_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-sub_field-sub_field-auto){: #guardedstruct-sub_field-sub_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-sub_field-sub_field-from){: #guardedstruct-sub_field-sub_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-sub_field-sub_field-on){: #guardedstruct-sub_field-sub_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-sub_field-sub_field-domain){: #guardedstruct-sub_field-sub_field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-sub_field-sub_field-struct){: #guardedstruct-sub_field-sub_field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-sub_field-sub_field-structs){: #guardedstruct-sub_field-sub_field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-sub_field-sub_field-hint){: #guardedstruct-sub_field-sub_field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-sub_field-sub_field-priority){: #guardedstruct-sub_field-sub_field-priority } | `boolean` | | |
+| [`error`](#guardedstruct-sub_field-sub_field-error){: #guardedstruct-sub_field-sub_field-error } | `boolean` | | |
+| [`authorized_fields`](#guardedstruct-sub_field-sub_field-authorized_fields){: #guardedstruct-sub_field-sub_field-authorized_fields } | `boolean` | | |
+| [`main_validator`](#guardedstruct-sub_field-sub_field-main_validator){: #guardedstruct-sub_field-sub_field-main_validator } | `{atom, atom}` | | |
+
+
+### guardedstruct.sub_field.sub_field.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-sub_field-sub_field-field-name){: #guardedstruct-sub_field-sub_field-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-sub_field-sub_field-field-type){: #guardedstruct-sub_field-sub_field-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-sub_field-sub_field-field-enforce){: #guardedstruct-sub_field-sub_field-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-sub_field-sub_field-field-default){: #guardedstruct-sub_field-sub_field-field-default } | `any` | | |
+| [`derives`](#guardedstruct-sub_field-sub_field-field-derives){: #guardedstruct-sub_field-sub_field-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-sub_field-sub_field-field-derive){: #guardedstruct-sub_field-sub_field-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-sub_field-sub_field-field-validator){: #guardedstruct-sub_field-sub_field-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-sub_field-sub_field-field-auto){: #guardedstruct-sub_field-sub_field-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-sub_field-sub_field-field-from){: #guardedstruct-sub_field-sub_field-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-sub_field-sub_field-field-on){: #guardedstruct-sub_field-sub_field-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-sub_field-sub_field-field-domain){: #guardedstruct-sub_field-sub_field-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-sub_field-sub_field-field-struct){: #guardedstruct-sub_field-sub_field-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-sub_field-sub_field-field-structs){: #guardedstruct-sub_field-sub_field-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-sub_field-sub_field-field-hint){: #guardedstruct-sub_field-sub_field-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-sub_field-sub_field-field-priority){: #guardedstruct-sub_field-sub_field-field-priority } | `boolean` | | |
+
+
+
+
+
+
+
+
+
+
+### guardedstruct.sub_field.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-sub_field-field-name){: #guardedstruct-sub_field-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-sub_field-field-type){: #guardedstruct-sub_field-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-sub_field-field-enforce){: #guardedstruct-sub_field-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-sub_field-field-default){: #guardedstruct-sub_field-field-default } | `any` | | |
+| [`derives`](#guardedstruct-sub_field-field-derives){: #guardedstruct-sub_field-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-sub_field-field-derive){: #guardedstruct-sub_field-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-sub_field-field-validator){: #guardedstruct-sub_field-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-sub_field-field-auto){: #guardedstruct-sub_field-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-sub_field-field-from){: #guardedstruct-sub_field-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-sub_field-field-on){: #guardedstruct-sub_field-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-sub_field-field-domain){: #guardedstruct-sub_field-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-sub_field-field-struct){: #guardedstruct-sub_field-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-sub_field-field-structs){: #guardedstruct-sub_field-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-sub_field-field-hint){: #guardedstruct-sub_field-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-sub_field-field-priority){: #guardedstruct-sub_field-field-priority } | `boolean` | | |
+
+
+
+
+
+
+
+
+
+
+### guardedstruct.conditional_field
+```elixir
+conditional_field name, type
+```
+
+
+
+
+### Nested DSLs
+ * [sub_field](#guardedstruct-conditional_field-sub_field)
+ * conditional_field
+ * field
+ * sub_field
+ * field
+ * sub_field
+ * field
+ * field
+ * [conditional_field](#guardedstruct-conditional_field-conditional_field)
+ * field
+ * sub_field
+ * field
+ * [field](#guardedstruct-conditional_field-field)
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-name){: #guardedstruct-conditional_field-name .spark-required} | `atom` | | |
+| [`type`](#guardedstruct-conditional_field-type){: #guardedstruct-conditional_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-enforce){: #guardedstruct-conditional_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-default){: #guardedstruct-conditional_field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-derives){: #guardedstruct-conditional_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-derive){: #guardedstruct-conditional_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-validator){: #guardedstruct-conditional_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-auto){: #guardedstruct-conditional_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-from){: #guardedstruct-conditional_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-on){: #guardedstruct-conditional_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-domain){: #guardedstruct-conditional_field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-struct){: #guardedstruct-conditional_field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-structs){: #guardedstruct-conditional_field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-hint){: #guardedstruct-conditional_field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-priority){: #guardedstruct-conditional_field-priority } | `boolean` | | |
+
+
+### guardedstruct.conditional_field.sub_field
+```elixir
+sub_field name, type
+```
+
+
+
+
+### Nested DSLs
+ * [conditional_field](#guardedstruct-conditional_field-sub_field-conditional_field)
+ * field
+ * sub_field
+ * field
+ * [sub_field](#guardedstruct-conditional_field-sub_field-sub_field)
+ * field
+ * [field](#guardedstruct-conditional_field-sub_field-field)
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-sub_field-name){: #guardedstruct-conditional_field-sub_field-name .spark-required} | `atom` | | |
+| [`type`](#guardedstruct-conditional_field-sub_field-type){: #guardedstruct-conditional_field-sub_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-sub_field-enforce){: #guardedstruct-conditional_field-sub_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-sub_field-default){: #guardedstruct-conditional_field-sub_field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-sub_field-derives){: #guardedstruct-conditional_field-sub_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-sub_field-derive){: #guardedstruct-conditional_field-sub_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-sub_field-validator){: #guardedstruct-conditional_field-sub_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-sub_field-auto){: #guardedstruct-conditional_field-sub_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-sub_field-from){: #guardedstruct-conditional_field-sub_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-sub_field-on){: #guardedstruct-conditional_field-sub_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-sub_field-domain){: #guardedstruct-conditional_field-sub_field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-sub_field-struct){: #guardedstruct-conditional_field-sub_field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-sub_field-structs){: #guardedstruct-conditional_field-sub_field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-sub_field-hint){: #guardedstruct-conditional_field-sub_field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-sub_field-priority){: #guardedstruct-conditional_field-sub_field-priority } | `boolean` | | |
+| [`error`](#guardedstruct-conditional_field-sub_field-error){: #guardedstruct-conditional_field-sub_field-error } | `boolean` | | |
+| [`authorized_fields`](#guardedstruct-conditional_field-sub_field-authorized_fields){: #guardedstruct-conditional_field-sub_field-authorized_fields } | `boolean` | | |
+| [`main_validator`](#guardedstruct-conditional_field-sub_field-main_validator){: #guardedstruct-conditional_field-sub_field-main_validator } | `{atom, atom}` | | |
+
+
+### guardedstruct.conditional_field.sub_field.conditional_field
+```elixir
+conditional_field name, type
+```
+
+
+
+
+### Nested DSLs
+ * [field](#guardedstruct-conditional_field-sub_field-conditional_field-field)
+ * [sub_field](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field)
+ * field
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-sub_field-conditional_field-name){: #guardedstruct-conditional_field-sub_field-conditional_field-name .spark-required} | `atom` | | |
+| [`type`](#guardedstruct-conditional_field-sub_field-conditional_field-type){: #guardedstruct-conditional_field-sub_field-conditional_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-sub_field-conditional_field-enforce){: #guardedstruct-conditional_field-sub_field-conditional_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-sub_field-conditional_field-default){: #guardedstruct-conditional_field-sub_field-conditional_field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-sub_field-conditional_field-derives){: #guardedstruct-conditional_field-sub_field-conditional_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-sub_field-conditional_field-derive){: #guardedstruct-conditional_field-sub_field-conditional_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-sub_field-conditional_field-validator){: #guardedstruct-conditional_field-sub_field-conditional_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-sub_field-conditional_field-auto){: #guardedstruct-conditional_field-sub_field-conditional_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-sub_field-conditional_field-from){: #guardedstruct-conditional_field-sub_field-conditional_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-sub_field-conditional_field-on){: #guardedstruct-conditional_field-sub_field-conditional_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-sub_field-conditional_field-domain){: #guardedstruct-conditional_field-sub_field-conditional_field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-sub_field-conditional_field-struct){: #guardedstruct-conditional_field-sub_field-conditional_field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-sub_field-conditional_field-structs){: #guardedstruct-conditional_field-sub_field-conditional_field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-sub_field-conditional_field-hint){: #guardedstruct-conditional_field-sub_field-conditional_field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-sub_field-conditional_field-priority){: #guardedstruct-conditional_field-sub_field-conditional_field-priority } | `boolean` | | |
+
+
+### guardedstruct.conditional_field.sub_field.conditional_field.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-sub_field-conditional_field-field-name){: #guardedstruct-conditional_field-sub_field-conditional_field-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-conditional_field-sub_field-conditional_field-field-type){: #guardedstruct-conditional_field-sub_field-conditional_field-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-sub_field-conditional_field-field-enforce){: #guardedstruct-conditional_field-sub_field-conditional_field-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-sub_field-conditional_field-field-default){: #guardedstruct-conditional_field-sub_field-conditional_field-field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-sub_field-conditional_field-field-derives){: #guardedstruct-conditional_field-sub_field-conditional_field-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-sub_field-conditional_field-field-derive){: #guardedstruct-conditional_field-sub_field-conditional_field-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-sub_field-conditional_field-field-validator){: #guardedstruct-conditional_field-sub_field-conditional_field-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-sub_field-conditional_field-field-auto){: #guardedstruct-conditional_field-sub_field-conditional_field-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-sub_field-conditional_field-field-from){: #guardedstruct-conditional_field-sub_field-conditional_field-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-sub_field-conditional_field-field-on){: #guardedstruct-conditional_field-sub_field-conditional_field-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-sub_field-conditional_field-field-domain){: #guardedstruct-conditional_field-sub_field-conditional_field-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-sub_field-conditional_field-field-struct){: #guardedstruct-conditional_field-sub_field-conditional_field-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-sub_field-conditional_field-field-structs){: #guardedstruct-conditional_field-sub_field-conditional_field-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-sub_field-conditional_field-field-hint){: #guardedstruct-conditional_field-sub_field-conditional_field-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-sub_field-conditional_field-field-priority){: #guardedstruct-conditional_field-sub_field-conditional_field-field-priority } | `boolean` | | |
+
+
+
+
+
+
+### guardedstruct.conditional_field.sub_field.conditional_field.sub_field
+```elixir
+sub_field name, type
+```
+
+
+
+
+### Nested DSLs
+ * [field](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field)
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-name){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-name .spark-required} | `atom` | | |
+| [`type`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-type){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-enforce){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-default){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-derives){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-derive){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-validator){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-auto){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-from){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-on){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-domain){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-struct){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-structs){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-hint){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-priority){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-priority } | `boolean` | | |
+| [`error`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-error){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-error } | `boolean` | | |
+| [`authorized_fields`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-authorized_fields){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-authorized_fields } | `boolean` | | |
+| [`main_validator`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-main_validator){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-main_validator } | `{atom, atom}` | | |
+
+
+### guardedstruct.conditional_field.sub_field.conditional_field.sub_field.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-name){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-type){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-enforce){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-default){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-derives){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-derive){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-validator){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-auto){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-from){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-on){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-domain){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-struct){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-structs){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-hint){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-priority){: #guardedstruct-conditional_field-sub_field-conditional_field-sub_field-field-priority } | `boolean` | | |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+### guardedstruct.conditional_field.sub_field.sub_field
+```elixir
+sub_field name, type
+```
+
+
+
+
+### Nested DSLs
+ * [field](#guardedstruct-conditional_field-sub_field-sub_field-field)
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-sub_field-sub_field-name){: #guardedstruct-conditional_field-sub_field-sub_field-name .spark-required} | `atom` | | |
+| [`type`](#guardedstruct-conditional_field-sub_field-sub_field-type){: #guardedstruct-conditional_field-sub_field-sub_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-sub_field-sub_field-enforce){: #guardedstruct-conditional_field-sub_field-sub_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-sub_field-sub_field-default){: #guardedstruct-conditional_field-sub_field-sub_field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-sub_field-sub_field-derives){: #guardedstruct-conditional_field-sub_field-sub_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-sub_field-sub_field-derive){: #guardedstruct-conditional_field-sub_field-sub_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-sub_field-sub_field-validator){: #guardedstruct-conditional_field-sub_field-sub_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-sub_field-sub_field-auto){: #guardedstruct-conditional_field-sub_field-sub_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-sub_field-sub_field-from){: #guardedstruct-conditional_field-sub_field-sub_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-sub_field-sub_field-on){: #guardedstruct-conditional_field-sub_field-sub_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-sub_field-sub_field-domain){: #guardedstruct-conditional_field-sub_field-sub_field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-sub_field-sub_field-struct){: #guardedstruct-conditional_field-sub_field-sub_field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-sub_field-sub_field-structs){: #guardedstruct-conditional_field-sub_field-sub_field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-sub_field-sub_field-hint){: #guardedstruct-conditional_field-sub_field-sub_field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-sub_field-sub_field-priority){: #guardedstruct-conditional_field-sub_field-sub_field-priority } | `boolean` | | |
+| [`error`](#guardedstruct-conditional_field-sub_field-sub_field-error){: #guardedstruct-conditional_field-sub_field-sub_field-error } | `boolean` | | |
+| [`authorized_fields`](#guardedstruct-conditional_field-sub_field-sub_field-authorized_fields){: #guardedstruct-conditional_field-sub_field-sub_field-authorized_fields } | `boolean` | | |
+| [`main_validator`](#guardedstruct-conditional_field-sub_field-sub_field-main_validator){: #guardedstruct-conditional_field-sub_field-sub_field-main_validator } | `{atom, atom}` | | |
+
+
+### guardedstruct.conditional_field.sub_field.sub_field.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-sub_field-sub_field-field-name){: #guardedstruct-conditional_field-sub_field-sub_field-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-conditional_field-sub_field-sub_field-field-type){: #guardedstruct-conditional_field-sub_field-sub_field-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-sub_field-sub_field-field-enforce){: #guardedstruct-conditional_field-sub_field-sub_field-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-sub_field-sub_field-field-default){: #guardedstruct-conditional_field-sub_field-sub_field-field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-sub_field-sub_field-field-derives){: #guardedstruct-conditional_field-sub_field-sub_field-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-sub_field-sub_field-field-derive){: #guardedstruct-conditional_field-sub_field-sub_field-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-sub_field-sub_field-field-validator){: #guardedstruct-conditional_field-sub_field-sub_field-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-sub_field-sub_field-field-auto){: #guardedstruct-conditional_field-sub_field-sub_field-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-sub_field-sub_field-field-from){: #guardedstruct-conditional_field-sub_field-sub_field-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-sub_field-sub_field-field-on){: #guardedstruct-conditional_field-sub_field-sub_field-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-sub_field-sub_field-field-domain){: #guardedstruct-conditional_field-sub_field-sub_field-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-sub_field-sub_field-field-struct){: #guardedstruct-conditional_field-sub_field-sub_field-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-sub_field-sub_field-field-structs){: #guardedstruct-conditional_field-sub_field-sub_field-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-sub_field-sub_field-field-hint){: #guardedstruct-conditional_field-sub_field-sub_field-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-sub_field-sub_field-field-priority){: #guardedstruct-conditional_field-sub_field-sub_field-field-priority } | `boolean` | | |
+
+
+
+
+
+
+
+
+
+
+### guardedstruct.conditional_field.sub_field.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-sub_field-field-name){: #guardedstruct-conditional_field-sub_field-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-conditional_field-sub_field-field-type){: #guardedstruct-conditional_field-sub_field-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-sub_field-field-enforce){: #guardedstruct-conditional_field-sub_field-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-sub_field-field-default){: #guardedstruct-conditional_field-sub_field-field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-sub_field-field-derives){: #guardedstruct-conditional_field-sub_field-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-sub_field-field-derive){: #guardedstruct-conditional_field-sub_field-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-sub_field-field-validator){: #guardedstruct-conditional_field-sub_field-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-sub_field-field-auto){: #guardedstruct-conditional_field-sub_field-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-sub_field-field-from){: #guardedstruct-conditional_field-sub_field-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-sub_field-field-on){: #guardedstruct-conditional_field-sub_field-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-sub_field-field-domain){: #guardedstruct-conditional_field-sub_field-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-sub_field-field-struct){: #guardedstruct-conditional_field-sub_field-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-sub_field-field-structs){: #guardedstruct-conditional_field-sub_field-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-sub_field-field-hint){: #guardedstruct-conditional_field-sub_field-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-sub_field-field-priority){: #guardedstruct-conditional_field-sub_field-field-priority } | `boolean` | | |
+
+
+
+
+
+
+
+
+
+
+### guardedstruct.conditional_field.conditional_field
+```elixir
+conditional_field name, type
+```
+
+
+
+
+### Nested DSLs
+ * [field](#guardedstruct-conditional_field-conditional_field-field)
+ * [sub_field](#guardedstruct-conditional_field-conditional_field-sub_field)
+ * field
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-conditional_field-name){: #guardedstruct-conditional_field-conditional_field-name .spark-required} | `atom` | | |
+| [`type`](#guardedstruct-conditional_field-conditional_field-type){: #guardedstruct-conditional_field-conditional_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-conditional_field-enforce){: #guardedstruct-conditional_field-conditional_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-conditional_field-default){: #guardedstruct-conditional_field-conditional_field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-conditional_field-derives){: #guardedstruct-conditional_field-conditional_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-conditional_field-derive){: #guardedstruct-conditional_field-conditional_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-conditional_field-validator){: #guardedstruct-conditional_field-conditional_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-conditional_field-auto){: #guardedstruct-conditional_field-conditional_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-conditional_field-from){: #guardedstruct-conditional_field-conditional_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-conditional_field-on){: #guardedstruct-conditional_field-conditional_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-conditional_field-domain){: #guardedstruct-conditional_field-conditional_field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-conditional_field-struct){: #guardedstruct-conditional_field-conditional_field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-conditional_field-structs){: #guardedstruct-conditional_field-conditional_field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-conditional_field-hint){: #guardedstruct-conditional_field-conditional_field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-conditional_field-priority){: #guardedstruct-conditional_field-conditional_field-priority } | `boolean` | | |
+
+
+### guardedstruct.conditional_field.conditional_field.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-conditional_field-field-name){: #guardedstruct-conditional_field-conditional_field-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-conditional_field-conditional_field-field-type){: #guardedstruct-conditional_field-conditional_field-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-conditional_field-field-enforce){: #guardedstruct-conditional_field-conditional_field-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-conditional_field-field-default){: #guardedstruct-conditional_field-conditional_field-field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-conditional_field-field-derives){: #guardedstruct-conditional_field-conditional_field-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-conditional_field-field-derive){: #guardedstruct-conditional_field-conditional_field-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-conditional_field-field-validator){: #guardedstruct-conditional_field-conditional_field-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-conditional_field-field-auto){: #guardedstruct-conditional_field-conditional_field-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-conditional_field-field-from){: #guardedstruct-conditional_field-conditional_field-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-conditional_field-field-on){: #guardedstruct-conditional_field-conditional_field-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-conditional_field-field-domain){: #guardedstruct-conditional_field-conditional_field-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-conditional_field-field-struct){: #guardedstruct-conditional_field-conditional_field-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-conditional_field-field-structs){: #guardedstruct-conditional_field-conditional_field-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-conditional_field-field-hint){: #guardedstruct-conditional_field-conditional_field-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-conditional_field-field-priority){: #guardedstruct-conditional_field-conditional_field-field-priority } | `boolean` | | |
+
+
+
+
+
+
+### guardedstruct.conditional_field.conditional_field.sub_field
+```elixir
+sub_field name, type
+```
+
+
+
+
+### Nested DSLs
+ * [field](#guardedstruct-conditional_field-conditional_field-sub_field-field)
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-conditional_field-sub_field-name){: #guardedstruct-conditional_field-conditional_field-sub_field-name .spark-required} | `atom` | | |
+| [`type`](#guardedstruct-conditional_field-conditional_field-sub_field-type){: #guardedstruct-conditional_field-conditional_field-sub_field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-conditional_field-sub_field-enforce){: #guardedstruct-conditional_field-conditional_field-sub_field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-conditional_field-sub_field-default){: #guardedstruct-conditional_field-conditional_field-sub_field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-conditional_field-sub_field-derives){: #guardedstruct-conditional_field-conditional_field-sub_field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-conditional_field-sub_field-derive){: #guardedstruct-conditional_field-conditional_field-sub_field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-conditional_field-sub_field-validator){: #guardedstruct-conditional_field-conditional_field-sub_field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-conditional_field-sub_field-auto){: #guardedstruct-conditional_field-conditional_field-sub_field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-conditional_field-sub_field-from){: #guardedstruct-conditional_field-conditional_field-sub_field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-conditional_field-sub_field-on){: #guardedstruct-conditional_field-conditional_field-sub_field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-conditional_field-sub_field-domain){: #guardedstruct-conditional_field-conditional_field-sub_field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-conditional_field-sub_field-struct){: #guardedstruct-conditional_field-conditional_field-sub_field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-conditional_field-sub_field-structs){: #guardedstruct-conditional_field-conditional_field-sub_field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-conditional_field-sub_field-hint){: #guardedstruct-conditional_field-conditional_field-sub_field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-conditional_field-sub_field-priority){: #guardedstruct-conditional_field-conditional_field-sub_field-priority } | `boolean` | | |
+| [`error`](#guardedstruct-conditional_field-conditional_field-sub_field-error){: #guardedstruct-conditional_field-conditional_field-sub_field-error } | `boolean` | | |
+| [`authorized_fields`](#guardedstruct-conditional_field-conditional_field-sub_field-authorized_fields){: #guardedstruct-conditional_field-conditional_field-sub_field-authorized_fields } | `boolean` | | |
+| [`main_validator`](#guardedstruct-conditional_field-conditional_field-sub_field-main_validator){: #guardedstruct-conditional_field-conditional_field-sub_field-main_validator } | `{atom, atom}` | | |
+
+
+### guardedstruct.conditional_field.conditional_field.sub_field.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-conditional_field-sub_field-field-name){: #guardedstruct-conditional_field-conditional_field-sub_field-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-conditional_field-conditional_field-sub_field-field-type){: #guardedstruct-conditional_field-conditional_field-sub_field-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-conditional_field-sub_field-field-enforce){: #guardedstruct-conditional_field-conditional_field-sub_field-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-conditional_field-sub_field-field-default){: #guardedstruct-conditional_field-conditional_field-sub_field-field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-conditional_field-sub_field-field-derives){: #guardedstruct-conditional_field-conditional_field-sub_field-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-conditional_field-sub_field-field-derive){: #guardedstruct-conditional_field-conditional_field-sub_field-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-conditional_field-sub_field-field-validator){: #guardedstruct-conditional_field-conditional_field-sub_field-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-conditional_field-sub_field-field-auto){: #guardedstruct-conditional_field-conditional_field-sub_field-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-conditional_field-sub_field-field-from){: #guardedstruct-conditional_field-conditional_field-sub_field-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-conditional_field-sub_field-field-on){: #guardedstruct-conditional_field-conditional_field-sub_field-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-conditional_field-sub_field-field-domain){: #guardedstruct-conditional_field-conditional_field-sub_field-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-conditional_field-sub_field-field-struct){: #guardedstruct-conditional_field-conditional_field-sub_field-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-conditional_field-sub_field-field-structs){: #guardedstruct-conditional_field-conditional_field-sub_field-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-conditional_field-sub_field-field-hint){: #guardedstruct-conditional_field-conditional_field-sub_field-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-conditional_field-sub_field-field-priority){: #guardedstruct-conditional_field-conditional_field-sub_field-field-priority } | `boolean` | | |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+### guardedstruct.conditional_field.field
+```elixir
+field name, type
+```
+
+
+
+
+
+
+
+
+### Arguments
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`name`](#guardedstruct-conditional_field-field-name){: #guardedstruct-conditional_field-field-name .spark-required} | `any` | | |
+| [`type`](#guardedstruct-conditional_field-field-type){: #guardedstruct-conditional_field-field-type .spark-required} | `any` | | |
+### Options
+
+| Name | Type | Default | Docs |
+|------|------|---------|------|
+| [`enforce`](#guardedstruct-conditional_field-field-enforce){: #guardedstruct-conditional_field-field-enforce } | `boolean` | | |
+| [`default`](#guardedstruct-conditional_field-field-default){: #guardedstruct-conditional_field-field-default } | `any` | | |
+| [`derives`](#guardedstruct-conditional_field-field-derives){: #guardedstruct-conditional_field-field-derives } | `String.t` | | |
+| [`derive`](#guardedstruct-conditional_field-field-derive){: #guardedstruct-conditional_field-field-derive } | `String.t` | | |
+| [`validator`](#guardedstruct-conditional_field-field-validator){: #guardedstruct-conditional_field-field-validator } | `{atom, atom}` | | |
+| [`auto`](#guardedstruct-conditional_field-field-auto){: #guardedstruct-conditional_field-field-auto } | `{atom, atom} \| {atom, atom, any}` | | |
+| [`from`](#guardedstruct-conditional_field-field-from){: #guardedstruct-conditional_field-field-from } | `String.t` | | |
+| [`on`](#guardedstruct-conditional_field-field-on){: #guardedstruct-conditional_field-field-on } | `String.t` | | |
+| [`domain`](#guardedstruct-conditional_field-field-domain){: #guardedstruct-conditional_field-field-domain } | `String.t` | | |
+| [`struct`](#guardedstruct-conditional_field-field-struct){: #guardedstruct-conditional_field-field-struct } | `atom` | | |
+| [`structs`](#guardedstruct-conditional_field-field-structs){: #guardedstruct-conditional_field-field-structs } | `atom \| boolean` | | |
+| [`hint`](#guardedstruct-conditional_field-field-hint){: #guardedstruct-conditional_field-field-hint } | `String.t` | | |
+| [`priority`](#guardedstruct-conditional_field-field-priority){: #guardedstruct-conditional_field-field-priority } | `boolean` | | |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/guidance/guarded-struct.livemd b/guidance/guarded-struct.livemd
index 3ecb77d..ccc8874 100644
--- a/guidance/guarded-struct.livemd
+++ b/guidance/guarded-struct.livemd
@@ -2,7 +2,7 @@
```elixir
Mix.install([
- {:guarded_struct, "~> 0.0.4"},
+ {:guarded_struct, "~> 0.1.0"},
{:html_sanitize_ex, "~> 1.4.3"},
# Optional dependencies, It’s recommended to create your own
@@ -14,6 +14,22 @@ Mix.install([
])
```
+## What's new in 0.1.0
+
+`v0.1.0` rewrites the macro core on Spark. **Existing 0.0.x code keeps working — every example below runs unchanged.** New features are demonstrated near the bottom of this notebook:
+
+| Feature | Section |
+|---|---|
+| Pattern-keyed maps (regex `field` names, closes #11) | _Pattern-keyed maps_ |
+| `virtual_field` (closes #5) | _Virtual fields_ |
+| `GuardedStruct.Validate` standalone API (closes #2) | _Standalone validation_ |
+| Erlang Record support (closes #6) | _Erlang Records_ |
+| Custom validators / sanitizers via Spark DSL | _Custom derive ops_ |
+| Splode error wrapping | _Splode errors_ |
+| Ash extension | _Ash integration_ |
+
+See [`CHANGELOG.md`](https://github.com/mishka-group/guarded_struct/blob/master/CHANGELOG.md) for the full list of changes.
+
## About
The creation of this macro will allow you to build `Structs` that provide you with a number of important options, including the following:
@@ -1420,3 +1436,449 @@ As you can see in the code above, you only need to give the macro the `structs:
##### Note:
> Using a list `conditional_field` in a nested list can create a logical bug for you if the list is not flattened, **Please test your builder before releasing to production**.
+
+## New features in 0.1.0
+
+The sections below are new in `v0.1.0`. They show features added on top of the existing API; everything above this point continues to work unchanged.
+
+## Pattern-keyed maps
+
+A `field` whose name is a regex declares a free-form map shape. The struct's `builder/1` returns a plain validated map. Useful for translations, headers, sharded data — anything where keys are runtime-unknown but values share a uniform shape.
+
+```elixir
+defmodule Shard do
+ use GuardedStruct
+ guardedstruct do
+ field :node, String.t(), enforce: true, derives: "sanitize(trim) validate(ipv4)"
+ end
+end
+
+defmodule ShardsMap do
+ use GuardedStruct
+ guardedstruct do
+ field ~r/^shard_\d+$/, struct(), struct: Shard, derives: "validate(map, not_empty)"
+ end
+end
+
+ShardsMap.builder(%{
+ "shard_1" => %{node: "10.0.0.1"},
+ "shard_2" => %{node: "10.0.0.2"}
+})
+# => {:ok, %{
+# "shard_1" => %Shard{node: "10.0.0.1"},
+# "shard_2" => %Shard{node: "10.0.0.2"}
+# }}
+```
+
+Keys stay as strings (atom-table-exhaustion safe by default). Mixing atom-keyed and regex-keyed `field`s in the same `guardedstruct` raises a compile-time error.
+
+## Virtual fields
+
+A `virtual_field` is validated through the full pipeline but excluded from the generated `defstruct`. Use it for input fields needed only by `main_validator/1`:
+
+```elixir
+defmodule Signup do
+ use GuardedStruct
+
+ guardedstruct do
+ field :email, String.t(), enforce: true, derives: "validate(email_r)"
+ field :password, String.t(), enforce: true, derives: "validate(string, min_len=8)"
+ virtual_field :password_confirm, String.t(), derives: "validate(string)"
+ end
+
+ def main_validator(attrs) do
+ if attrs[:password] == attrs[:password_confirm] do
+ {:ok, attrs}
+ else
+ {:error,
+ [%{field: :password_confirm, action: :match, message: "passwords don't match"}]}
+ end
+ end
+end
+
+Signup.builder(%{
+ email: "alice@x.com",
+ password: "longpassword",
+ password_confirm: "longpassword"
+})
+# => {:ok, %Signup{email: "alice@x.com", password: "longpassword"}}
+# Note: password_confirm is NOT on the struct — it was used by main_validator and dropped.
+```
+
+## Dynamic fields
+
+A `dynamic_field` is a free-form map field with **passthrough semantics** — whatever map you submit (string keys, atom keys, mixed, nested) round-trips byte-identical to `builder/1`'s output. No string-to-atom conversion of inner keys, ever. Defaults to `%{}`, `type :: map()`, `derives: "validate(map)"`.
+
+```elixir
+defmodule WithMetadata do
+ use GuardedStruct
+ guardedstruct do
+ field :name, String.t(), enforce: true
+ dynamic_field :metadata
+ end
+end
+
+WithMetadata.builder(%{name: "Alice", metadata: %{"role" => "admin", "tier" => 2}})
+# => {:ok, %WithMetadata{name: "Alice", metadata: %{"role" => "admin", "tier" => 2}}}
+# String keys stay as strings — no String.to_atom of user input.
+
+WithMetadata.builder(%{name: "Alice", metadata: %{:role => "admin", "tier" => 2}})
+# => {:ok, %WithMetadata{metadata: %{role: "admin", "tier" => 2}}}
+# Mixed keys round-trip exactly. This is atom-attack-safe by default.
+```
+
+Why it matters: parsing `%{"" => ...}` from JSON would otherwise create new atoms in the BEAM atom table (which is bounded). `dynamic_field` lets you accept arbitrary user maps without that risk.
+
+## `@derives` decorator
+
+Alternative to inline `derives:` — keeps field declarations short when the derive string gets long. The `@derives` attribute applies to the very NEXT field declaration (one-shot, like `@doc`).
+
+```elixir
+defmodule Article do
+ use GuardedStruct
+
+ guardedstruct do
+ @derives "sanitize(trim) validate(string, not_empty, max_len=200)"
+ field :title, String.t(), enforce: true
+
+ @derives "sanitize(trim, downcase) validate(string, max_len=80)"
+ field :slug, String.t(), enforce: true
+
+ @derives "validate(integer, min_len=0)"
+ field :views, integer(), default: 0
+ end
+end
+```
+
+Aliases: `@derive_rules` (same behavior) — both work, `@derives` is canonical. If both `@derives` (decorator) and `derives:` (inline option) are present on the same field, the inline option wins.
+
+## JSON encoding
+
+Set `json: true` on the section to auto-derive a JSON encoder. Uses `Jason.Encoder` if `:jason` is in your deps; falls back to the built-in `JSON.Encoder` on Elixir 1.18+; no-op if neither is available.
+
+```elixir
+defmodule Order do
+ use GuardedStruct
+
+ guardedstruct json: true do
+ field :id, String.t(), enforce: true
+ field :total, integer(), enforce: true
+ end
+end
+
+{:ok, order} = Order.builder(%{id: "abc", total: 99})
+Jason.encode!(order)
+# => ~s({"id":"abc","total":99})
+
+# On Elixir 1.18+ without Jason in deps:
+# JSON.encode!(order)
+```
+
+The encoder cascades to sub_field submodules — nested structs serialize recursively.
+
+## Introspection — `GuardedStruct.Info`
+
+Every generated module gets a rich set of introspection helpers via `GuardedStruct.Info`.
+
+```elixir
+defmodule User do
+ use GuardedStruct
+
+ guardedstruct enforce: true do
+ field :name, String.t()
+ field :email, String.t(), derives: "validate(email_r)"
+ virtual_field :password_confirm, String.t()
+ sub_field :address, struct() do
+ field :city, String.t()
+ end
+ end
+end
+
+# One call — full dump
+GuardedStruct.Info.describe(User)
+# => %{
+# module: User,
+# keys: [:name, :email, :address],
+# enforce_keys: [:name, :email, :address],
+# fields: [
+# %{name: :name, kind: :field, type: "String.t()", enforce?: true, ...},
+# %{name: :email, kind: :field, derive: "validate(email_r)", ...},
+# %{name: :address, kind: :sub_field, sub_module: User.Address, ...},
+# %{name: :password_confirm, kind: :virtual_field, enforce?: false, ...}
+# ],
+# options: %{enforce: true, json: false, atomic: false, ...}
+# }
+
+# Field-level lookups
+GuardedStruct.Info.field_kind(User, :email) #=> :field
+GuardedStruct.Info.enforce?(User, :email) #=> true
+GuardedStruct.Info.virtual?(User, :password_confirm) #=> true
+GuardedStruct.Info.field_derives(User, :email) #=> "validate(email_r)"
+GuardedStruct.Info.sub_module(User, :address) #=> User.Address
+
+# Collections by kind
+GuardedStruct.Info.sub_fields(User) #=> [:address]
+GuardedStruct.Info.virtual_fields(User) #=> [:password_confirm]
+```
+
+## Audit-log diffing — `GuardedStruct.Diff`
+
+```elixir
+{:ok, v1} = User.builder(%{name: "Alice", email: "alice@x.com"})
+{:ok, v2} = User.builder(%{name: "Alicia", email: "alice@x.com"})
+
+GuardedStruct.Diff.diff(v1, v2)
+# => %{name: {:changed, "Alice", "Alicia"}}
+
+GuardedStruct.Diff.apply(v1, %{name: {:changed, "Alice", "Alicia"}})
+# => %User{name: "Alicia", email: "alice@x.com", ...}
+
+GuardedStruct.Diff.equal?(v1, v2)
+# => false
+```
+
+Diffs are nested-struct-aware — only changed fields appear in the result map.
+
+## `example/0` helper
+
+Every generated module has an `example/0` function that returns a struct populated with declared defaults (and type-based placeholders for fields without defaults). Useful for REPL inspection, docs, fixture generation.
+
+```elixir
+defmodule Order do
+ use GuardedStruct
+ guardedstruct do
+ field :id, String.t(), default: "order-1"
+ field :total, integer(), default: 0
+ field :currency, String.t(), default: "USD"
+ end
+end
+
+Order.example()
+# => %Order{id: "order-1", total: 0, currency: "USD"}
+```
+
+## Telemetry
+
+Every top-level `builder/1` call emits three events:
+
+| Event | Payload | Metadata |
+|---|---|---|
+| `[:guarded_struct, :builder, :start]` | `%{system_time}` | `%{module}` |
+| `[:guarded_struct, :builder, :stop]` | `%{duration}` | `%{module, result: :ok | :error, error_count}` |
+| `[:guarded_struct, :builder, :exception]` | `%{duration}` | `%{module, kind, reason, stacktrace}` |
+
+Wire a handler in your application startup:
+
+```elixir
+:telemetry.attach(
+ "log-builds",
+ [:guarded_struct, :builder, :stop],
+ fn _e, %{duration: d}, %{module: m, result: r}, _ ->
+ Logger.info("#{inspect(m)} #{r} in #{System.convert_time_unit(d, :native, :microsecond)}µs")
+ end,
+ nil
+)
+```
+
+Only top-level builds emit — nested sub_field builds inherit; you see exactly one event per public `builder/1` call.
+
+## Standalone validation
+
+`GuardedStruct.Validate` exposes the schema without going through `builder/1`. Three tiers:
+
+```elixir
+# Tier 1 — ad-hoc op-string against a value, no module needed
+GuardedStruct.Validate.run("validate(string, max_len=80, email_r)", "alice@example.com")
+# => {:ok, "alice@example.com"}
+
+# Tier 2 — single named field of a module
+GuardedStruct.Validate.field(Signup, :email, "alice@x.com")
+# => {:ok, "alice@x.com"}
+
+# Tier 2 — with cross-field deps via context
+GuardedStruct.Validate.field(MyStruct, :owner_id, "u-123",
+ context: %{user_id: "u-123"}
+)
+
+# Tier 2 — isolated mode skips on:/domain: deps entirely
+GuardedStruct.Validate.field(MyStruct, :owner_id, "u-123", mode: :isolated)
+
+# Tier 3 — partial subset of fields (form-as-you-type, PATCH endpoints)
+GuardedStruct.Validate.partial(Signup, %{email: "alice@x.com", password: "longpassword"})
+# => {:ok, %{email: "alice@x.com", password: "longpassword"}}
+# No enforce_keys check — missing fields silently skipped.
+```
+
+## Erlang Records
+
+```elixir
+require Record
+Record.defrecord(:user_record, name: nil, age: nil)
+
+defmodule WithRecord do
+ use GuardedStruct
+ guardedstruct do
+ field(:user, :tuple, derive: "validate(record=user_record)")
+ end
+end
+
+rec = user_record(name: "Alice", age: 30)
+WithRecord.builder(%{user: rec})
+# => {:ok, %WithRecord{user: {:user_record, "Alice", 30}}}
+```
+
+The `record=tag` form checks that the input is a tagged tuple with the given tag. The bare `validate(record)` accepts any tagged tuple.
+
+## Custom derive ops
+
+Beyond the 50+ built-in validators and 11 sanitizers, you can register your own via a small Spark-native DSL. Declarations live inside a `derives do ... end` block:
+
+```elixir
+defmodule MyApp.Derives do
+ use GuardedStruct.Derive.Extension
+
+ derives do
+ validator :slug, fn input ->
+ is_binary(input) and Regex.match?(~r/^[a-z0-9-]+$/, input)
+ end
+
+ sanitizer :slugify, fn input when is_binary(input) ->
+ input
+ |> String.downcase()
+ |> String.replace(~r/[^a-z0-9-]+/u, "-")
+ end
+ end
+end
+
+# Register globally — config/config.exs
+# config :guarded_struct, derive_extensions: [MyApp.Derives]
+
+# OR register per-module — overrides global, with `:config` sentinel for merge
+defmodule Post do
+ use GuardedStruct, derive_extensions: [MyApp.Derives]
+
+ guardedstruct do
+ field :slug, String.t(), derives: "sanitize(slugify) validate(slug)"
+ end
+end
+```
+
+Per-module resolution rules:
+- `[A, B]` — these only; global is ignored
+- `[:config, A]` — global ++ [A] (global wins on op-name collisions)
+- `[A, :config]` — [A] ++ global (A wins on collisions)
+- `[A, :config, B]` — [A] ++ global ++ [B]
+
+A compile-time warning fires if a custom op-name shadows a built-in registered in `GuardedStruct.Derive.Registry` (the custom would be dead code since built-in clauses match first).
+
+## Splode errors
+
+`builder/1` returns the legacy tuple shape `{:error, [%{field, action, message}]}` by default. Wrap with Splode for `traverse_errors/2`, `to_class/1`, JSON serialisation:
+
+```elixir
+case Person.builder(input) do
+ {:ok, _} = ok ->
+ ok
+
+ {:error, errs} ->
+ {:error, GuardedStruct.Errors.from_tuple(errs)}
+end
+```
+
+## Ash integration
+
+Use the same DSL inside an `Ash.Resource`:
+
+```elixir
+defmodule MyApp.Resources.User do
+ use Ash.Resource, extensions: [GuardedStruct.AshResource]
+
+ guardedstruct do
+ field(:name, :string, enforce: true, derives: "validate(string, max_len=80)")
+ field(:email, :string, enforce: true, derives: "validate(email_r)")
+ end
+
+ changes do
+ change GuardedStruct.AshResource.Change
+ end
+
+ # ... your normal Ash actions, attributes, policies, etc.
+end
+
+MyApp.Resources.User.__guarded_change__(%{name: "Alice", email: "alice@x.com"})
+# => {:ok, %{name: "Alice", email: "alice@x.com"}}
+```
+
+The pipeline lives under the `__guarded_*__` namespace so it doesn't clash with Ash's own callbacks. The `Change` module bridges `__guarded_change__/1` into the changeset pipeline — runs sanitize + validate on every `:create` and `:update`.
+
+### Auto-wire (Option B)
+
+Set `auto_wire true` and skip the `changes do ... end` block — a Spark transformer injects the change for you via `Ash.Resource.Builder.add_change/3`:
+
+```elixir
+defmodule MyApp.Resources.User do
+ use Ash.Resource, extensions: [GuardedStruct.AshResource]
+
+ guardedstruct do
+ auto_wire true
+ field :email, :string, derives: "sanitize(trim, downcase) validate(email_r)"
+ end
+
+ # No `changes do ... end` block needed.
+end
+```
+
+### Bulk operations
+
+The bridge implements `batch_change/3`, so `Ash.bulk_create/3` and `Ash.bulk_update/3` (use `strategy: :stream`) work end-to-end. Sanitize runs per row through the imperative pipeline.
+
+### Atomic mode (compile-time-verified)
+
+For resources where every derive op is SQL-translatable, set `atomic true` to opt into compile-time atomic-safety verification:
+
+```elixir
+defmodule MyApp.Resources.User do
+ use Ash.Resource, extensions: [GuardedStruct.AshResource]
+
+ guardedstruct do
+ atomic true
+ auto_wire true
+
+ field :email, :string, derives: "sanitize(trim, downcase) validate(email_r, max_len=320)"
+ field :username, :string, derives: "validate(string, min_len=3, max_len=20)"
+ field :age, :integer, derives: "validate(integer, min_len=0, max_len=150)"
+ field :role, :string, derives: "validate(enum=String[admin::user::guest])"
+ field :tenant_id, :string, derives: "validate(uuid)"
+ end
+
+ actions do
+ defaults [:read, :destroy]
+ create :create, accept: [:email, :username, :age, :role, :tenant_id]
+
+ update :update do
+ accept [:email, :username, :age, :role]
+ require_atomic? false
+ end
+ end
+
+ attributes do
+ uuid_primary_key :id
+ attribute :email, :string, allow_nil?: false, public?: true
+ attribute :username, :string, allow_nil?: false, public?: true
+ attribute :age, :integer, public?: true
+ attribute :role, :string, public?: true
+ attribute :tenant_id, :string, public?: true
+ end
+end
+```
+
+The `VerifyAtomic` compile-time verifier rejects (with `Spark.Error.DslError` pointing at the offending field) any derive op that can't translate to atomic SQL:
+
+* `validate(email)` / `validate(url)` — need DNS / network I/O (use `email_r` / `url_r` instead)
+* per-field `validator: {Mod, :fn}` MFAs — arbitrary Elixir
+* `auto: {Mod, :fn}` MFAs — arbitrary Elixir
+* `main_validator/1` callback — cross-field Elixir
+* cross-field `on:` / `from:` / `domain:` options
+* Custom ops from `GuardedStruct.Derive.Extension`
+
+Sanitize ops (`trim`, `downcase`, `strip_tags`, `slugify`, …) are **always allowed** — they run in Elixir before the atomic SQL fires. The full atomic-safe registry lives in `GuardedStruct.AtomicClassifier`. Default is `atomic: false`.
diff --git a/lib/derive/derive.ex b/lib/derive/derive.ex
deleted file mode 100644
index 4dfc01b..0000000
--- a/lib/derive/derive.ex
+++ /dev/null
@@ -1,198 +0,0 @@
-defmodule GuardedStruct.Derive do
- alias GuardedStruct.Derive.{Parser, SanitizerDerive, ValidationDerive}
-
- @spec derive(
- {:error, any(), any()}
- | {:ok, any(), list(String.t() | map())}
- | {:error, any(), :halt}
- | {:error, :nested, list(), any(), [binary()]}
- ) :: {:ok, map()} | {:error, any()}
- def derive({:error, type, message, :halt}) do
- {:error, type, message}
- end
-
- def derive({:error, :nested, builders_errors, data, derive_inputs}),
- do: derive({:ok, data, derive_inputs}, builders_errors)
-
- def derive({:error, _, _} = error), do: error
-
- def derive({:error, _} = error), do: error
-
- @spec derive({:ok, any(), list(String.t() | map())}, list()) ::
- {:ok, map()} | {:error, list()}
- def derive({:ok, data, derive_inputs}, extra_error \\ []) do
- reduced_fields =
- Enum.reduce(derive_inputs, %{}, fn map, acc ->
- derives = Parser.parser(map.derive)
- field = Map.get(data, map.field)
- hint = Map.get(map, :hint) || []
-
- update_reduced_fields(field, derives, hint, map, acc)
- end)
-
- {:error, get_error} = error = error_handler(reduced_fields, extra_error)
-
- if length(get_error) == 0, do: {:ok, Map.merge(data, reduced_fields)}, else: error
- end
-
- defp update_reduced_fields(nil, _parsed_derive, _hint, _map, acc), do: acc
-
- defp update_reduced_fields(get_field, parsed_derive, hints, map, acc)
- when is_list(parsed_derive) and parsed_derive != [] do
- # Temporary way to find it is list conditional or not
- list_data? = is_list(get_field) and length(get_field) == length(parsed_derive)
-
- get_field =
- if list_data? do
- get_field
- else
- stream = Stream.duplicate(get_field, length(parsed_derive))
- Enum.to_list(stream)
- end
-
- converted_validated_values =
- Enum.zip([parsed_derive, get_field, hints])
- |> Enum.map(fn {derive, value, hint} ->
- derive = if(derive == [], do: nil, else: derive)
-
- {all_data, validated_errors} =
- {map.field, value}
- |> SanitizerDerive.call(Map.get(derive || %{}, :sanitize))
- |> ValidationDerive.call(Map.get(derive || %{}, :validate), hint)
-
- if length(validated_errors) > 0, do: {:error, validated_errors}, else: all_data
- end)
-
- {errors, data} = derive_list_values_and_errors_divider(converted_validated_values)
-
- if list_data? do
- Map.put(acc, map.field, if(length(errors) > 0, do: {:error, errors}, else: data))
- else
- Map.put(acc, map.field, if(length(data) > 0, do: List.first(data), else: {:error, errors}))
- end
- end
-
- defp update_reduced_fields(get_field, parsed_derive, hint, map, acc) do
- # destruct because we consider empty list default value when there is no derive
- parsed_derive = if(parsed_derive == [], do: nil, else: parsed_derive)
-
- {all_data, validated_errors} =
- {map.field, get_field}
- |> SanitizerDerive.call(Map.get(parsed_derive || %{}, :sanitize))
- |> ValidationDerive.call(Map.get(parsed_derive || %{}, :validate), hint)
-
- converted_validated_values =
- if length(validated_errors) > 0, do: {:error, validated_errors}, else: all_data
-
- Map.put(acc, map.field, converted_validated_values)
- end
-
- defp derive_list_values_and_errors_divider(data) do
- {error, no_error} =
- data
- |> Enum.split_with(&(is_tuple(&1) and elem(&1, 0) == :error))
-
- converted_error = Enum.map(error, fn {:error, errors} -> errors end) |> Enum.concat()
-
- {converted_error, no_error}
- end
-
- @spec error_handler(map(), list(any())) :: {:error, any()}
- def error_handler(reduced_fields, extra_error \\ []) do
- errors =
- Enum.find(extra_error, fn %{field: _, errors: errorMap} ->
- !is_list(errorMap) and errorMap.action == :required_fields
- end)
- |> case do
- nil ->
- get_error =
- reduced_fields
- |> Map.values()
- |> Enum.filter(&(is_tuple(&1) && elem(&1, 0) == :error))
- |> Enum.map(fn {:error, errors} -> errors end)
- |> Enum.concat()
- |> halt_errors()
-
- get_error ++ extra_error
-
- _ ->
- extra_error
- end
-
- {:error, errors}
- end
-
- defp halt_errors(errors_list) do
- errors_list
- |> Enum.reduce_while([], fn item, acc ->
- if Map.get(item, :status) == :halt,
- do: {:halt, acc ++ [Map.delete(item, :status)]},
- else: {:cont, acc ++ [item]}
- end)
- end
-
- @spec get_derives_from_success_conditional_data(list(any())) :: any()
- @doc false
- def get_derives_from_success_conditional_data(conds) do
- Enum.reduce(conds, [], fn
- {field, {{:ok, _data}, opts}}, acc ->
- case Keyword.keyword?(opts) do
- true ->
- get_derive = Keyword.get(opts, :derive, [])
- get_hint = Keyword.get(opts, :hint, [])
- acc ++ [Map.new([{:derive, get_derive}, {:field, field}, {:hint, get_hint}])]
-
- false when is_list(opts) ->
- %{derive: derives, hint: hints} =
- Enum.reduce(opts, %{derive: [], hint: []}, fn item, acc ->
- get_derive = Keyword.get(item, :derive, [])
- get_hint = Keyword.get(item, :hint, [])
-
- Map.merge(acc, %{derive: acc.derive ++ [get_derive], hint: acc.hint ++ [get_hint]})
- end)
-
- acc ++ [Map.new([{:derive, derives}, {:field, field}, {:hint, hints}])]
-
- _ ->
- # We do not cover this setuation
- acc
- end
-
- {field, values}, acc ->
- %{derive: derives, hint: hints} =
- Enum.reduce(values, %{derive: [], hint: []}, fn {{:ok, _value}, opts}, acc ->
- get_derive = Keyword.get(opts, :derive, [])
- get_hint = Keyword.get(opts, :hint, [])
-
- Map.merge(acc, %{derive: acc.derive ++ [get_derive], hint: acc.hint ++ [get_hint]})
- end)
-
- acc ++ [Map.new([{:derive, derives}, {:field, field}, {:hint, hints}])]
- end)
- end
-
- def pre_derives_check({{:ok, _, data}, _} = result, opts, field) do
- run_pre_derives_check(data, opts[:derive], result, field, opts)
- end
-
- def pre_derives_check({{:ok, data}, _, _} = result, opts, field) do
- run_pre_derives_check(data, opts[:derive], result, field, opts)
- end
-
- def pre_derives_check({{:error, _, _}, _} = result, _opts, _field), do: result
-
- def pre_derives_check({{:error, _}, _, _} = result, _opts, _field), do: result
-
- def pre_derives_check({{:error, _}, _} = result, _opts, _field), do: result
-
- defp run_pre_derives_check(_, nil, validator_result, _field, _opts), do: validator_result
-
- defp run_pre_derives_check(value, derive, _, field, opts) do
- {:ok, Map.new([{field, value}]), [%{derive: derive, field: field}]}
- |> derive()
- |> case do
- {:ok, data} -> {{:ok, field, Map.get(data, field)}, opts}
- {:error, _} = error -> {error, field, opts}
- end
- end
-end
diff --git a/lib/derive/parser.ex b/lib/derive/parser.ex
deleted file mode 100644
index 78c75c7..0000000
--- a/lib/derive/parser.ex
+++ /dev/null
@@ -1,264 +0,0 @@
-defmodule GuardedStruct.Derive.Parser do
- import GuardedStruct.Messages, only: [translated_message: 1]
-
- @spec parser(list(String.t()) | String.t()) :: any()
- def parser(inputs) when is_list(inputs) do
- Enum.map(inputs, &parser(&1))
- end
-
- def parser(input) do
- String.split(String.trim(input), ")")
- |> Enum.reject(&(&1 == ""))
- |> Enum.map(fn x ->
- case Code.string_to_quoted!(String.trim(x) <> ")") do
- {key, _, parameters} ->
- convert_parameters(key, parameters)
-
- _ ->
- nil
- end
- end)
- |> Enum.reject(&is_nil(&1))
- |> merge_parser_list()
- rescue
- # We do not check the drive in compile time, so we need to pass nil
- _e -> nil
- end
-
- def parser(blocks, :conditional, parent \\ "root") do
- case blocks do
- {:__block__, line, items} ->
- {:__block__, line, elements_unification(items, parent)}
-
- {:field, line, items} ->
- {:field, line, add_parent_tags(items, parent)}
-
- {:sub_field, line, items} ->
- {:sub_field, line, add_parent_tags(items, parent)}
-
- {:conditional_field, line, items} ->
- raise(translated_message(:unsupported_conditional_field))
-
- {:conditional_field, line,
- elements_unification(add_parent_tags(items, parent, "conds"), parent)}
- end
- end
-
- defp elements_unification(blocks, parent) do
- Enum.map(blocks, fn
- {:field, line, items} ->
- {:field, line, add_parent_tags(items, parent)}
-
- {:sub_field, line, items} ->
- {:sub_field, line, add_parent_tags(items, parent)}
-
- {:conditional_field, line, items} ->
- raise(translated_message(:unsupported_conditional_field))
-
- comverted_items = add_parent_tags(items, parent, "conds")
-
- recursive_children =
- Enum.map(comverted_items, fn item ->
- if Keyword.keyword?(item) and Keyword.has_key?(item, :do),
- do: [
- do:
- parser(Keyword.get(item, :do), :conditional, find_node_tags(comverted_items).id)
- ],
- else: item
- end)
-
- {:conditional_field, line, recursive_children}
- end)
- end
-
- def find_node_tags([_name, _type, opts | _reset] = _items) do
- %{parent: opts[:__node_parent_tree__], type: opts[:__node_type__], id: opts[:__node_id__]}
- end
-
- defp add_parent_tags(items, parent, type \\ "normal") do
- id = parent <> "::" <> GuardedStruct.Helper.Extra.randstring(8)
-
- Enum.map(items, fn item ->
- if Keyword.keyword?(item) and !Keyword.has_key?(item, :__node_type__) and
- !Keyword.has_key?(item, :do) do
- item ++ [__node_parent_tree__: parent, __node_type__: type, __node_id__: id]
- else
- item
- end
- end)
- end
-
- @spec convert_to_atom_map({:ok, map()} | {:error, any(), any()} | map()) ::
- {:error, any(), any()} | map()
-
- def convert_to_atom_map({:error, _, _} = error), do: error
-
- def convert_to_atom_map({:ok, map}) when is_map(map), do: convert_to_atom_map(map)
-
- def convert_to_atom_map(map) when is_struct(map) do
- for {key, value} <- Map.from_struct(map),
- into: %{},
- do: {convert_key(key), convert_value(value)}
- end
-
- def convert_to_atom_map(map) when is_map(map) do
- for {key, value} <- map, into: %{}, do: {convert_key(key), convert_value(value)}
- end
-
- defp convert_key(key) when is_binary(key), do: String.to_atom(key)
-
- defp convert_key(key), do: key
-
- defp convert_value(%{__struct__: struct} = map)
- when struct in [NaiveDateTime, DateTime, Date] do
- map
- end
-
- defp convert_value(%{} = map), do: convert_to_atom_map(map)
-
- defp convert_value([]), do: []
-
- defp convert_value(list) when is_list(list), do: Enum.map(list, &convert_value/1)
-
- defp convert_value(value), do: value
-
- @spec convert_parameters(atom() | String.t(), any()) :: nil | %{optional(any()) => list()}
- def convert_parameters(derive_key, parameters) do
- converted =
- parameters
- |> Enum.map(fn
- {key, _, nil} ->
- key
-
- {:=, _, [{key, _, nil}, {value, _, nil}]} when is_atom(value) ->
- {key, Atom.to_string(value)}
-
- {:=, _, [{key, _, nil}, value]} when is_integer(value) ->
- {key, value}
-
- {:=, _, [{key, _, nil}, value]} when is_list(value) and key == :custom ->
- case value do
- [{:__aliases__, _, module_list}, {function, _, nil}] ->
- {key, {module_list, function}}
-
- _ ->
- nil
- end
-
- {:=, _, [{key, _, nil}, value]} when is_list(value) ->
- if Enum.any?(value, &is_tuple(&1)),
- do: convert_parameters(key, value),
- else: {key, value}
-
- {:=, _, [{key, _, nil}, {_, _, [{:__aliases__, _, [type]} | _t]} = value]}
- when is_tuple(value) and is_atom(type) ->
- {key, Macro.to_string(value)}
-
- {:=, _, [{key, _, nil}, value]} when is_binary(value) ->
- {key, value}
-
- _ ->
- nil
- end)
- |> Enum.reject(&is_nil(&1))
-
- if converted == [], do: nil, else: Map.put(%{}, derive_key, converted)
- end
-
- defp merge_parser_list([]), do: nil
-
- defp merge_parser_list(list_of_maps) do
- Enum.reduce(list_of_maps, %{}, fn map, acc ->
- Map.merge(acc, map)
- end)
- end
-
- @spec parse_core_keys_pattern(binary()) :: list()
- def parse_core_keys_pattern(pattern) do
- pattern
- |> String.trim()
- |> String.split("::", trim: true)
- |> Enum.map(&String.to_atom/1)
- end
-
- @spec is_data?(%{:data => any(), :errors => any(), optional(any()) => any()}) :: boolean()
- @doc false
- def is_data?(%{data: [], errors: []}), do: true
-
- def is_data?(%{data: [], errors: errors}) when errors != [], do: false
-
- def is_data?(%{data: data, errors: errors}) when data != [] and errors == [], do: true
-
- def is_data?(%{data: _data, errors: errors}) when errors != [], do: false
-
- @spec map_keys(map(), list(atom())) :: any()
- @doc false
- def map_keys(map_data, keys) when is_map(map_data) do
- case List.first(Map.keys(map_data)) do
- nil -> keys
- data when is_atom(data) -> keys
- data when is_binary(data) -> Enum.map(keys, &Atom.to_string(&1))
- end
- end
-
- def map_keys(_map, keys), do: keys
-
- @spec field_status?(tuple(), atom()) :: boolean()
-
- def field_status?({{:error, _data}, _opts}, status) when status === :error,
- do: true
-
- def field_status?({{:error, _, _}, _}, status) when status === :error,
- do: true
-
- def field_status?({{:error, _, _}, _, _}, status) when status === :error,
- do: true
-
- def field_status?({{field_status, _, _}, _}, status) when field_status === status,
- do: true
-
- def field_status?({{field_status, _}, _, _}, status) when field_status === status,
- do: true
-
- def field_status?(_, _), do: false
-
- @spec field_value(
- maybe_improper_list()
- | {{:ok, any()} | {:error, any(), any()} | {:ok, any(), any()}, any()}
- | {{:ok, any()} | {:error, any(), any()}, any(), any()}
- ) :: maybe_improper_list() | {any(), any()}
- def field_value({{:error, _, _}, _} = output), do: [output]
-
- def field_value({{:error, _, _}, _, _} = output), do: [output]
-
- def field_value({{:ok, _, value}, opts}), do: {value, opts}
-
- def field_value({{:ok, value}, _, opts}), do: {value, opts}
-
- def field_value({{:ok, value}, opts}), do: {value, opts}
-
- def field_value(output) when is_list(output), do: output
-
- def field_value(nil), do: raise(translated_message(:parser_field_value))
-
- @spec conds_list(list(map()) | map(), String.t()) :: any()
- def conds_list(data, parent_key) do
- items_with_parent =
- Enum.filter(data, fn %{opts: opts} -> opts[:__node_parent_tree__] == parent_key end)
-
- Enum.reduce(items_with_parent, %{}, fn item, acc ->
- children = find_conds_children_recursive(data, item.opts[:__node_id__])
- Map.put(acc, item.opts[:__node_id__], Map.merge(item, %{children: children}))
- end)
- end
-
- defp find_conds_children_recursive(data, parent_tag) do
- children =
- Enum.filter(data, fn %{opts: opts} -> opts[:__node_parent_tree__] == parent_tag end)
-
- Enum.reduce(children, %{}, fn item, acc ->
- children = find_conds_children_recursive(data, item.opts[:__node_id__])
- Map.put(acc, item.opts[:__node_id__], Map.merge(item, %{children: children}))
- end)
- end
-end
diff --git a/lib/guarded_struct.ex b/lib/guarded_struct.ex
index 567f6db..e6f0869 100644
--- a/lib/guarded_struct.ex
+++ b/lib/guarded_struct.ex
@@ -1,2910 +1,391 @@
defmodule GuardedStruct do
@moduledoc """
- The creation of this macro will allow you to build `Structs` that provide you with a number of
- important options, including the following:
+ GuardedStruct macro: build structs with validation, sanitization, constructors,
+ and nested-struct support.
- 1. Validation
- 2. Sanitizing
- 3. Constructor
- 4. It provides the capacity to operate in a nested style simultaneously.
+ ## Quick example
- Suppose you are going to collect a number of pieces of information from the user,
- and before doing anything else, you are going to sanitize them.
- After that, you are going to validate each piece of data, and if there are no issues,
- you will either display it in a proper output or save it somewhere else.
- All of the characteristics that are associated with this macro revolve around cleaning and validating the data.
+ defmodule MyStruct do
+ use GuardedStruct
- The features that we list below are individually based on a particular strategy
- and requirement, but thankfully, they may be combined and mixed in any way that you see fit.
-
- It bestows to you a significant amount of authority in this sphere.
- After the initial version of this macro was obtained from the source of the `typed_struct` library,
- many sections of it were rewritten, or new concepts were taken from libraries in Rust and Scala
- and added to this library in the form of Elixir base.
-
- The initial version of this macro can be found in the `typed_struct` library. Its base is a
- syntax that is very easy to comprehend, especially for non-technical product managers, and highly straightforward.
-
- Before explaining the copyright, I must point out that the primary library, which is `typed_struct`,
- is no longer supported for a long time, so please pay attention to the following copyright.
-
- ## Copyright
-
- The code in this module is based on the `typed_struct` library (https://github.com/ejpcmac/typed_struct),
- which is licensed under the MIT License.
-
- Modifications and additions have been made to enhance its capabilities as part of the current project.
-
- **MIT License**
-
- Adding new Copyright (c) [2023] [Shahryar Tavakkoli at [Mishka Group](https://github.com/mishka-group)]
-
- **Note:** If the license changes during the support of this project, this file will always remain on MIT
-
- """
-
- ####################################################################
- ################ (▰˘◡˘▰) initializing (▰˘◡˘▰) ################
- ####################################################################
- import GuardedStruct.Messages, only: [translated_message: 1, translated_message: 2]
- alias GuardedStruct.{Derive, Derive.Parser, Derive.ValidationDerive}
- defexception [:term]
-
- @temporary_revaluation [
- :gs_fields,
- :gs_sub_fields,
- :gs_types,
- :gs_enforce_keys,
- :gs_validator,
- :gs_main_validator,
- :gs_derive,
- :gs_authorized_fields,
- :gs_external,
- :gs_core_keys,
- :gs_conditional_fields,
- :gs_caller
- ]
-
- @impl true
- def message(exception), do: translated_message(:message_exception, exception)
-
- defmacro __using__(_) do
- quote do
- import GuardedStruct, only: [guardedstruct: 1, guardedstruct: 2]
- end
- end
-
- @doc """
- ### Defines a guarded struct
-
- The beginning of the block consists of the introduction of a `Struct` with the `guardedstruct` macro,
- which is solely responsible for recording a series of information in order to create a struct, as well
- as all of the fields with the `field` macro, and if you need to create another struct within this struct
- (in actuality, a module child within another module), you must use the `sub_field` macro.
-
- **Note:** there is no restriction on the number of times you can call the `sub_field` macro or the
- field macro within the context of the `sub_field` macro.
-
- **Note:** Because `Stract` does not prioritize the display of keys depending on your requirements,
- you do not need to follow the priority of the fields and call them in order to utilize the app.
- Implement the program's logic, regardless of what it might be.
-
- **Note:** Because of different limitations, if you want to write a test, you must first
- place the module in which you built the struct outside of the test macro. Once the struct
- has been built, you may then test it by calling it within the test macro itself.
- The examples it provides can also be found in the testing done by this library itself.
-
- **Note:** this library is only supported on versions of `Elixir 1.15` and higher, as well as `OTP 26`,
- and that the manufacturer does not offer bug patches for problems that occur in older software versions.
-
- **Note:** All of this library's dependencies are optional; nonetheless,
- if you require their use in your program, you will need to include them. We provide further
- explanation on the topic in the area you're looking for.
-
- > Before continuing with the discussion about the library section and also offering practical
- examples in this field, it is important to understand that when you construct a struct in a module,
- after compilation in the runtime of the program, each module includes the following functional functions:
-
- 1. The `builder()` function is actually an action function, and it requires you to provide it with
- information in the form of a `map`.
-
- 2. The `enforce_keys()` function: this method returns the necessary keys of the first layer of the
- struct. However, if you want to display all of the keys of the nested struct,
- you will need to enter the `:all` input, which is not yet implemented in this version.
-
- 3. The `keys()` function has the same requirements as the `enforce_keys()`
- function, with the exception that it returns all of the keys, including the ones that aren't necessary.
-
- ---
-
- **And also, any data that enters the `builder` function must go through the following path:**
-
- 1. If the `map` currently uses the `string` data type, it will be converted to the `atom` data type.
-
- 2. Eliminates the keys from the `struct` that are not present in the list
-
- 3. Determines whether or not all of the essential keys have been transmitted.
-
- 4. If you write your own custom validation, each field's validations will be checked.
-
- > It is important to notice that regardless of the circumstances, this macro also inspects the module itself.
- If there is a `validator` function but none of the functions are set,
- it calls the validator function directly from the module itself into the field itself.
-
- 5. The output of the complete `struct` is entered into the mother validation,
- and the programmer is given the opportunity to write for the final output in this validation.
- This validation also provides the possibility of writing for the output of the struct.
-
- > This macro will call the struct's `main_validator` directly from the module
- it has been placed in if, in this section, the `main_validator` is not set in the
- struct but is found in the module that contains the struct.
-
- 6. If there were no problems in the previous phases (it is important to note that options 4 and 5 are not required),
- it will proceed to the next level of the program, which is the validation and custom Sanitizer stage.
-
- 7. To begin, the Sanitizer will alter the data so that it corresponds to what you have called in each field,
- and it will not return any errors.
- Even if the Sanitizer programmer is not utilized in the required type as a result of an accidental oversight,
- the data will still be passed to the following stage.
-
- 8. At this point, it will return an error or data for each field, depending on the validations that you called.
-
- 9. At the end of the process, you will receive a tuple that will either have problems in it or
- the final data with an ok status.
-
- > It is important to keep in mind that if your `struct` is nested, all of the internal errors
- of these structs are also included in the list of problems. Additionally,
- the data will be sent to you when the status is positive, but only if you have called the parent of this struct.
-
- > Note that each nested struct can be used on its own and possesses all of the
- capabilities that have been discussed thus far. For instance, if you have module `A` and
- you utilized the `sub_field` that is named `auth` in it, you may now use it separately from the `A.Auth` Use. Use.
-
- ---
-
- ### Examples
-
- 1. #### Defining a struct layer without additional options
-
- ```elixir
- defmodule MyStruct do
- use GuardedStruct
-
- guardedstruct do
- field :field_one, String.t()
- field :field_two, integer(), enforce: true
- field :field_three, boolean(), enforce: true
- field :field_four, atom(), default: :hey
- end
- end
- ```
-
- ---
-
- 2. #### Define a struct with settings related to essential keys or `opaque` type
-
- ##### Options
-
- * `enforce` - if set to true, sets `enforce: true` to all fields by default.
- This can be overridden by setting `enforce: false` or a default value on
- individual fields.
- * `opaque` - if set to true, creates an opaque type for the struct.
- * `module` - if set, creates the struct in a submodule named `module`.
-
- ```elixir
- defmodule MyModule do
- use GuardedStruct
-
- guardedstruct enforce: true do
- field(:enforced_by_default, term())
- field(:not_enforced, term(), enforce: false)
- field(:with_default, integer(), default: 1)
- field(:with_false_default, boolean(), default: false)
- field(:with_nil_default, term(), default: nil)
- end
- end
-
- # OR opaque
-
- defmodule MyModule do
- use GuardedStruct
-
- guardedstruct opaque: true do
- field(:enforced_by_default, term())
- field(:not_enforced, term(), enforce: false)
- field(:with_default, integer(), default: 1)
- field(:with_false_default, boolean(), default: false)
- field(:with_nil_default, term(), default: nil)
- end
- end
-
- # OR opaque
-
- defmodule MyModule do
- use GuardedStruct
-
- guardedstruct do
- field(:enforced_by_default, term())
- field(:not_enforced, term(), enforce: true)
- field(:with_default, integer(), default: 1)
- field(:with_false_default, boolean(), default: false)
- field(:with_nil_default, term(), default: nil)
- end
- end
-
- # OR create sub module
-
- defmodule TestModule do
- use GuardedStruct
-
- guardedstruct module: Struct do
- field(:field, term())
- end
- end
- ```
-
- ---
-
- 3. #### Defining the struct by calling the validation module or calling from the module that contains the struct
-
- ##### Options
- * `validator` - if set as tuple like this {ModuleName, :function_name} for each field,
- in fact you have a `builder` function that check the validation.
-
- ```elixir
- # First, it looks at whether a validator has been set for each field,
- # otherwise it looks inside the module.
- defmodule MyModule do
- alias MyModule.AnotherModule
- use GuardedStruct
-
- guardedstruct do
- field(:name, String.t(), validator: {AnotherModule, :validator})
- field(:title, String.t())
- end
-
- def validator(:title, value) do
- {:ok, :title, value}
- end
-
- # You can not use it, but it is mentioned here for test clarity
- def validator(name, value) do
- {:ok, name, value}
- end
- end
- ```
-
- - Output without error: `{:ok, :field_name, value}`
- - Output with error: `{:error, :field_name, ERROR MESSAGE}`
-
- ---
-
- 4. #### Define the struct by calling the `main_validator` for full access on the output
-
- ##### Options
- * `main_validator` - if set as tuple like this {ModuleName, :function_name},
- for guardedstruct, in fact you have a global validation.
-
- ```elixir
- # First, it looks at whether a main_validator has been set for each field,
- # otherwise it looks inside the module.
- defmodule MyModule do
- alias MyModule.AnotherModule
- use GuardedStruct
-
- guardedstruct main_validator: {AnotherModule, :main_validator} do
- field(:name, String.t())
- field(:title, String.t())
- end
-
- # if `guardedstruct` has no `main_validator` which is configed
- def main_validator(value) do
- {:ok, value}
- end
- end
- ```
-
- - Output without error: `{:ok, value}`
- - Output with error: `{:error, :generalـreason, errors_list}`
-
- ---
-
- 5. #### Define struct with `derive`
-
- > derive is divided into two parts: `validate` and `sanitize`, which is priority with `sanitize`
-
- **It should be noted that in the following tables you can see that in order to use some derives, you need to add its dependency on your project.**
-
-
- #### Sanitize
-
- | How to use | Dependencies | Description |
- | ---------- | ------------ | ----------- |
- | `"sanitize(trim)"` | NO | Trim your string |
- | `"sanitize(upcase)"` | NO | Upcase your string |
- | `"sanitize(downcase)"` | NO | Downcase your string |
- | `"sanitize(capitalize)"` | NO | Capitalize your string |
- | `"sanitize(basic_html)"` | `:html_sanitize_ex` | Sanitize your string base on `basic_html` |
- | `"sanitize(html5)"` | `:html_sanitize_ex` | Sanitize your string base on `html5` |
- | `"sanitize(markdown_html)"` | `:html_sanitize_ex` | Sanitize your string base on `markdown_html` |
- | `"sanitize(strip_tags)"` | `:html_sanitize_ex` | Sanitize your string base on `strip_tags` |
- | `"sanitize(tag)"` | `:html_sanitize_ex` | Sanitize your string base on `html_sanitize_ex` selection |
- | `"sanitize(string_float)"` | `:html_sanitize_ex` or `none` | Sanitize your string base on `html_sanitize_ex` and `Float.parse/1` |
- | `"sanitize(string_float)"` | `:html_sanitize_ex` or NO | Sanitize your string base on `html_sanitize_ex` and `Float.parse/1` |
- | `"sanitize(string_integer)"` | `:html_sanitize_ex` or NO | Sanitize your string base on `html_sanitize_ex` and `Integer.parse/1` |
-
- #### Validate
-
- | How to use | Dependencies | Description |
- | ---------- | ------------ | ----------- |
- | `"validate(string)"` | NO | Validate if the data is string|
- | `"validate(integer)"` | NO | Validate if the data is integer|
- | `"validate(list)"` | NO | Validate if the data is list|
- | `"validate(atom)"` | NO | Validate if the data is atom|
- | `"validate(bitstring)"` | NO | Validate if the data is bitstring|
- | `"validate(boolean)"` | NO | Validate if the data is boolean|
- | `"validate(exception)"` | NO | Validate if the data is exception|
- | `"validate(float)"` | NO | Validate if the data is float|
- | `"validate(function)"` | NO | Validate if the data is function|
- | `"validate(map)"` | NO | Validate if the data is map|
- | `"validate(nil_value)"` | NO | Validate if the data is nil value|
- | `"validate(not_nil_value)"` | NO | Validate if the data is not nil value|
- | `"validate(number)"` | NO | Validate if the data is number|
- | `"validate(pid)"` | NO | Validate if the data is Elixir pid|
- | `"validate(port)"` | NO | Validate if the data is Elixir port|
- | `"validate(reference)"` | NO | Validate if the data is Elixir reference|
- | `"validate(struct)"` | NO | Validate if the data is struct|
- | `"validate(tuple)"` | NO | Validate if the data is tuple|
- | `"validate(not_empty)"` | NO | Validate if the data is not empty - binary, map, list|
- | `"validate(max_len=10)"` | NO | Validate if the data is more than 10 - Range, integer, binary|
- | `"validate(min_len=10)"` | NO | Validate if the data is less than 10 - Range, integer, binary|
- | `"validate(url)"` | NO | Validate if the data is url|
- | `"validate(geo_url)"` | `ex_url` | Validate if the data is geo url|
- | `"validate(tell)"` | `ex_url` | Validate if the data is tell|
- | `"validate(tell=98)"` | `ex_url` | Validate if the data is tell with country code|
- | `"validate(email)"` | `email_checker` | Validate if the data is email|
- | `"validate(location)"` | `ex_url` | Validate if the data is location|
- | `"validate(string_boolean)"` | NO | Validate if the data is string boolean|
- | `"validate(datetime)"` | NO | Validate if the data is datetime|
- | `"validate(range)"` | NO | Validate if the data is datetime|
- | `"validate(date)"` | NO | Validate if the data is datetime|
- | `"validate(regex='^[a-zA-Z]+@mishka\.group$')"` | NO | Validate if the data is match with regex|
- | `"validate(ipv4)"` | NO | Validate if the data is ipv4|
- | `"validate(not_empty_string)"` | NO | Validate if the data is not empty string|
- | `"validate(uuid)"` | NO | Validate if the data is uuid|
- | `"validate(enum=String[admin::user::banned])"` | NO | Validate if the data is one of the enum value, which is String|
- | `"validate(enum=Atom[admin::user::banned])"` | NO | Validate if the data is one of the enum value, which is Atom|
- | `"validate(enum=Integer[1::2::3])"` | NO | Validate if the data is one of the enum value, which is Integer|
- | `"validate(enum=Float[1.5::2.0::4.5])"` | NO | Validate if the data is one of the enum value, which is Float|
- | `"validate(enum=Map[%{status: 1}::%{status: 2}::%{status: 3}])"` | NO | Validate if the data is one of the enum value, which is Map|
- | `"validate(enum=Tuple[{:admin, 1}::{:user, 2}::{:banned, 3}])"` | NO | Validate if the data is one of the enum value, which is Tuple|
- | `"validate(equal=some_thing)"` | NO | Validate if the data is equal with validation value, which is any type|
- | `"validate(either=[string, enum=Integer[1::2::3]])"` | NO | Validate if the data is valid with each derive validation|
- | `"validate(custom=[Enum, all?])"` | NO | Validate if the you custom function returns true, **Please read section 20**|
- | `"validate(some_string_float)"` | NO | Validate if the string data is float (Somewhat by removing the string)|
- | `"validate(string_float)"` | NO | Validate if the string data is float (Strict mode)|
- | `"validate(string_integer)"` | NO | Validate if the string data is integer (Strict mode)|
- | `"validate(some_string_integer)"` | NO | Validate if the string data is integer (Somewhat by removing the string)|
- | `"validate(not_flatten_empty)"` | NO | Validate the list if it is empty by summing and flattening the entire list|
- | `"validate(not_flatten_empty_item)"` | NO | Validate the list if it is empty by summing and flattening the entire list and first level children|
- | `"validate(queue)"` | NO | Validate the data is Erlang queue or not |
- | `"validate(username)"` | NO | Validate the input has username format or not |
- | `"validate(full_name)"` | NO | Validate the input has full_name format or not |
-
- ```elixir
- defmodule MyModule do
- use GuardedStruct
-
- guardedstruct do
- field(:id, integer(), derive: "sanitize(trim) validate(integer, max_len=20, min_len=5)")
- field(:title, String.t(), derive: "sanitize(trim, upcase) validate(not_empty_string)")
- field(:name, String.t(), derive: "sanitize(trim, capitalize) validate(string, not_empty, max_len=20)")
- end
- end
- ```
-
- ---
-
- 6. #### Extending `derive` section
-
- ##### Options
- * `validate_derive` - It can be just one module or a list of modules
- * `sanitize_derive` - It can be just one module or a list of modules
-
- First set Application env:
-
- ```elixir
- Application.put_env(:guarded_struct, :validate_derive, [TestValidate, TestValidate2])
- Application.put_env(:guarded_struct, :sanitize_derive, [TestSanitize, TestSanitize2])
-
- # OR
- Application.put_env(:guarded_struct, :validate_derive, TestValidate)
- Application.put_env(:guarded_struct, :sanitize_derive, TestSanitize)
- ```
-
- ```elixir
- defmodule TestValidate do
- def validate(:testv1, input, field) do
- if is_binary(input),
- do: input,
- else: {:error, field, :testv1, "The name field must not be empty"}
- end
- end
-
- defmodule TestValidate2 do
- def validate(:testv2, input, field) do
- if is_binary(input),
- do: input,
- else: {:error, field, :testv1, "The name field must not be empty"}
- end
- end
-
- defmodule TestSanitize do
- def sanitize(:capitalize_v1, input) do
- if is_binary(input), do: String.capitalize(input), else: input
- end
- end
-
- defmodule TestSanitize2 do
- def sanitize(:capitalize_v2, input) do
- if is_binary(input), do: String.capitalize(input), else: input
- end
- end
-
- defmodule MyModule do
- use GuardedStruct
-
- guardedstruct validate_derive: TestValidate, sanitize_derive: TestSanitize do
- field(:id, integer(), derive: "sanitize(trim) validate(not_exist)")
- field(:title, String.t(), derive: "sanitize(trim) validate(string)")
- field(:name, String.t(), derive: "sanitize(capitalize_v2) validate(string)")
- end
- end
-
- # OR you can extend with list of modules
-
- defmodule MyModule do
- use GuardedStruct
-
- guardedstruct validate_derive: [TestValidate, TestValidate2], sanitize_derive: [TestSanitize, TestSanitize2] do
- field(:id, integer(), derive: "validate(ineteger)")
- field(:title, String.t(), derive: "sanitize(trim) validate(string)")
- field(:name, String.t(), derive: "sanitize(capitalize_v2) validate(string)")
- end
- end
- ```
- ---
-
- 7. #### Struct definition with `validator` and `derive` simultaneously
-
- ```elixir
- # In this code, name field has not custom validator module and function
- # Then it see the caller module for it
- defmodule MyModule do
- use GuardedStruct
-
- guardedstruct do
- field(:name, String.t(),
- enforce: true,
- derive: "sanitize(trim, upcase) validate(not_empty)"
- )
-
- field(:title, String.t(), derive: "sanitize(trim, capitalize) validate(not_empty)")
- end
-
- def validator(:name, value) do
- if is_binary(value), do: {:ok, :name, "Mishka "}, else: {:error, :name, "No, never"}
- end
-
- def validator(name, value) do
- {:ok, name, value}
- end
- end
-
- # OR with custom validator
-
- defmodule MyModule do
- alias MyModule.AnotherModule
- use GuardedStruct
-
- guardedstruct do
- field(:name, String.t(),
- enforce: true,
- derive: "sanitize(trim, capitalize) validate(not_empty)",
- validator: {AnotherModule, :validator}
- )
- field(:title, String.t(), derive: "sanitize(trim, capitalize) validate(not_empty)")
- end
-
- # You can not use it, but it is mentioned here for test clarity
- def validator(name, value) do
- {:ok, name, value}
- end
- end
- ```
- ---
-
- 8. #### Define a nested and complex struct
-
- ```elixir
- defmodule TestNestedStruct do
- use GuardedStruct
-
- guardedstruct do
- field(:name, String.t(),
- derive:
- "sanitize(strip_tags, trim, capitalize) validate(string, not_empty, max_len=20, min_len=3)"
- )
-
- field(:family, String.t(),
- derive:
- "sanitize(basic_html, trim, capitalize) validate(string, not_empty, max_len=20, min_len=3)"
- )
-
- field(:age, integer(), enforce: true, derive: "validate(integer, max_len=110, min_len=18)")
-
- sub_field(:auth, struct(), enforce: true) do
- field(:server, String.t(), derive: "validate(regex='^[a-zA-Z]+@mishka\.group$')")
-
- field(:identity_provider, String.t(),
- derive: "sanitize(strip_tags, trim, lowercase) validate(not_empty)"
- )
-
- sub_field(:role, struct(), enforce: true) do
- field(:name, String.t(),
- derive:
- "sanitize(strip_tags, trim, lowercase) validate(enum=Atom[admin::user::banned])"
- )
-
- field(:action, String.t(), derive: "validate(string_boolean)")
-
- field(:status, String.t(),
- derive: "validate(enum=Map[%{status: 1}::%{status: 2}::%{status: 3}])"
- )
- end
-
- field(:last_activity, String.t(), derive: "sanitize(strip_tags, trim) validate(datetime)")
- end
-
- sub_field(:profile, struct()) do
- field(:site, String.t(), derive: "validate(url)")
-
- field(:nickname, String.t(), validator: {TestNestedStruct, :validator})
- end
-
- field(:username, String.t(),
- enforce: true,
- derive: "sanitize(tag=strip_tags) validate(not_empty, max_len=20, min_len=3)"
- )
- end
-
- def validator(:nickname, value) do
- if is_binary(value),
- do: {:ok, :nickname, value},
- else: {:error, :nickname, "Invalid nickname"}
- end
-
- def validator(field, value) do
- {:ok, field, value}
- end
- end
- ```
-
- 9. #### Error and data output sample
-
- ```elixir
- # Error
- {:error,
- [
- %{
- field: :profile,
- errors: {:bad_parameters, [%{message: "Invalid nickname", field: :nickname}]}
- },
- %{
- field: :auth,
- errors:
- {:bad_parameters,
- [
- %{message: _msg, field: :last_activity, action: :datetime},
- %{
- field: :role,
- errors:
- {:bad_parameters,
- [
- %{message: _msg1, field: :action, action: :string_boolean}
- ]}
- }
- ]}
- }
- ]}
-
- # Data
-
- {:ok,
- %MishkaDeveloperToolsTest.GuardedStructTest.TestNestedStruct{
- username: "mishka",
- profile: %MishkaDeveloperToolsTest.GuardedStructTest.TestNestedStruct.Profile{
- nickname: "mishka",
- site: "https://elixir-lang.org"
- },
- auth: %MishkaDeveloperToolsTest.GuardedStructTest.TestNestedStruct.Auth{
- last_activity: "2023-08-20 16:54:07.841434Z",
- role: %MishkaDeveloperToolsTest.GuardedStructTest.TestNestedStruct.Auth.Role{
- action: "true",
- name: :user,
- status: %{status: 2}
- },
- identity_provider: "google",
- server: "users@mishka.tools"
- },
- age: 18,
- family: "Group",
- name: "Mishka"
- }}
- ```
-
- 10. #### Set config to show error inside `defexception`
-
- You may want to display the received errors in Elixir's `defexception`. you just need to enable the
- `error: true` for `guardedstruct` macro or `sub_field`.
-
- **Note**: When you enable the `error` option. This macro will generate for you a module that
- is part of the parent module subset, and within that module, it will generate a `defexception` struct.
-
- ##### Error `defexception` modules
-
- ```elixir
- TestCallNestedStructWithError.Error
- TestCallNestedStructWithError.Auth.Error
- TestCallNestedStructWithError.Auth.Path.Error
- ```
-
- ##### Sample code
-
- ```elixir
- defmodule TestCallNestedStructWithError do
- use GuardedStruct
-
- guardedstruct error: true do
- field(:name, String.t(), derive: "validate(string)")
-
- sub_field(:auth, struct(), error: true) do
- field(:action, String.t(), derive: "validate(not_empty)")
-
- sub_field(:path, struct(), error: true) do
- field(:name, String.t())
- end
- end
- end
- end
-
- # And you should call it like this, the second entry should be `true` or `false` to show error `defexception`
- TestCallNestedStructWithError.builder(%{name: 1}, true)
- ```
-
- 11. #### `authorized_fields` option to limit user input
-
- If this option is not used, the program will automatically drop fields that are not defined;
- however, if this option is set, it will return an error to the user if they transmit a field
- that is not in the list of specified fields. If this option is not used, the program will automatically
- drop fields that are not defined.
-
- **Please take note** that the `required_fields` and this section are not the same thing,
- and that the validation of the mandatory fields will take place after this section.
-
- ```elixir
- defmodule TestAuthorizeKeys do
- use GuardedStruct
-
- guardedstruct authorized_fields: true do
- field(:name, String.t(), derive: "validate(string)")
-
- sub_field(:auth, struct(), authorized_fields: true) do
- field(:action, String.t(), derive: "validate(not_empty)")
-
- sub_field(:path, struct()) do
- field(:name, String.t())
- end
- end
- end
- end
-
- TestAuthorizeKeys.builder(%{name: "Shahryar", test: "test"})
- # Ouput: `{:error, :authorized_fields, [:test]}`
-
- TestAuthorizeKeys.builder(%{name: "Shahryar", auth: %{action: "admin", test: "test"}})
- # Ouput: `{:error, [%{field: :auth, errors: {:authorized_fields, [:test]}}]}`
- ```
-
- 12. #### Call external struct/structs module
-
- This option can be helpful for you if you wish to construct your own modules in various files
- and then make those modules reusable in the future. Simply implement the macro in another module,
- and then call that module from the `field` macro. The `struct` and `structs` options are the
- ones in which the module can be placed. The first one will provide you with an indication that you
- will be given a map, and the second one will provide you with a list of maps.
-
-
- ```elixir
- defmodule TestAuthStruct do
- use GuardedStruct
-
- guardedstruct do
- field(:action, String.t(), derive: "validate(not_empty)")
- end
- end
-
- defmodule TestOnValueStruct do
- use GuardedStruct
-
- guardedstruct do
- field(:name, String.t(), derive: "validate(string)")
- field(:auth_path, struct(), struct: TestAuthStruct)
- # field(:auth_path, struct(), structs: TestAuthStruct)
- end
- end
- ```
-
- 13. #### List of structs
-
- As was discussed in the earlier available choices. In the `field` macro that is used to
- call **another module**, as well as in the `sub_field` macro, you have the ability to retrieve
- a list of structs rather than a single struct.
-
- ```elixir
- defmodule TestUserAuthStruct do
- use GuardedStruct
-
- guardedstruct do
- field(:name, String.t(), derive: "validate(not_empty)")
- field(:auth_path, struct(), structs: TestAuthStruct)
-
- sub_field(:profile, list(struct()), structs: true) do
- field(:github, String.t(), enforce: true, derive: "validate(url)")
- field(:nickname, String.t(), derive: "validate(not_empty)")
- end
- end
- end
-
- TestUserAuthStruct.builder(%{
- name: "mishka",
- auth_path: [
- %{action: "*:admin", path: %{role: "1"}},
- %{action: "*:user", path: %{role: "3"}}
- ]
- })
-
- # OR
- TestUserAuthStruct.builder(%{
- name: "mishka",
- auth_path: [
- %{action: "*:admin", path: %{role: "1"}},
- %{action: "*:user", path: %{role: "3", rel: %{social: "github"}}}
- ],
- profile: [%{github: "https://github.com/mishka-group"}]
- })
- ```
-
- 14. #### Struct information function
-
- You will need to include a function known as `__information__()` in each and every module
- that you develop for your very own `structs`. This function will store a variety of information, such as keys,
- callers, and so on.
-
- **Note:** There is a possibility that further information will be added to this function; please check its
- output after each update.
-
- **Note:** If you call another Struct module within the `field` macro, you should not use
- the `caller` key within this function. This is due to the fact that the constructor information
- is only available during **compile** time, and not run time.
-
- ```elixir
- TestStruct.__information__()
- ```
-
- 15. #### Transmitting whole output of builder function to its children
-
- Because new keys have been added, such as `auto`, `on`, and `from` which will be explained
- in more detail below. The `builder` function is available in the following two different styles.
-
- > If you don't provide the `:root` key, you can just specify the child key,
- but if you do, you have to send the entire map as an `attar`. This is something to keep in mind.
-
-
- ```elixir
- def builder(attrs, error)
-
- def builder({key, attrs} = input, error)
- when is_tuple(input) and is_map(attrs) and is_list(key) do
- ...
- end
- ```
-
- 16. #### Auto core key
-
- Even if the user transmits the information and it is already in the input, such as with the ID field,
- the sequence of fields still has to be formed automatically. You can accomplish what you want to with
- the help of the `auto` option.
-
- > As you can see in the code below, we have several types of `auto` option calls
-
- ---
-
- > When the core keys are called, the entire primary map is sent to each child.
-
- ```elixir
- defmodule TestAutoValueStruct do
- use GuardedStruct
-
- guardedstruct do
- field(:username, String.t(), derive: "validate(not_empty)")
- field(:user_id, String.t(), auto: {Ecto.UUID, :generate})
- field(:parent_id, String.t(), auto: {Ecto.UUID, :generate})
-
- sub_field(:profile, struct()) do
- field(:id, String.t(), auto: {Ecto.UUID, :generate})
- field(:nickname, String.t(), derive: "validate(not_empty)")
-
- sub_field(:social, struct()) do
- field(:id, String.t(), auto: {TestAutoValueStruct, :create_uuid, "test-path"})
- field(:skype, String.t(), derive: "validate(string)")
- field(:username, String.t(), from: "root::username")
- end
- end
-
- sub_field(:items, struct(), structs: true) do
- field(:id, String.t(), auto: {Ecto.UUID, :generate})
- field(:something, String.t(), derive: "validate(string)", from: "root::username")
- end
- end
-
- def create_uuid(default) do
- UUID.generate() <> "-\#{default}\"
- end
- end
- ```
-
- > **Note**: When changing a record in the database, for example, you might need to make sure that a particular
- > piece of data does not get overwritten by an automatic piece of data if one already exists.
- > To find a solution to this issue, you will need to invoke the `builder` function in the following manner.
-
- ```elixir
- TestModule.builder({:root, %{username: "mishka", user_id: "test_not_to_be_replaced"}, :edit})
- ```
-
- The desired key can be derived from the information that was supplied by the user,
- and it is stored in the first entry of the `Tuple`. If it is `:root` or `[:root]`, it indicates that the entire
- data set is being referred to, and if it is a special key that must be valued as a list,
- it indicates that the `builder` will begin its operation from that particular key.
- It is important to notice that the key has to be `sub_field` if the path is chosen to be displayed.
-
- 17. #### On core key
-
- With the aid of this option, you can make the presence of a field dependent on the presence of another field and,
- if there is no error, produce an error message.
-
- If you pay attention to the routing method, the routing will start from the sent map itself
- if `:root` is specified, but if it is not used, the routing will start from the received
- map in the child if it is not used.
-
- > When the core keys are called, the entire primary map is sent to each child.
-
- ##### Note:
-
- > By default, `on` core key is called when the value of the calling field is sent;
- > To force the field to be non-empty, you must use enforce.
-
- ```elixir
- defmodule TestOnValueStruct do
- use GuardedStruct
-
- guardedstruct do
- field(:name, String.t(), derive: "validate(string)")
-
- sub_field(:profile, struct()) do
- field(:id, String.t(), auto: {Ecto.UUID, :generate})
- field(:nickname, String.t(), on: "root::name", derive: "validate(string)")
- field(:github, String.t(), derive: "validate(string)")
-
- sub_field(:identity, struct()) do
- field(:provider, String.t(), on: "root::profile::github", derive: "validate(string)")
- field(:id, String.t(), auto: {Ecto.UUID, :generate})
- field(:rel, String.t(), on: "sub_identity::auth_path::action")
-
- sub_field(:sub_identity, struct()) do
- field(:id, String.t(), auto: {Ecto.UUID, :generate})
- field(:auth_path, struct(), struct: TestAuthStruct)
- end
- end
- end
-
- sub_field(:last_activity, list(struct()), structs: true) do
- field(:action, String.t(), enforce: true, derive: "validate(string)", on: "root::name")
- end
- end
- end
- ```
- 18. #### From core key
-
- You can select this alternative if you require any data that was delivered in another key
- to be incorporated into the key that you are looking for. If the key is present, the data
- associated with it will be copied; however, if the key is not there, the data in and of itself will be retained.
-
- If you pay attention to the routing method, the routing will start from the sent map itself
- if `:root` is specified, but if it is not used, the routing will start from the received map
- in the child if it is not used.
-
- ---
-
- > When the core keys are called, the entire primary map is sent to each child.
-
- > Note: It is possible that you will need to check that the field you wish to duplicate exists,
- and in order to do so, you can use either the `on` key or the `enforce` option.
-
- ```elixir
- defmodule TestAutoValueStruct do
- use GuardedStruct
-
- guardedstruct do
- field(:username, String.t(), derive: "validate(not_empty)")
- field(:user_id, String.t(), auto: {Ecto.UUID, :generate})
- field(:parent_id, String.t(), auto: {Ecto.UUID, :generate})
-
- sub_field(:profile, struct()) do
- field(:id, String.t(), auto: {Ecto.UUID, :generate})
- field(:nickname, String.t(), derive: "validate(not_empty)")
-
- sub_field(:social, struct()) do
- field(:id, String.t(), auto: {TestAutoValueStruct, :create_uuid, "test-path"})
- field(:skype, String.t(), derive: "validate(string)")
- field(:username, String.t(), from: "root::username")
- end
- end
-
- sub_field(:items, struct(), structs: true) do
- field(:id, String.t(), auto: {Ecto.UUID, :generate})
- field(:something, String.t(), derive: "validate(string)", from: "root::username")
- end
- end
-
- def create_uuid(default) do
- UUID.generate() <> "-\#{default}\"
- end
- end
- ```
-
- 19. #### Domain core key
-
- When dealing with a structure that is heavily nested, it is occasionally necessary
- to establish the permitted range of values for a set of parameters based on the
- input provided by a parent.
- Note that similar to earlier parts, we do not transfer the entirety of either
- the `Struct` or the `Map` to this feature in this particular section.
- Always keep in mind the top-down structure, often known as the parent-to-child relationship.
-
- ```elixir
- defmodule AllowedParentDomain do
- use GuardedStruct
-
- guardedstruct authorized_fields: true do
- field(:username, String.t(),
- domain: "!auth.action=String[admin, user]::?auth.social=Atom[banned]",
- derive: "validate(string)"
- )
-
- field(:type_social, String.t(),
- domain: "?auth.type=Map[%{name: \"mishka\"}, %{name: \"mishka2\"}]",
- derive: "validate(string)"
- )
-
- sub_field(:auth, struct(), authorized_fields: true) do
- field(:action, String.t(), derive: "validate(not_empty)")
- field(:social, atom(), derive: "validate(atom)")
- field(:type, map(), derive: "validate(map)")
- end
- end
- end
- ```
-
- **Please see the `domain` core key, for example:**
-
- ```elixir
- domain: "!auth.action=String[admin, user]::?auth.social=Atom[banned]"
- ```
-
- **In this part:**
- - If `username` key is sent you must have `auth.action` path which is string `admin` or string `user`
- - If `username` key is sent you you can have `auth.social` path which is just atom `:banned`
- - So the `auth.social` can be nil and inside user input impossible nil
-
- **Note**: Within this section of the core keys, we are making use of the `:enum` Derive.
- You are free to make advantage of any and all of the amenities that this Derive provides.
-
- ---
-
- **Note:**:
-
- It is important to think about the fact that the `domain` core key does not
- consider any update of the `auto` core key and instead examines the data that was initially entered in the `builder`.
- The information that was entered is not altered in any way by this function; it is merely validating it.
-
- ---
-
- 19. #### Domain core key with `equal` and `either` support
-
- This component supplies all of the facilities that are necessary to be able to utilize the
- two keys labeled `equal` and `either`, but because of a little interference, its style is
- different from the original style of each of these keys, and you are required to adhere to
- these guidelines. Play can be found in this section.
-
- ##### Example for `equal`
-
- ```elixir
- "?auth.equal=Equal[Atom>>name]"
- ```
-
- ##### Example for `either`
-
- ```elixir
- domain: "?auth.either=Either[string, enum>>Integer[1>>2>>3]]"
- ```
-
- **Note**: As you can see, the `>>` indicator has been utilized in this area,
- despite the fact that it was not included in the first version of these validations.
-
- 20. #### Domain core key with Custom function support
-
- Imagine that you have a function that determines for you whether or not the data that has been sent is valid.
-
- **Note**: the function is required to have an input.
- **Note**: the function must return either true or false.
- **Note**: When writing code for the module, do not utilize aliases; instead, write the module's complete path.
-
- ```elixir
- defmodule AllowedParentCustomDomain do
- use GuardedStruct
- @module_path "MishkaDeveloperToolsTest.GuardedStructTest.AllowedParentCustomDomain"
-
- guardedstruct authorized_fields: true do
- field(:username, String.t(),
- domain: "!auth.action=Custom[\#{@module_path\}, is_stuff?]",
- derive: "validate(string)"
- )
-
- sub_field(:auth, struct(), authorized_fields: true) do
- field(:action, String.t(), derive: "validate(not_empty)")
- end
- end
-
- def is_stuff?(data) when data == "ok", do: true
- def is_stuff?(_data), do: false
- end
- ```
-
- **Note**: if you want to use `custom` inside `derive` validation, you should do like this:
-
- ```elixir
- defmodule TestCustomValidationDerive do
- use GuardedStruct
-
- guardedstruct authorized_fields: true do
- field(:status, String.t(), derive: "validate(custom=[\#{__MODULE__}, is_stuff?])")
- end
-
- def is_stuff?(data) when data == "ok", do: true
- def is_stuff?(_data), do: false
- end
- ```
-
- **Note**: You can see when you use it inside a derive, the GuardedStruct calculates the you module `alias`.
-
- 21. #### Conditional fields
-
- One of the unique capabilities of this macro is the ability to define conditions
- and differentiate between the various kinds of `fields`. Assume that you want the `social`
- field to be able to take both a value `string` and a `map` where `address` and `provider`
- are included in the `map`.
- It is important to notice that the `conditional_field` contained within this macro have
- the capability of supporting `sub_field`. You can look at some illustrations down below.
-
- Note: Please read this if you want to document any conditional fields for your API.
- For instance, your front team ought to be aware of which area of the output is for.
- You have the option of adding the `hint` keyword in accordance with the aforementioned code.
- And the clue is in your practice here.
-
- **Output of hint**: `__hint__`
-
- ```elixir
- defmodule ConditionalFieldComplexTest do
- use GuardedStruct
- alias ConditionalFieldValidatorTestValidators, as: VAL
-
- guardedstruct do
- field(:provider, String.t())
-
- sub_field(:profile, struct()) do
- field(:name, String.t(), enforce: true)
- field(:family, String.t(), enforce: true)
-
- conditional_field(:address, any()) do
- field(:address, String.t(), hint: "address1", validator: {VAL, :is_string_data})
-
- sub_field(:address, struct(), hint: "address2", validator: {VAL, :is_map_data}) do
- field(:location, String.t(), enforce: true)
- field(:text_location, String.t(), enforce: true)
- end
-
- sub_field(:address, struct(), hint: "address3", validator: {VAL, :is_map_data}) do
- field(:location, String.t(), enforce: true, derive: "validate(string, location)")
- field(:text_location, String.t(), enforce: true)
- field(:email, String.t(), enforce: true)
- end
- end
- end
-
- conditional_field(:product, any()) do
- field(:product, String.t(), hint: "product1", validator: {VAL, :is_string_data})
-
- sub_field(:product, struct(), hint: "product2", validator: {VAL, :is_map_data}) do
- field(:name, String.t(), enforce: true)
- field(:price, integer(), enforce: true)
-
- sub_field(:information, struct()) do
- field(:creator, String.t(), enforce: true)
- field(:company, String.t(), enforce: true)
-
- conditional_field(:inventory, integer() | struct(), enforce: true) do
- field(:inventory, integer(),
- hint: "inventory1",
- validator: {VAL, :is_int_data},
- derive: "validate(integer, max_len=33)"
- )
-
- sub_field(:inventory, struct(), hint: "inventory2", validator: {VAL, :is_map_data}) do
- field(:count, integer(), enforce: true)
- field(:expiration, integer(), enforce: true)
- end
- end
- end
- end
- end
- end
- end
- ```
-
- Call the builder
-
- ```elixir
- ConditionalFieldComplexTest.builder(%{
- provider: "Mishka",
- profile: %{
- name: "Shahryar",
- family: "Tavakkoli",
- address: %{
- location: "geo:48.198634,-16.371648,3.4;crs=wgs84;u=40.0",
- text_location: "Nowhere",
- email: "shahryar@mishka.tools"
- }
- },
- product: %{
- name: "MishkaDeveloperTools",
- price: 0,
- information: %{
- creator: "Shahryar Tavakkoli",
- company: "mishka group",
- inventory: %{
- count: 3_000_000,
- expiration: 33
- }
- }
- }
- })
- ```
-
- 22. #### List Conditional fields
-
- The `conditional_fields` is one of the most important aspects of this macro, which is available
- to the programmer in all of its many variants. Typically, you have the ability to send a map
- through the `builder`. If the map is compliant with one of the requirements, your output will be returned.
- Additionally, you have the ability to transmit the value of one of the keys related to the map in the form of a list.
- Now, with this option, you are able to transmit the complete entry as a list.
- In addition, you are able to send one of the items on this list as another list,
- and nesting functionality has been made available to you.
-
- ```elixir
- conditional_field(:activities, any(), structs: true) do
- field(:activities, struct(), struct: ExtrenalConditional, validator: {VAL, :is_map_data}, hint: "activities1")
-
- field(:activities, struct(), structs: ExtrenalConditional, validator: {VAL, :is_list_data}, hint: "activities2")
-
- field(:activities, String.t(), hint: "activities3", validator: {VAL, :is_string_data})
- end
- ```
- As you can see in the code above, you only need to give the macro the `structs: true` option
-
- ##### Note:
-
- > Using a list `conditional_field` in a nested list can create a logical bug for you if the list is not flattened, **Please test your builder before releasing to production**.
- """
- defmacro guardedstruct(opts \\ [], do: block) do
- ast = register_struct(block, opts, :root, __CALLER__.module)
- is_error = !is_nil(Keyword.get(opts, :error))
- # It helps you create module inside module to define types
- case opts[:module] do
- nil ->
- quote do
- # Create a lexical scope.
- (fn -> unquote(ast) end).()
-
- if unquote(is_error), do: GuardedStruct.create_error_module()
- end
-
- module ->
- quote do
- defmodule unquote(module) do
- unquote(ast)
-
- if unquote(is_error), do: GuardedStruct.create_error_module()
- end
- end
- end
- end
-
- ####################################################################
- ################### (▰˘◡˘▰) Macros (▰˘◡˘▰) ###################
- ####################################################################
-
- @spec create_error_module() :: Macro.t()
- @doc false
- defmacro create_error_module() do
- quote do
- defmodule Error do
- defexception [:term, :errors]
-
- @impl true
- def message(exception) do
- """
- #{translated_message(:message_exception)}
- Term: #{inspect(exception.term)}
- Errors: #{inspect(exception.errors)}
- """
- end
- end
- end
- end
-
- @spec __type__(any(), keyword()) :: Macro.t()
- @doc false
- defmacro __type__(types, opts) do
- if Keyword.get(opts, :opaque, false) do
- quote bind_quoted: [types: types] do
- @opaque t() :: %__MODULE__{unquote_splicing(types)}
- end
- else
- quote bind_quoted: [types: types] do
- @type t() :: %__MODULE__{unquote_splicing(types)}
- end
- end
- end
-
- @spec field(atom(), any(), keyword()) :: Macro.t()
- @doc false
- defmacro field(name, type, opts \\ []) do
- quote bind_quoted: [name: name, type: Macro.escape(type), opts: opts] do
- GuardedStruct.__field__(name, type, opts, __ENV__, false)
- end
- end
-
- @spec sub_field(atom(), any(), keyword(), [{:do, any()}]) :: Macro.t()
- @doc false
- defmacro sub_field(name, type, opts \\ [], do: block) do
- ast = register_struct(block, opts, name, __CALLER__.module)
- type = Macro.escape(type)
- is_error = !is_nil(Keyword.get(opts, :error))
-
- quote do
- %{name: module_name, cond?: _cond?} =
- Module.get_attribute(__ENV__.module, :gs_conditional_fields)
- |> GuardedStruct.sub_conditional_field_module(unquote(name), __ENV__)
-
- GuardedStruct.__field__(unquote(name), unquote(type), unquote(opts), __ENV__, true)
-
- defmodule module_name do
- unquote(ast)
-
- if unquote(is_error), do: GuardedStruct.create_error_module()
- end
- end
- end
-
- @spec create_builder(Macro.Env.t()) :: Macro.t()
- @doc false
- defmacro create_builder(%Macro.Env{module: module}) do
- exists_validator?(module, :main_validator, :gs_main_validator)
- exists_validator?(module, :validator, :gs_validator, 2)
-
- escaped_list =
- List.delete(@temporary_revaluation, :gs_types)
- |> Enum.map(&Macro.escape(Module.get_attribute(module, &1)))
-
- quote do
- def builder(attrs, error \\ false)
-
- def builder({key, attrs} = input, error)
- when is_tuple(input) and (is_map(attrs) or is_struct(attrs)) and
- (is_list(key) or is_atom(key)) do
- attrs = if(is_struct(attrs), do: Map.from_struct(attrs), else: attrs)
-
- GuardedStruct.builder(
- %{attrs: attrs, module: unquote(module), revaluation: unquote(escaped_list)},
- key,
- :add,
- error
- )
- end
-
- def builder({key, attrs, type} = input, error)
- when is_tuple(input) and (is_map(attrs) or is_struct(attrs)) and
- (is_list(key) or is_atom(key)) do
- attrs = if(is_struct(attrs), do: Map.from_struct(attrs), else: attrs)
-
- GuardedStruct.builder(
- %{attrs: attrs, module: unquote(module), revaluation: unquote(escaped_list)},
- key,
- type,
- error
- )
- end
-
- def builder(attrs, error) when is_map(attrs) or is_struct(attrs) do
- attrs = if(is_struct(attrs), do: Map.from_struct(attrs), else: attrs)
-
- GuardedStruct.builder(
- %{attrs: attrs, module: unquote(module), revaluation: unquote(escaped_list)},
- :root,
- :add,
- error
- )
+ guardedstruct enforce: true do
+ field :name, String.t()
+ field :title, String.t(), default: "untitled"
+ end
end
- def builder(_attrs, _error) do
- err = %{message: translated_message(:builder), action: :bad_parameters}
+ MyStruct.builder(%{name: "Mishka"})
+ # => {:ok, %MyStruct{name: "Mishka", title: "untitled"}}
- {:error, err}
- end
-
- def enforce_keys() do
- unquote(Enum.at(escaped_list, 2))
- end
+ ## Atom-attack safety
- def enforce_keys(:all) do
- GuardedStruct.show_nested_keys(unquote(module), :enforce_keys)
- end
+ GuardedStruct accepts both atom-keyed and string-keyed input maps for
+ convenience (e.g. JSON payloads come with string keys). The runtime
+ must convert string keys to atoms to match your declared field names —
+ and that conversion is the classic atom-table-exhaustion DoS vector
+ in Elixir.
- def enforce_keys(key) do
- Enum.member?(unquote(Enum.at(escaped_list, 2)), key)
- end
+ ### How GuardedStruct defends — two layers
- def keys() do
- unquote(List.first(escaped_list) |> Enum.map(&elem(&1, 0))) |> Enum.reverse()
- end
+ **Layer 1.** `Parser.convert_to_atom_map/2` uses `String.to_existing_atom/1`
+ rather than `String.to_atom/1`. String keys are converted ONLY if the
+ atom already exists (i.e. matches a `field`/`sub_field`/`conditional_field`
+ declaration elsewhere in your codebase). Unknown / attacker-controlled
+ keys stay as strings — they cannot grow the atom table.
- def keys(:all) do
- GuardedStruct.show_nested_keys(unquote(module))
- end
+ **Layer 2.** `dynamic_field` values are **identity-preserved** —
+ whatever map you submit (string keys, atom keys, mixed, nested) is
+ byte-identical to what comes back from `builder/1`. No key conversion
+ at any depth.
- def keys(key) do
- Enum.member?(unquote(List.first(escaped_list) |> Enum.map(&elem(&1, 0))), key)
+ defmodule Doc do
+ use GuardedStruct
+ guardedstruct do
+ field :id, String.t(), enforce: true
+ dynamic_field :metadata
+ end
end
- def __information__() do
- info = unquote(List.last(escaped_list) |> List.first())
+ Doc.builder(%{id: "x", metadata: %{"foo" => 1, :bar => 2, "baz" => %{"nested" => 3}}})
+ # => {:ok, %Doc{id: "x", metadata: %{"foo" => 1, :bar => 2, "baz" => %{"nested" => 3}}}}
+ # ↑ ↑ ↑
+ # string stays atom stays deep nested string STAYS
- path =
- if(Map.get(info, :key) == :root,
- do: [],
- else:
- info.module
- |> Module.split()
- |> GuardedStruct.reverse_module_keys(info.key)
- )
+ ### How to consume `dynamic_field` values safely
- conds = Enum.at(unquote(escaped_list), 9) |> Enum.map(&elem(&1, 0)) |> Enum.uniq()
+ When the input came from JSON / any untrusted source, your dynamic_field
+ ends up with string keys exactly as the sender wrote them:
- fields = %{
- path: path,
- keys: keys(),
- enforce_keys: enforce_keys(),
- conditional_keys: conds
- }
-
- Map.merge(info, fields)
+ def receive(%{"id" => id, "metadata" => meta}) do
+ {:ok, doc} = Doc.builder(%{id: id, metadata: meta})
+ name = doc.metadata["customer_name"] # ← read with string keys
+ plan = doc.metadata["plan_tier"]
end
- end
- end
-
- @spec delete_temporary_revaluation(Macro.Env.t()) :: :ok
- @doc false
- defmacro delete_temporary_revaluation(%Macro.Env{module: module}) do
- Enum.each(unquote(@temporary_revaluation), &Module.delete_attribute(module, &1))
- end
- @spec conditional_field(atom(), any(), keyword(), [{:do, any()}]) :: Macro.t()
- @doc false
- defmacro conditional_field(name, type, opts \\ [], do: block) do
- # type = Macro.escape(quote do: struct())
- type = Macro.escape(type)
- Parser.parser(block, :conditional)
+ If you need atom keys for ergonomics (e.g. `doc.metadata.foo`
+ dot-access), convert AT THE BOUNDARY where you know which keys are
+ safe:
- quote do
- GuardedStruct.__field__(unquote(name), unquote(type), unquote(opts), __ENV__, true, true)
- unquote(block)
- end
- end
+ safe_keys = ~w(customer_name plan_tier signup_source)a # ← compile-time list
- ####################################################################
- ############## (▰˘◡˘▰) Action Functions (▰˘◡˘▰) ##############
- ####################################################################
-
- # +-------------------+
- # | |
- # | GuardedStruct |
- # | |
- # +---------+---------+
- # |
- # +-------v--------+
- # | |
- # | __type__ |
- # | |
- # +-------+--------+
- # |
- # +--------------+ | +-----------------+
- # | | | | |
- # | field +-----+------+ sub_field +----+
- # | | | | | |
- # +--------------+ | +-----------------+ |
- # | |
- # | |
- # +---------v-----------+ +--------+ | +-------------+
- # | | | | | | | |
- # | convert_to_atom_map <---+ | field +--+--+ sub_field |
- # | | | | | | | | |
- # +---------+-----------+ | +--------+ | +-------------+
- # | | |
- # +---------v------------+ | |
- # +-+ before_revaluation | | |
- # | +----------------------+ | |
- # | +-------------+
- # |
- # +----------v-----------+ +----------------+ +-------------+
- # | | | | | | |
- # | +-------v---------+ | | +------------v-------------+ | +-------v-------+
- # | | auto_core_key | | | | | | | Derive.derive |
- # | +-------+---------+ | | | +-------------------+ | | +-------+-------+
- # | | | | | | authorized_fields | | | |
- # | +-------v---------+ | | | +---------+---------+ | | +---------v-----------+
- # | | domain_core_key | | | | | | | | exceptions_handler |
- # | +-------+---------+ | | | +--------v--------+ | | +---------------------+
- # | | | | | | required_fields | | |
- # | +-------v--------+ | | | +--------+--------+ | |
- # | | on_core_key | | | | | | |
- # | +-------+--------+ | | | +----------v-----------+ | |
- # | | | | | | sub_fields_validating| | |
- # | +-------v--------+ | | | +----------+-----------+ | |
- # | | from_core_key | | | | | | |
- # | +----------------+ | | | +--------v---------+ | |
- # | | | | |fields_validating | | |
- # | | | | +--------+---------+ | |
- # +---------+------------+ | | | | |
- # | | | +--------v---------+ | |
- # | | | | main_validating | | |
- # +--------------+ | +------------------+ | |
- # | | |
- # +-----------+--------------+ |
- # | |
- # +------------------+
-
- @spec register_struct(any(), nil | maybe_improper_list() | map(), atom(), module()) :: Macro.t()
- @doc false
- def register_struct(block, opts, key, caller) do
- quote do
- Enum.each(unquote(@temporary_revaluation), fn attr ->
- Module.register_attribute(__MODULE__, attr, accumulate: true)
- end)
+ atomized =
+ for k <- safe_keys, into: %{} do
+ {k, Map.get(doc.metadata, Atom.to_string(k))}
+ end
- Module.put_attribute(__MODULE__, :gs_enforce?, unquote(!!opts[:enforce]))
+ That converts only the keys YOU declared in source — the atom table
+ cannot grow from user input regardless of what the request body
+ contains.
- Module.put_attribute(
- __MODULE__,
- :gs_caller,
- %{key: unquote(key), module: __MODULE__, caller: unquote(caller)}
- )
+ ### What NOT to do
- Module.put_attribute(__MODULE__, :gs_authorized_fields, unquote(!!opts[:authorized_fields]))
+ # ❌ NEVER do this on user-controlled maps:
+ metadata = doc.metadata |> Map.new(fn {k, v} -> {String.to_atom(k), v} end)
+ # ^^^^^^^^^^^^^^^^^
+ # creates a new atom from EVERY key the user sent.
- main_validator = unquote(opts[:main_validator])
+ The library protects you on the way IN. Don't undo that protection on
+ the way OUT.
- if !is_nil(main_validator) && is_tuple(main_validator) do
- Module.put_attribute(__MODULE__, :gs_main_validator, main_validator)
- end
+ ### Reporting a vulnerability
- if !is_nil(main_validator) && (!is_tuple(main_validator) or tuple_size(main_validator) != 2) do
- raise(ArgumentError, translated_message(:register_struct))
- end
+ See `SECURITY.md` for the security policy and how to report.
+ """
- @before_compile {unquote(__MODULE__), :create_builder}
- @before_compile {unquote(__MODULE__), :delete_temporary_revaluation}
+ use Spark.Dsl, default_extensions: [extensions: [GuardedStruct.Dsl]]
- import GuardedStruct
- # Leave the block with its orginal face
- unquote(block)
+ defmacro __using__(opts) do
+ super_opts = Keyword.drop(opts, [:derive_extensions])
+ super_ast = super(super_opts)
- # Point what field should be required
- @enforce_keys @gs_enforce_keys
- defstruct @gs_fields
+ derive_extensions_opt =
+ opts
+ |> Keyword.get(:derive_extensions)
+ |> resolve_extension_aliases(__CALLER__)
- # Create type `t()` with `@opaque` option
- GuardedStruct.__type__(@gs_types, unquote(opts))
- end
- end
+ # Validate at compile time so typos / bad shapes fail loudly here, not
+ # silently at the first builder/1 call.
+ GuardedStruct.Derive.Extension.validate_opt!(derive_extensions_opt)
- @spec __field__(atom(), any(), keyword(), Macro.Env.t(), boolean(), boolean()) :: nil | :ok
- @doc false
- def __field__(name, type, opts, env_data, subfield, cond? \\ false)
+ derive_extensions_ast = Macro.escape(derive_extensions_opt)
- def __field__(name, type, opts, %Macro.Env{module: mod} = _env, sub_field, cond?)
- when is_atom(name) do
- gs_fields = Module.get_attribute(mod, :gs_fields)
- gs_conditional = Module.get_attribute(mod, :gs_conditional_fields)
+ quote do
+ unquote(super_ast)
+ import GuardedStruct.Dsl, only: []
+ import GuardedStruct, only: [guardedstruct: 1, guardedstruct: 2]
- # We check if this field is already set and it is not conditional type, so should send error to user
- if Keyword.has_key?(gs_fields, name) and !Keyword.has_key?(gs_conditional, name) do
- raise ArgumentError, translated_message(:field, name)
- end
+ @__guarded_derive_extensions_opt__ unquote(derive_extensions_ast)
- # If for this name, there is no record which be submitted
- if !Keyword.has_key?(gs_conditional, name) do
- config(:core_keys, opts, mod, name)
- config(:derive, opts, mod, name)
- config(:struct, opts, sub_field, mod, name)
- config(:fields_types, opts, mod, name, type)
+ @doc false
+ def __guarded_derive_extensions_opt__, do: @__guarded_derive_extensions_opt__
end
-
- # In this line, we should update conditional moduale attributes
- if cond? or Keyword.has_key?(gs_conditional, name),
- do: config(:conditional, opts, mod, name, Keyword.get(gs_conditional, name), sub_field)
- end
-
- def __field__(name, _type, _opts, _env, _sub_field, _cond?) do
- raise ArgumentError, translated_message(:field_type, name)
- end
-
- @spec builder(
- %{
- :attrs => map(),
- :module => module(),
- :revaluation => list(),
- optional(any()) => any()
- },
- :root | list(atom()),
- :add | :edit,
- boolean()
- ) :: {:ok, map() | list(map())} | {:error, any()}
- @doc false
- def builder(actions, key, type, error \\ false) do
- %{attrs: attrs, module: module, revaluation: [h | t]} = actions
-
- [
- sub_fields,
- enforce_keys,
- validator,
- main_validator,
- derives,
- authorized_fields,
- external,
- core_keys,
- conditional_fields,
- _caller
- ] = t
-
- found_main_validator = Enum.find(main_validator, &is_tuple(&1))
- fields = Enum.map(h, &elem(&1, 0))
-
- attrs
- |> before_revaluation(key)
- |> authorized_fields(fields, authorized_fields)
- |> required_fields(enforce_keys)
- |> Parser.convert_to_atom_map()
- |> auto_core_key(core_keys, type)
- |> domain_core_key(attrs)
- |> on_core_key(attrs)
- |> from_core_key()
- |> conditional_fields_validating(conditional_fields, type, key)
- |> sub_fields_validating(fields, module, sub_fields, external, key, type)
- |> fields_validating(validator, module)
- |> main_validating(found_main_validator, main_validator, module)
- |> replace_condition_fields_derives(derives)
- |> Derive.derive()
- |> exceptions_handler(module, error)
end
- defp before_revaluation(attrs, :root), do: attrs
+ # When the `derive_extensions:` opt is passed in source as
+ # `[Foo.Bar, :config]`, Elixir hands the macro the AST form
+ # `[{:__aliases__, _, [:Foo, :Bar]}, :config]`. We resolve aliases via
+ # `Macro.expand/2` against the caller's environment — that's the only
+ # way to honour `alias Foo` AND nested-module context (so `LocalDerives`
+ # inside test/X.exs becomes the fully-qualified `Test.X.LocalDerives`).
+ defp resolve_extension_aliases(nil, _caller), do: nil
- defp before_revaluation(attrs, [:root]), do: attrs
-
- defp before_revaluation(attrs, key) when is_list(key) do
- data = get_in(attrs, Parser.map_keys(attrs, key))
- if is_map(data), do: data, else: Map.new([{:bad_parameters, data}])
+ defp resolve_extension_aliases(list, caller) when is_list(list) do
+ Enum.map(list, fn
+ {:__aliases__, _, _} = ast -> Macro.expand(ast, caller)
+ other -> other
+ end)
end
- defp before_revaluation(attrs, key) do
- data = Map.get(attrs, Parser.map_keys(attrs, key))
- if is_map(data), do: data, else: Map.new([{:bad_parameters, data}])
- end
+ defp resolve_extension_aliases(other, _caller), do: other
- @spec authorized_fields(map() | list(), list(atom()), list()) ::
- {:ok, any()} | {:error, list(), :halt}
- @doc false
- def authorized_fields(attrs, fields, authorized) do
- case check_authorized_fields(attrs, fields, authorized) do
- {_, true, _} ->
- {:ok, attrs}
-
- {_, false, filtered} ->
- err = %{
- message: translated_message(:authorized_fields),
- fields: filtered,
- action: :authorized_fields
- }
-
- {:error, err, :halt}
- end
- end
+ @doc "Arity-4 wrapper for `sub_field name, type, opts do … end`."
+ defmacro sub_field(name, type, opts, do_block) when is_list(opts) and is_list(do_block) do
+ merged = opts ++ do_block
- @spec required_fields({:ok, map()} | {:error, any(), :halt}, any()) ::
- {:ok, map()} | {:error, any(), :halt}
- @doc false
- def required_fields({:ok, attrs}, enforces) do
- with missing_keys <- Enum.reject(Parser.map_keys(attrs, enforces), &Map.has_key?(attrs, &1)),
- {:missing_keys, true, _missing_keys} <-
- {:missing_keys, Enum.empty?(missing_keys), missing_keys} do
- {:ok, attrs}
- else
- {:missing_keys, false, missing_keys} ->
- err = %{
- message: translated_message(:required_fields),
- fields: missing_keys,
- action: :required_fields
- }
-
- {:error, err, :halt}
+ quote do
+ sub_field(unquote(name), unquote(type), unquote(merged))
end
end
- def required_fields({:error, _, :halt} = error, _), do: error
-
- defp auto_core_key({:error, _, :halt} = error, _, _), do: error
-
- defp auto_core_key(attrs, core_keys, type) do
- reduce_attrs =
- Enum.filter(core_keys, fn {_key, %{type: type, values: _}} -> type == :auto end)
- |> Enum.reduce(attrs, fn item, acc ->
- case {type, !is_nil(Map.get(acc, elem(item, 0))), item} do
- {:edit, true, {key, %{type: :auto, values: _value}}} ->
- Map.put(acc, key, Map.get(acc, key))
-
- {_, _, {key, %{type: :auto, values: {module, function, default}}}}
- when is_list(default) ->
- Map.put(acc, key, apply(module, function, default))
-
- {_, _, {key, %{type: :auto, values: {module, function, default}}}} ->
- Map.put(acc, key, apply(module, function, [default]))
-
- {_, _, {key, %{type: :auto, values: {module, function}}}} ->
- Map.put(acc, key, apply(module, function, []))
-
- _ ->
- acc
- end
- end)
-
- {reduce_attrs, core_keys}
- end
-
- defp domain_core_key({:error, _, :halt} = error, _), do: error
-
- defp domain_core_key({attrs, core_keys}, full_attars) do
- # It is important to think about the fact that the `domain` core key does not
- # consider any update of the `auto` core key and instead examines the data that was initially entered in the `builder`.
- # The information that was entered is not altered in any way by this function; it is merely validating it.
- domain_parameters_errors =
- Enum.map(core_keys, fn
- {key, %{type: :domain, values: pattern}} ->
- parsed =
- parse_domain_patterns(pattern, key, full_attars, attrs)
- |> List.flatten()
-
- if length(parsed) == 0, do: nil, else: parsed
-
- _ ->
- nil
- end)
- |> Enum.reject(&is_nil(&1))
- |> List.flatten()
+ @doc "Arity-4 wrapper for `conditional_field name, type, opts do … end`."
+ defmacro conditional_field(name, type, opts, do_block)
+ when is_list(opts) and is_list(do_block) do
+ merged = opts ++ do_block
- if length(domain_parameters_errors) == 0 do
- {:ok, attrs, core_keys}
- else
- {:error, domain_parameters_errors, :halt}
+ quote do
+ conditional_field(unquote(name), unquote(type), unquote(merged))
end
end
- defp on_core_key({:error, _, :halt} = error, _), do: error
-
- defp on_core_key({:ok, attrs, core_keys}, full_attrs) do
- full_attrs = Parser.convert_to_atom_map(full_attrs)
- dependent_keys_errors = check_dependent_keys(attrs, core_keys, full_attrs)
-
- if length(dependent_keys_errors) == 0,
- do: {:ok, attrs, core_keys, full_attrs},
- else: {:error, dependent_keys_errors, :halt}
- end
-
- defp from_core_key({:error, _, :halt} = error), do: error
-
- defp from_core_key({:ok, attrs, core_keys, full_attrs}) do
- reduce_attrs =
- Enum.filter(core_keys, fn {_key, %{type: type, values: _}} -> type == :from end)
- |> Enum.reduce(attrs, fn {key, %{type: :from, values: pattern}}, acc ->
- splited_pattern = Parser.parse_core_keys_pattern(pattern)
- [h | t] = splited_pattern
-
- if(h == :root, do: get_in(full_attrs, t), else: get_in(attrs, splited_pattern))
- |> case do
- data when is_nil(data) -> acc
- data -> Map.put(acc, key, data)
- end
- end)
-
- {:ok, reduce_attrs, full_attrs}
- end
-
- defp conditional_fields_validating({:error, _, :halt} = error, _, _, _), do: error
-
- defp conditional_fields_validating({:ok, attrs, full_attrs}, conditionals, type, key) do
- {cond_fields, uncond_fields} = conditionals_fields_parameters_divider(attrs, conditionals)
-
- cond_builders =
- Enum.map(cond_fields, fn {field, value} ->
- cond_data = Keyword.get(conditionals, field)
- list_conditional = Keyword.get(cond_data.opts, :structs)
-
- {cond_data, field, value, full_attrs, key, type, list_conditional}
- |> conditional_fields_validating_pattern()
- end)
-
- cond_data = conditionals_fields_data_divider(cond_builders)
- {:ok, uncond_fields, cond_data, full_attrs}
- end
-
- @spec sub_fields_validating(
- {:error, any(), :halt} | {:ok, map(), list(), map() | list()},
- list(atom()),
- module(),
- keyword(),
- keyword(),
- atom(),
- :add | :edit
- ) :: {:error, any(), :halt} | {map(), list(), list(), list(), any()}
- @doc false
- def sub_fields_validating({:error, _, :halt} = error, _, _, _, _, _, _), do: error
-
- def sub_fields_validating(
- {:ok, attrs, conds, full_attrs},
- fields,
- _module,
- sub_fields,
- external,
- key,
- type
- ) do
- allowed_fields = Map.take(attrs, fields) |> Map.keys()
- # TODO: lock 900 nonosec
- sub_modules = get_fields_sub_module(allowed_fields, sub_fields, external)
-
- sub_modules_builders =
- sub_modules
- |> Enum.map(fn
- %{field: field, module: module, type: :list, opts: opts} ->
- {get_field_validator(opts, module, field, Map.get(full_attrs, field)), opts}
- |> Derive.pre_derives_check(opts, field)
- |> case do
- {{:ok, _, sanitized_value}, _} ->
- {field,
- list_builder(Map.put(full_attrs, field, sanitized_value), module, field, key, type)}
-
- {{:error, error}, _opts} ->
- {field, {:error, error}}
-
- {{:error, error}, _field, _opts} ->
- {field, {:error, error}}
- end
-
- %{field: field, module: module, type: :struct, opts: opts} ->
- keys =
- reverse_module_keys(Module.split(module), field)
- |> combine_parent_field(if(is_list(key), do: key, else: [key]))
- |> List.delete(:root)
-
- {get_field_validator(opts, module, field, Map.get(full_attrs, field)), opts}
- |> Derive.pre_derives_check(opts, field)
- |> case do
- {{:ok, _, sanitized_value}, _} ->
- {field, module.builder({keys, Map.put(full_attrs, field, sanitized_value), type})}
-
- {{:error, error}, _opts} ->
- {field, {:error, error}}
-
- {{:error, error}, _field, _opts} ->
- {field, {:error, error}}
- end
- end)
-
- {
- attrs,
- sub_modules_builders_data(sub_modules_builders),
- sub_modules_builders_errors(sub_modules_builders),
- reject_sub_module_fields(allowed_fields, sub_modules),
- conds
- }
- end
-
- @spec fields_validating(
- {:error, any(), :halt} | {map(), map() | list(map()), list(), list(), keyword()},
- any(),
- any()
- ) :: {:error, any(), :halt} | {list(), any(), any(), any(), any()}
- @doc false
- def fields_validating({:error, _, :halt} = error, _, _), do: error
-
- def fields_validating({attrs, sub_data, sub_errors, unsub, conds}, validator, module) do
- # Just keep the normal fields of attrs
- allowed_data = Map.take(attrs, unsub)
-
- validated =
- allowed_data
- |> Enum.map(fn {key, value} ->
- GuardedStruct.find_validator(key, value, validator, module)
- end)
-
- validated_errors =
- Enum.filter(validated, fn {status, _field, _error_or_data} -> status == :error end)
- |> Enum.map(fn {_status, field, error_or_data} ->
- %{field: field, message: error_or_data, action: :validator}
+ @doc """
+ `guardedstruct opts do … end` — top-level options like `enforce: true` or
+ `module: Foo` are lifted into setter calls inside the section body.
+ """
+ defmacro guardedstruct(opts, do: block) when is_list(opts) do
+ block = transform_derive_rules(block)
+ validate_block!(block)
+ block_enforce? = Keyword.get(opts, :enforce, false) == true
+ pre_enforce_keys = extract_enforce_keys(block, block_enforce?)
+ block_aliases = extract_aliases(block)
+
+ setters =
+ Enum.map(opts, fn {key, value} ->
+ {key, [], [value]}
end)
- validated_allowed_data =
- if length(validated_errors) == 0,
- do: convert_list_tuple_to_map(validated),
- else: allowed_data
-
- {validated_errors, validated_allowed_data, sub_data, sub_errors, conds}
- end
-
- @spec main_validating(
- {:error, any()}
- | {:error, any(), :halt}
- | {list(), any(), any(), list(),
- %{:data => any(), :errors => any(), optional(any()) => any()}},
- nil | tuple(),
- list(boolean()),
- module()
- ) ::
- {:error, any()}
- | {:ok, map(), any()}
- | {:error, any(), :halt}
- | {:error, :nested, list(), struct(), any()}
- @doc false
- def main_validating({:error, _, :halt} = error, _, _, _), do: error
-
- def main_validating({:error, _} = error, _, _, _), do: error
-
- def main_validating(validating_input, main_validator, gs_main_validator, module) do
- {validated_errors, validated_allowed_data, sub_data, sub_errors, conds} =
- validating_input
-
- {status, main_outputs} =
- cond do
- length(validated_errors) > 0 ->
- {:error, %{}}
-
- !is_nil(main_validator) ->
- {module, func} = main_validator
- apply(module, func, [validated_allowed_data])
-
- gs_main_validator == [true] ->
- apply(module, :main_validator, [validated_allowed_data])
-
- true ->
- {:ok, validated_allowed_data}
- end
-
- # We summarized the main logic in the following function
- # This helps us to better analyze the output of the conditional fields section
- {status, validated_errors, sub_errors, conds, module, main_outputs, sub_data}
- |> validation_errors_aggregator()
- end
-
- @spec replace_condition_fields_derives(tuple(), list(map())) :: any()
- @doc false
- def replace_condition_fields_derives({:ok, data, conds}, derives) do
- new_derives =
- Enum.reject(derives, &(&1.field in Enum.uniq(Keyword.keys(conds)))) ++
- Derive.get_derives_from_success_conditional_data(conds)
-
- {:ok, data, new_derives}
- end
-
- def replace_condition_fields_derives({:error, :nested, _, _, conds} = error, derives) do
- new_derives =
- Enum.reject(derives, &(&1.field in Enum.uniq(Keyword.keys(conds)))) ++
- Derive.get_derives_from_success_conditional_data(conds)
+ full_block = {:__block__, [], setters ++ [block]}
- error
- |> Tuple.delete_at(4)
- |> Tuple.insert_at(4, new_derives)
- end
-
- def replace_condition_fields_derives({:error, _, data} = error, _) when data == :halt, do: error
-
- def replace_condition_fields_derives({:error, error, data}, derives)
- when data == %{} or derives == [],
- do: {:error, error}
-
- def replace_condition_fields_derives({:error, error, data}, derives) do
- derive_inputs = Enum.filter(derives, &(&1.field in Enum.uniq(Map.keys(data))))
-
- derives_error =
- Derive.derive({:ok, data, derive_inputs})
- |> case do
- {:ok, _} -> []
- {:error, error} -> error
- end
-
- {:error, derives_error ++ error}
- rescue
- _ -> {:error, error}
- end
-
- def replace_condition_fields_derives(error, _derives), do: error
-
- @spec exceptions_handler({:ok, any()} | {:error, any()}, module(), boolean()) ::
- {:ok, any()} | {:error, any()}
- @doc false
- def exceptions_handler(ouput, module, exception \\ false)
-
- def exceptions_handler({:ok, _} = successful_output, _, _), do: successful_output
-
- def exceptions_handler({:error, error, :halt}, _module, false), do: {:error, error}
-
- def exceptions_handler({:error, _errors} = error_output, _module, false), do: error_output
-
- def exceptions_handler({:error, error_list}, module, true) do
- concated = Module.safe_concat([module, Error])
- raise(concated, errors: error_list)
- end
+ quote do
+ require GuardedStruct.Dsl
+ import GuardedStruct, only: [sub_field: 4, conditional_field: 4]
- ####################################################################
- ################### (▰˘◡˘▰) Helpers (▰˘◡˘▰) ##################
- ####################################################################
+ unquote_splicing(block_aliases)
- @spec reverse_module_keys(list(String.t()), atom()) :: list()
- @doc false
- def reverse_module_keys(splited_module, key) do
- path =
- for {_module, idx} <- Enum.with_index(splited_module) do
- Enum.join(Enum.take(splited_module, idx + 1), ".")
+ GuardedStruct.Dsl.guardedstruct do
+ unquote(full_block)
end
- |> Enum.reverse()
- |> tl
- |> Enum.reduce_while([], fn item, acc ->
- concated = Module.concat(String.split(item, ".", trim: true))
-
- {concated, function_exported?(concated, :__information__, 0)}
- |> case do
- {module, true} ->
- module_info = apply(module, :__information__, [])
-
- if(module_info.key == :root,
- do: {:halt, acc},
- else: {:cont, acc ++ [module_info.key]}
- )
-
- _ ->
- {:halt, acc}
- end
- end)
-
- path ++ [key]
- end
- @spec find_validator(atom(), any(), keyword(), module()) :: any()
- @doc false
- def find_validator(field, data, gs_validator, caller_module) do
- case Enum.find(gs_validator, &(&1 != true && &1.field == field)) do
- %{field: key, validator: {module, func}} ->
- apply(module, func, [key, data])
-
- _ ->
- if Enum.member?(gs_validator, true),
- do: caller_module.validator(field, data),
- else: {:ok, field, data}
+ @enforce_keys unquote(pre_enforce_keys)
end
end
- @spec get_fields_sub_module(list(atom()), keyword(), keyword(), boolean()) :: list()
- @doc false
- def get_fields_sub_module(fields, sub_fields, external, list \\ false) do
- Enum.map(fields, fn field ->
- extra_field = Keyword.get(external, field)
-
- {!is_nil(extra_field), Keyword.get(sub_fields, field), extra_field}
- |> case do
- {true, _, %{module: module, opts: opts}} ->
- if !list,
- do: %{field: field, module: module, type: extra_field.type, opts: opts},
- else: field
-
- {false, %{module: module}, _} ->
- if !list,
- do: %{field: field, module: module, type: :struct, opts: []},
- else: field
-
- _ ->
- nil
- end
- end)
- |> Enum.reject(&is_nil(&1))
- end
-
- @spec show_nested_keys(atom() | tuple(), atom()) :: list()
- @doc false
- def show_nested_keys(module, type \\ :keys) do
- apply(module, type, [])
- |> Enum.map(fn item ->
- sub_module = create_module_name(item, module, :direct)
-
- if Code.ensure_loaded?(sub_module) do
- Map.new([{item, show_nested_keys(sub_module)}])
- else
- item
- end
- end)
- end
-
- @spec create_module_name(atom(), Macro.t(), atom()) :: atom()
- @doc false
- def create_module_name(name, module_name, type \\ :macro) do
- name
- |> atom_to_module()
- |> then(&Module.concat(if(type == :macro, do: module_name.module, else: module_name), &1))
- end
-
- @spec config(
- :conditional,
- keyword(),
- module(),
- atom(),
- nil | %{:fields => list(), optional(any()) => any()},
- boolean()
- ) :: :ok
- @doc false
- def config(:conditional, opts, mod, name, nil, _sub?) do
- Module.put_attribute(
- mod,
- :gs_conditional_fields,
- {name,
- %{
- field: name,
- opts: opts,
- fields_count: 0,
- sub_fields_count: 0,
- caller: mod,
- fields: []
- }}
- )
- end
-
- def config(:conditional, opts, mod, name, gs_conditional, true) do
- %{sub_fields_count: sub_fields_count} = gs_conditional
-
- module_number =
- String.to_atom("#{name}#{Integer.to_string(gs_conditional.sub_fields_count + 1)}")
- |> create_module_name(mod, :direct)
-
- list_field? = Keyword.has_key?(opts, :structs)
- field = [%{sub?: true, opts: opts, name: name, module: module_number, list?: list_field?}]
-
- Module.put_attribute(
- mod,
- :gs_conditional_fields,
- {name,
- Map.merge(gs_conditional, %{
- sub_fields_count: sub_fields_count + 1,
- fields: gs_conditional.fields ++ field
- })}
- )
- end
-
- def config(:conditional, opts, mod, name, gs_conditional, false) do
- %{fields_count: fields_count} = gs_conditional
- list_field? = Keyword.has_key?(opts, :structs)
- field = [%{sub?: false, opts: opts, name: name, module: nil, list?: list_field?}]
-
- Module.put_attribute(
- mod,
- :gs_conditional_fields,
- {name,
- Map.merge(gs_conditional, %{
- fields_count: fields_count + 1,
- fields: gs_conditional.fields ++ field
- })}
- )
- end
-
- @spec config(:fields_types | :struct, keyword(), module(), atom(), any()) :: nil | :ok
- @doc false
- def config(:fields_types, opts, mod, name, type) do
- has_default? = Keyword.has_key?(opts, :default)
- enforce_by_default? = Module.get_attribute(mod, :gs_enforce?)
-
- enforce? =
- if is_nil(opts[:enforce]),
- do: enforce_by_default? && !has_default?,
- else: !!opts[:enforce]
-
- nullable? = !has_default? && !enforce?
-
- Module.put_attribute(mod, :gs_fields, {name, opts[:default]})
- Module.put_attribute(mod, :gs_types, {name, type_for(type, nullable?)})
- if enforce?, do: Module.put_attribute(mod, :gs_enforce_keys, name)
- end
+ @doc "`guardedstruct do … end` — no top-level options."
+ defmacro guardedstruct(do: block) do
+ block = transform_derive_rules(block)
+ validate_block!(block)
+ pre_enforce_keys = extract_enforce_keys(block, false)
+ block_aliases = extract_aliases(block)
- def config(:struct, opts, sub_field, mod, name) do
- struct? = Keyword.has_key?(opts, :struct)
-
- if !sub_field and (struct? or Keyword.has_key?(opts, :structs)) do
- Module.put_attribute(
- mod,
- :gs_external,
- {name,
- %{
- module: opts[:struct] || opts[:structs],
- type: if(struct?, do: :struct, else: :list),
- opts: opts
- }}
- )
- end
+ quote do
+ require GuardedStruct.Dsl
+ import GuardedStruct, only: [sub_field: 4, conditional_field: 4]
- if sub_field do
- converted_name = create_module_name(name, mod, :direct)
- Module.put_attribute(mod, :gs_sub_fields, {name, %{module: converted_name, opts: opts}})
+ unquote_splicing(block_aliases)
- if Keyword.get(opts, :structs) do
- Module.put_attribute(
- mod,
- :gs_external,
- {name, %{module: converted_name, type: :list, opts: opts}}
- )
+ GuardedStruct.Dsl.guardedstruct do
+ unquote(block)
end
- end
- end
- @spec config(:core_keys | :derive, keyword(), module(), atom()) :: nil | :ok
- @doc false
- def config(:derive, opts, mod, name) do
- if !is_nil(opts[:derive]),
- do:
- Module.put_attribute(mod, :gs_derive, %{
- field: name,
- derive: opts[:derive]
- })
-
- if !is_nil(opts[:validator]) do
- Module.put_attribute(mod, :gs_validator, %{
- field: name,
- validator: opts[:validator]
- })
+ @enforce_keys unquote(pre_enforce_keys)
end
end
- def config(:core_keys, opts, mod, name) do
- Enum.each([:on, :from, :auto, :domain], fn item ->
- if Keyword.has_key?(opts, item) do
- core_key = %{values: opts[item], type: item}
- Module.put_attribute(mod, :gs_core_keys, {name, core_key})
- end
- end)
- end
-
- @spec sub_conditional_field_module(
- keyword(),
- atom(),
- atom()
- | binary()
- | list()
- | number()
- | {any(), any()}
- | {atom() | {any(), list(), atom() | list()}, keyword(), atom() | list()}
- ) :: %{cond?: boolean(), name: atom()}
- @doc false
- def sub_conditional_field_module(conditionals, name, env) do
- case Keyword.get(conditionals, name) do
- nil ->
- %{name: create_module_name(name, env), cond?: false}
-
- data ->
- module_number = String.to_atom("#{name}#{Integer.to_string(data.sub_fields_count + 1)}")
- %{name: create_module_name(module_number, env), cond?: true}
- end
- end
+ # Every entity type that accepts a `:derives` opt. `@derives "..."` /
+ # `@derive_rules "..."` decorators get consumed by the very next call to
+ # any of these.
+ @decoratable_entities [:field, :sub_field, :conditional_field, :virtual_field, :dynamic_field]
- defp exists_validator?(mod, modfn, attr_name, arity \\ 1) do
- if Module.defines?(mod, {modfn, arity}) do
- Module.put_attribute(mod, attr_name, true)
- end
- end
+ # Walk the block and convert any `@derive_rules "..."` / `@derives "..."`
+ # decorator that sits immediately above a decoratable entity call into an
+ # inline `derives: "..."` opt on that entity. One-shot — consumed by the
+ # very next entity declaration, like `@doc`.
+ defp transform_derive_rules(block) do
+ items =
+ case block do
+ {:__block__, meta, list} -> {:__block__, meta, do_transform_derive_rules(list, nil, [])}
+ single -> List.first(do_transform_derive_rules([single], nil, [])) || single
+ end
- defp convert_list_tuple_to_map(list) do
- Enum.reduce(list, %{}, fn {_, key, value}, acc ->
- Map.put(acc, key, value)
- end)
+ items
end
- defp list_builder(attrs, module, field, key, type, cond_list \\ nil)
-
- defp list_builder(_attrs, nil, field, _key, _type, _cond_list) do
- err = %{message: translated_message(:list_builder), field: field, action: :bad_parameters}
- {:error, err}
- end
+ defp do_transform_derive_rules([], _pending, acc), do: Enum.reverse(acc)
- defp list_builder(_attrs, true, _field, _key, _type, _cond_list) do
- # Developers are advised to use special conditional settings for conditional data that
- # will be checked as a list. If you need a standard field to accommodate a list,
- # there are two options:
-
- # The first method: there is no need to include it in the `structs: true` subset;
- # instead, you can derive or validate each piece of data.
- # The alternative is to utilize an external module.
- # Invoking a different structure from a different module within the corresponding section
-
- # The reason why this issue exists:
- # Due to the macro structure, I opted for a list data iteration that was appropriate.
- # For each subfield, I generate a module and struct.
- # If a standard field is called again without the module,
- # the source data is repeated in this field. Additionally,
- # this field cannot be sent alone,
- # as the constructor module functions as a pipeline that verifies every
- # requirement until it reaches its conclusion. You are required to transmit all data.
-
- # An alternative course of action is to update the library. Remember to send PR to this lib :)
- # **That is why we should construct a builder that verifies this key exclusively from the root path.**
- raise(translated_message(:list_builder_field_exception))
+ defp do_transform_derive_rules([{:@, _meta, [{name, _, [rules]}]} | rest], _pending, acc)
+ when name in [:derive_rules, :derives] and is_binary(rules) do
+ do_transform_derive_rules(rest, rules, acc)
end
- defp list_builder(attrs, module, field, key, type, cond_list) do
- field_path =
- reverse_module_keys(Module.split(module), field)
- |> combine_parent_field(if(is_list(key), do: key, else: [key]))
- |> List.delete(:root)
-
- get_field =
- if is_nil(cond_list),
- do: get_in(attrs, field_path),
- else: update_in(attrs, field_path, fn _ -> cond_list end) |> get_in(field_path)
-
- if is_list(get_field) do
- builders_output =
- Enum.map(get_field, fn
- item when is_list(item) ->
- Enum.map(item, &module.builder({field_path, Map.put(attrs, field, &1), type}))
-
- item ->
- module.builder({field_path, Map.put(attrs, field, item), type})
- end)
-
- errors =
- List.flatten(builders_output)
- |> Enum.find(&(elem(&1, 0) == :error))
-
- errors ||
- {:ok,
- Enum.map(builders_output, fn
- item when is_list(item) -> Enum.map(item, &elem(&1, 1))
- item -> elem(item, 1)
- end)}
- else
- error = %{message: translated_message(:list_builder_type), field: field, action: :type}
- {:error, error}
- end
+ defp do_transform_derive_rules([{op, meta, args} | rest], pending, acc)
+ when op in @decoratable_entities and not is_nil(pending) do
+ new_args = args |> inject_derive(pending) |> recurse_into_block()
+ do_transform_derive_rules(rest, nil, [{op, meta, new_args} | acc])
end
- defp combine_parent_field(module_keys, parent_list) do
- combined_list = parent_list ++ module_keys
- Enum.uniq(combined_list)
+ defp do_transform_derive_rules([{op, meta, args} | rest], pending, acc)
+ when op in @decoratable_entities do
+ new_args = recurse_into_block(args)
+ do_transform_derive_rules(rest, pending, [{op, meta, new_args} | acc])
end
- defp atom_to_module(field) do
- field
- |> Atom.to_string()
- |> Macro.camelize()
- |> String.to_atom()
+ defp do_transform_derive_rules([item | rest], pending, acc) do
+ do_transform_derive_rules(rest, pending, [item | acc])
end
- defp reject_sub_module_fields(fields, sub_modules) do
- fields
- |> Enum.reject(fn field ->
- Enum.any?(sub_modules, fn
- %{field: ^field} -> true
- _ -> false
- end)
- end)
- end
+ # Recurse into a sub_field / conditional_field's `do:` block so `@derives`
+ # decorators inside the body are also expanded.
+ defp recurse_into_block(args) do
+ case args do
+ [name, type, opts, [do: do_block]] when is_list(opts) ->
+ [name, type, opts, [do: transform_derive_rules(do_block)]]
- defp sub_modules_builders_data(sub_modules_builders) do
- sub_modules_builders
- |> Enum.filter(fn {_field, output} -> elem(output, 0) == :ok end)
- |> Enum.map(fn {field, {_, data}} -> Map.new([{field, data}]) end)
- end
+ [name, type, [{:do, _} | _] = kw] ->
+ rewritten = Keyword.update!(kw, :do, fn block -> transform_derive_rules(block) end)
+ [name, type, rewritten]
- defp sub_modules_builders_errors(sub_modules_builders) do
- sub_modules_builders
- |> Enum.filter(fn {_field, output} -> elem(output, 0) == :error end)
- |> Enum.map(fn {field, error} ->
- %{field: field, errors: elem(error, 1)}
- end)
- end
+ [name, type, opts] when is_list(opts) ->
+ case Keyword.fetch(opts, :do) do
+ {:ok, do_block} ->
+ [name, type, Keyword.put(opts, :do, transform_derive_rules(do_block))]
- defp check_dependent_keys(attrs, core_keys, full_attrs) do
- Enum.map(core_keys, fn
- {key, %{type: :on, values: pattern}} ->
- splited_pattern = Parser.parse_core_keys_pattern(pattern)
- [h | t] = splited_pattern
-
- with get_key_value <- Map.get(full_attrs, key) || Map.get(attrs, key),
- {:get_key_value, false} <- {:get_key_value, is_nil(get_key_value)},
- get_value <-
- if(h == :root, do: get_in(full_attrs, t), else: get_in(attrs, splited_pattern)),
- {:get_value, false} <- {:get_value, !is_nil(get_value)} do
- %{
- message: translated_message(:check_dependent_keys, {key, splited_pattern}),
- field: key,
- action: :dependent_keys
- }
- else
- {:get_key_value, true} -> nil
- {:get_value, true} -> nil
+ :error ->
+ args
end
- _ ->
- nil
- end)
- |> Enum.reject(&is_nil(&1))
- end
-
- # Makes the type nullable if the key is not enforced.
- defp type_for(type, false), do: type
-
- defp type_for(type, _), do: quote(do: unquote(type) | nil)
-
- defp check_authorized_fields(attrs, fields, authorized_fields) do
- case List.first(authorized_fields) do
- false ->
- {:authorized_fields, true, []}
-
- true ->
- filtered = Enum.filter(Map.keys(attrs), &(&1 not in Parser.map_keys(attrs, fields)))
- {:authorized_fields, length(filtered) == 0, filtered}
- end
- end
-
- defp domain_field_status(field, attrs, converted_pattern, key, force \\ nil) do
- domain_field = get_domain_field(field, attrs)
- converted_pattern = converted_domain_pattern(converted_pattern)
-
- if !is_nil(domain_field) do
- ValidationDerive.validate(converted_pattern, domain_field, key)
- |> case do
- data when is_tuple(data) and elem(data, 0) == :error ->
- %{
- message: translated_message(:domain_field_status, key),
- field_path: field,
- field: key,
- action: :domain_parameters
- }
-
- _ ->
- nil
- end
- else
- if is_nil(force),
- do: nil,
- else: %{
- message: translated_message(:force_domain_field_status, key),
- field_path: field,
- field: key,
- action: :domain_parameters
- }
+ other ->
+ other
end
end
- defp converted_domain_pattern(converted_pattern) do
- converted_pattern
- |> case do
- "Tuple" <> list ->
- {:enum, "Tuple[#{re_structure_domain_for_derive(list, "string")}]"}
-
- "Map" <> list ->
- {:enum, "Map[#{re_structure_domain_for_derive(list, "string")}]"}
+ defp inject_derive(args, derive_str) do
+ case args do
+ # field/sub_field/conditional_field/virtual_field with explicit opts
+ [name, type, opts] when is_list(opts) ->
+ [name, type, put_derive(opts, derive_str)]
- "Equal" <> data ->
- converted_data =
- data
- |> String.replace(["[", "]"], "")
- |> String.replace(">>", "::")
+ # field/sub_field/conditional_field/virtual_field with opts AND do-block
+ [name, type, opts, do_block] when is_list(opts) and is_list(do_block) ->
+ [name, type, put_derive(opts, derive_str), do_block]
- {:equal, converted_data}
+ # field/sub_field/conditional_field/virtual_field with NO opts
+ # (note: type is an AST tuple, never a list — `is_tuple(type)` distinguishes
+ # this case from `dynamic_field name, [opts]` below).
+ [name, type] when is_tuple(type) ->
+ [name, type, [derives: derive_str]]
- "Either" <> list ->
- converted_data =
- list
- |> String.replace("enum>>", "enum=")
- |> String.replace(">>", "::")
- |> then(&Parser.convert_parameters("parsed_string", Code.string_to_quoted!(&1)))
+ # dynamic_field with opts — args: [:name] in DSL, opts is a keyword list
+ [name, opts] when is_atom(name) and is_list(opts) ->
+ [name, put_derive(opts, derive_str)]
- %{either: converted_data["parsed_string"]}
+ # dynamic_field with NO opts at all
+ [name] when is_atom(name) ->
+ [name, [derives: derive_str]]
- "Custom" <> list ->
- {:custom, list}
-
- data ->
- {:enum, re_structure_domain_for_derive(data)}
+ other ->
+ other
end
end
- defp parse_domain_patterns(pattern, key, full_attrs, attrs) do
- # "!auth=String[admin, user]::?auth.social=Atom[banned, moderated]"
- # for example `auth.social` should be atom and between `banned` and `moderated`
- # ? and ! means the `auth.social` can exist or not and if yes it should be atom and between the values
- # We change attrs instead of full_attrs inside Map get to support it inside children
- (Map.get(full_attrs, key) || Map.get(attrs, key))
- |> case do
- nil ->
- []
-
- _ ->
- pattern
- |> String.trim()
- |> String.split("::", trim: true)
- |> Enum.map(&String.split(&1, "=", trim: true))
- |> Enum.map(fn
- ["!" <> field, converted_pattern] ->
- domain_field_status(field, full_attrs, converted_pattern, key, :error)
-
- ["?" <> field, converted_pattern] ->
- domain_field_status(field, full_attrs, converted_pattern, key)
- end)
- |> Enum.reject(&is_nil(&1))
+ # Inline `derives:` or `derive:` wins over the decorator. If neither is
+ # set, inject under the canonical `derives:` name.
+ defp put_derive(opts, derive_str) do
+ cond do
+ Keyword.has_key?(opts, :derives) -> opts
+ Keyword.has_key?(opts, :derive) -> opts
+ true -> Keyword.put(opts, :derives, derive_str)
end
end
- defp get_domain_field(field, attrs) do
- field
- |> String.trim()
- |> String.split(".", trim: true)
- |> Enum.map(&String.to_atom/1)
- |> then(&get_in(attrs, &1))
- end
-
- defp re_structure_domain_for_derive(data) do
- data
- |> String.split(",", trim: true)
- |> Enum.map(&String.trim/1)
- |> Enum.join("::")
- end
-
- defp re_structure_domain_for_derive(data, "string") do
- {converted, []} = Code.eval_string(data)
-
- Enum.reduce(converted, "", fn item, acc ->
- acc <> "#{Macro.to_string(item)}::"
- end)
- end
-
- defp conditionals_fields_data_divider(builders) do
- Enum.reduce(builders, %{data: [], errors: []}, fn
- {field, conds, priority}, acc ->
- # TODO: it just keeps one derive not list of them
- %{data: data, errors: errors} =
- {field, conds, acc, priority}
- |> separate_conditions_based_priority()
-
- %{data: acc.data ++ data, errors: acc.errors ++ errors}
-
- list, acc ->
- grouped =
- Enum.group_by(list, fn
- {key, [{_, _, _} | _], _} -> key
- [{key, _field_errors, _} | _] -> key
- {key, _field_errors, _} -> key
- end)
-
- field = grouped |> Map.keys() |> List.first()
-
- field_data = Map.get(grouped, field)
-
- priority =
- if is_list(field_data) and is_tuple(List.first(field_data)) do
- List.first(field_data) |> elem(2)
- else
- false
- end
-
- %{data: data, errors: errors} =
- {field, Map.get(grouped, field), acc, priority}
- |> separate_conditions_based_priority("list")
+ defp extract_aliases(block) do
+ items =
+ case block do
+ {:__block__, _, list} -> list
+ single -> [single]
+ end
- %{data: acc.data ++ data, errors: acc.errors ++ errors}
+ Enum.filter(items, fn
+ {:alias, _meta, _args} -> true
+ _ -> false
end)
end
- defp separate_conditions_based_priority(params, type \\ "normal")
-
- defp separate_conditions_based_priority({field, conds, acc, priority}, "normal") do
- [success_data, error_data] = reduce_success_data_and_error_data(conds)
-
- derives = Enum.map(success_data, fn {_data, derive} -> derive end)
-
- data =
- if(length(success_data) > 0,
- do: [{field, {List.first(success_data) |> elem(0), derives}}],
- else: []
- )
-
- Map.merge(acc, %{
- errors:
- if(length(error_data) > 0 and length(success_data) == 0,
- do: [{field, if(priority, do: [List.first(error_data)], else: error_data)}],
- else: []
- ),
- data: data
- })
- end
-
- defp separate_conditions_based_priority({field, conds, acc, _priority}, "list")
- when is_nil(field) or is_nil(conds),
- do: acc
+ defp extract_enforce_keys(block, block_enforce?) do
+ items =
+ case block do
+ {:__block__, _, list} -> list
+ single -> [single]
+ end
- defp separate_conditions_based_priority({field, conds, acc, priority}, "list") do
- [success_data, error_data] =
- Enum.map(conds, fn
- item when is_tuple(item) ->
- elem(item, 1)
+ Enum.flat_map(items, fn
+ {:field, _meta, [name | rest]} when is_atom(name) and not is_nil(name) ->
+ opts =
+ case rest do
+ [_type, opts] when is_list(opts) -> opts
+ [_type] -> []
+ _ -> []
+ end
- item when is_list(item) ->
- [{_key, field_errors, _} | _] = item
- field_errors
- end)
- |> Enum.reduce([[], []], fn values, [data, error] ->
- ok_data = Enum.find(values, &Parser.field_status?(&1, :ok))
- error_data = Enum.filter(values, &Parser.field_status?(&1, :error))
-
- if(!is_nil(ok_data)) do
- {value, opts} = Parser.field_value(ok_data)
- [data ++ [{{:ok, Map.new([{field, value}])}, opts}], error]
- else
- [data, error ++ Parser.field_value(error_data)]
+ cond do
+ Keyword.get(opts, :enforce) == false -> []
+ Keyword.get(opts, :enforce) == true -> [name]
+ block_enforce? and not Keyword.has_key?(opts, :default) -> [name]
+ true -> []
end
- end)
-
- Map.merge(acc, %{
- errors:
- if(length(error_data) > 0,
- do: [
- {field,
- if(priority, do: [List.first(Enum.uniq(error_data))], else: Enum.uniq(error_data))}
- ],
- else: []
- ),
- data: if(length(success_data) > 0, do: [{field, success_data}], else: [])
- })
- end
-
- @spec reduce_success_data_and_error_data(list(any())) :: list(any())
- @doc false
- def reduce_success_data_and_error_data(conds) do
- Enum.reduce(conds, [[], []], fn
- {{:ok, key, value}, opts}, [data, error] ->
- [data ++ [{{:ok, Map.new([{key, value}])}, opts}], error]
-
- {{:ok, success}, key, opts}, [data, error] ->
- [data ++ [{{:ok, Map.new([{key, success}])}, opts}], error]
-
- {{:error, _key, _value}, _opts} = output, [data, error] ->
- [data, error ++ [output]]
-
- {{:error, _error}, _opts} = output, [data, error] ->
- [data, error ++ [output]]
-
- {{:error, _error}, _key, _opts} = output, [data, error] ->
- [data, error ++ [output]]
- end)
- end
-
- # The priority in this section is the comprehensibility of the codes.
- # This part is hard enough and how to call errors is complicated
- defp validation_errors_aggregator(
- {status, validated_errors, sub_builders_errors, conds, module, main_error_or_data,
- sub_builders}
- ) do
- {status, length(validated_errors), length(sub_builders_errors), Parser.is_data?(conds)}
- |> case do
- {:ok, 0, 0, true} ->
- merged_struct =
- Enum.reduce(sub_builders, struct(module, main_error_or_data), fn item, acc ->
- Map.merge(acc, item)
- end)
- |> Map.merge(cond_data_converter(conds))
-
- {:ok, merged_struct, conds.data}
-
- {:ok, 0, sub_errors, true} when sub_errors != [] ->
- {:error, :nested, sub_builders_errors, struct(module, main_error_or_data), conds.data}
-
- {:ok, val_err, _, false} when val_err > 0 ->
- errors = cond_errors_converter(conds)
- {:error, validated_errors ++ sub_builders_errors ++ errors}
-
- {:ok, _, _, false} ->
- errors = cond_errors_converter(conds)
- {:error, validated_errors ++ sub_builders_errors ++ errors, main_error_or_data}
-
- {:error, val_err, _, false} when val_err > 0 ->
- errors = cond_errors_converter(conds)
- {:error, validated_errors ++ sub_builders_errors ++ errors}
-
- {:error, _, _, false} ->
- errors = cond_errors_converter(conds)
- {:error, validated_errors ++ sub_builders_errors ++ [main_error_or_data] ++ errors}
-
- {:ok, _, _, true} ->
- {:error, validated_errors ++ sub_builders_errors}
-
- {:error, val_err, _, true} when val_err > 0 ->
- {:error, validated_errors ++ sub_builders_errors}
-
- {:error, _, _, true} ->
- {:error, validated_errors ++ sub_builders_errors ++ [main_error_or_data]}
- end
- end
-
- defp cond_data_converter(conds) do
- Enum.reduce(conds.data, %{}, fn
- {field, {{:ok, data}, _opts}}, acc ->
- Map.put(acc, field, Map.get(data, List.first(Map.keys(data))))
-
- {field, values}, acc ->
- data = Enum.map(values, &Map.get(Parser.field_value(&1) |> elem(0), field))
- Map.put(acc, field, data)
- end)
- end
- defp cond_errors_converter(conds) do
- Enum.reduce(conds.errors, [], fn {field, entries}, acc ->
- # Suppose that in the front end, the programmer believes that only two types of errors
- # should be returned, whereas in the rear end, four modes are considered. Currently,
- # the individual who will use the API does not comprehend for which mode this error is sent.
- # Similarly, if hint is set, it can indicate which mode this error is sent in.
- # This section only applies to fields with conditions.
- # It should be noted that the hint must be documented as a custom contract in the user's document.
- transformed_errors =
- Enum.reduce(entries, [], fn
- {{:error, data}, opts}, acc when is_list(data) ->
- acc ++ Enum.map(data, &add_hint(&1, opts))
-
- {{:error, data}, opts}, acc ->
- acc ++ [add_hint(data, opts)]
-
- {{:error, data}, _field, opts}, acc when is_list(data) ->
- acc ++ Enum.map(data, &add_hint(&1, opts))
-
- {{:error, data}, _field, opts}, acc ->
- acc ++ [add_hint(data, opts)]
- end)
-
- acc ++ [%{field: field, action: :conditionals, errors: transformed_errors}]
+ _other ->
+ []
end)
+ |> Enum.reverse()
end
- defp add_hint(error, opts) do
- case Keyword.get(opts, :hint) do
- nil -> error
- hint -> Map.merge(error, %{__hint__: hint})
- end
- end
-
- defp get_field_validator(opts, caller, field, value) do
- {status, field, value} =
- outout =
- case Keyword.get(opts, :validator) do
- nil ->
- # In this place we checke local validator function of caller
- if function_exported?(caller, :validator, 2),
- do: apply(caller, :validator, [field, value]),
- else: {:ok, field, value}
-
- {module, func} ->
- apply(module, func, [field, value])
-
- _ ->
- {:ok, field, value}
+ defp validate_block!(block) do
+ items =
+ case block do
+ {:__block__, _, list} -> list
+ single -> [single]
end
- if status == :ok,
- do: outout,
- else: {:error, %{field: field, message: value, action: :validator}}
- end
-
- # We could merge these 2 function with `when` but, I think we need it in the future.
- defp execute_field_validator({opts, module, field, value, key, type, full_attrs}, :list_field) do
- structs = if Keyword.get(opts, :structs), do: module, else: Keyword.get(opts, :structs)
-
- case get_field_validator(opts, module, field, value) do
- {:ok, _field, value} ->
- {list_builder(full_attrs, structs, field, key, type, value), field, opts}
-
- error ->
- {error, opts}
- end
- end
-
- defp execute_field_validator(
- {opts, caller, field, value, key, type, full_attrs},
- :list_external
- ) do
- case get_field_validator(opts, caller, field, value) do
- {:ok, _field, value} ->
- {list_builder(full_attrs, Keyword.get(opts, :structs), field, key, type, value), field,
- opts}
-
- error ->
- {error, opts}
- end
- end
-
- defp execute_field_validator({opts, caller, field, value, type, module}, :external) do
- case get_field_validator(opts, caller, field, value) do
- {:ok, _field, _value} ->
- {module.builder({:root, value, type}), field, opts}
-
- error ->
- {error, opts}
- end
- end
-
- defp execute_field_validator(
- {opts, caller, field, value, module, key, full_attrs, type},
- :sub_field
- ) do
- case get_field_validator(opts, caller, field, value) do
- {:ok, _field, _value} ->
- keys =
- reverse_module_keys(Module.split(module), field)
- |> combine_parent_field(if(is_list(key), do: key, else: [key]))
- |> List.delete(:root)
+ Enum.reduce(items, [], fn
+ {:field, _meta, [name | _]}, seen ->
+ cond do
+ is_atom(name) and not is_nil(name) ->
+ if name in seen do
+ raise ArgumentError, "the field #{inspect(name)} is already set"
+ end
- full_attrs = update_in(full_attrs, keys, fn _ -> value end)
+ [name | seen]
- {module.builder({keys, full_attrs, type}), field, opts}
+ is_number(name) or is_binary(name) ->
+ raise ArgumentError, "a field name must be an atom, got #{inspect(name)}"
- error ->
- {error, opts}
- end
- end
+ true ->
+ seen
+ end
- defp conditionals_fields_parameters_divider(attrs, conditionals) do
- Enum.reduce(attrs, {%{}, %{}}, fn {key, val}, {cond_acc, uncond_acc} ->
- if Keyword.has_key?(conditionals, key),
- do: {Map.put(cond_acc, key, val), uncond_acc},
- else: {cond_acc, Map.put(uncond_acc, key, val)}
+ _, seen ->
+ seen
end)
- end
-
- @spec conditional_fields_validating_pattern(
- {any(), atom(), list(any()), map() | list(), atom(), :add | :edit, boolean()}
- ) ::
- list() | {any(), list(), any()}
- @doc false
- def conditional_fields_validating_pattern(
- {cond_data, field, list_values, full_attrs, key, type, true}
- )
- when is_list(list_values) do
- outputs =
- {get_field_validator(cond_data.opts, cond_data.caller, field, list_values), cond_data.opts}
- |> Derive.pre_derives_check(cond_data.opts, field)
- |> case do
- {{:ok, _, sanitized_value}, _} ->
- Enum.map(sanitized_value, fn value ->
- {Map.merge(cond_data, %{opts: Keyword.drop(cond_data.opts, [:derive, :validator])}),
- field, value, full_attrs, key, type, false}
- |> conditional_fields_validating_pattern()
- end)
-
- error ->
- {field, [error], Keyword.get(cond_data.opts, :priority, false)}
- end
-
- outputs
- end
-
- def conditional_fields_validating_pattern(
- {_cond_data, field, _list_values, _full_attrs, _key, _type, true}
- ) do
- err = %{message: "Your input must be a list of maps", field: field, action: :bad_parameters}
- [[{field, [{{:error, err}, field, []}], false}]]
- end
- def conditional_fields_validating_pattern({cond_data, field, value, full_attrs, key, type, _}) do
- {get_field_validator(cond_data.opts, cond_data.caller, field, value), cond_data.opts}
- |> Derive.pre_derives_check(cond_data.opts, field)
- |> case do
- {{:ok, _, sanitized_value}, _} ->
- output =
- Enum.map(cond_data.fields, fn
- # Normail field that has custom validator function, if it does not. should pass ok
- # The priority is with the external module
- %{sub?: false, opts: opts, module: nil, list?: false} ->
- case Keyword.get(opts, :struct) do
- nil ->
- {get_field_validator(opts, cond_data.caller, field, sanitized_value), opts}
- |> Derive.pre_derives_check(opts, field)
-
- module ->
- try do
- {opts, cond_data.caller, field, sanitized_value, type, module}
- |> execute_field_validator(:external)
- |> Derive.pre_derives_check(opts, field)
- rescue
- _ ->
- {get_field_validator(opts, cond_data.caller, field, sanitized_value), opts}
- |> Derive.pre_derives_check(opts, field)
- end
- end
-
- %{sub?: false, opts: opts, module: nil, list?: true} ->
- # It is not a sub field, but it should load external module
- # because we have no normal field which is list
- {opts, cond_data.caller, field, sanitized_value, key, type, full_attrs}
- |> execute_field_validator(:list_external)
- |> Derive.pre_derives_check(opts, field)
-
- %{sub?: true, opts: opts, module: module, list?: false} ->
- # It is a sub field and just accepts a map not list of map
- {opts, cond_data.caller, field, sanitized_value, module, key, full_attrs, type}
- |> execute_field_validator(:sub_field)
- |> Derive.pre_derives_check(opts, field)
-
- %{sub?: true, opts: opts, module: module, list?: true} ->
- # It is a sub field and accepts a list of maps
- {opts, module, field, sanitized_value, key, type, full_attrs}
- |> execute_field_validator(:list_field)
- |> Derive.pre_derives_check(opts, field)
- end)
-
- {field, output, Keyword.get(cond_data.opts, :priority, false)}
-
- error ->
- {field, [error], Keyword.get(cond_data.opts, :priority, false)}
- end
+ :ok
end
end
diff --git a/lib/guarded_struct/ash_resource.ex b/lib/guarded_struct/ash_resource.ex
new file mode 100644
index 0000000..57fb7d1
--- /dev/null
+++ b/lib/guarded_struct/ash_resource.ex
@@ -0,0 +1,156 @@
+defmodule GuardedStruct.AshResource do
+ @moduledoc """
+ A Spark DSL extension that adds the GuardedStruct DSL to an Ash resource.
+
+ ## Usage
+
+ defmodule MyApp.User do
+ use Ash.Resource,
+ domain: MyApp.MyDomain,
+ extensions: [GuardedStruct.AshResource]
+
+ attributes do
+ uuid_primary_key :id
+ attribute :email, :string, allow_nil?: false, public?: true
+ end
+
+ # GuardedStruct DSL — identical syntax to standalone `use GuardedStruct`.
+ guardedstruct do
+ field :email, :string,
+ derives: "sanitize(trim, downcase) validate(string, not_empty, email_r)"
+
+ field :nickname, :string,
+ derives: "sanitize(strip_tags, trim) validate(string, max_len=20)"
+
+ sub_field :preferences, :map do
+ field :theme, :string, derives: "validate(enum=String[light::dark])"
+ end
+ end
+
+ # Wire the change into Ash's changeset pipeline (Option A — manual).
+ changes do
+ change GuardedStruct.AshResource.Change
+ end
+ end
+
+ Now every `:create` and `:update` action runs the GuardedStruct pipeline
+ (sanitize → validate → derive → main_validator) before Ash hits the data
+ layer. Errors surface as standard `Ash.Changeset.add_error/2` errors.
+
+ ## Two wiring modes
+
+ ### Option A — manual (default)
+
+ Ship-and-forget: we provide `GuardedStruct.AshResource.Change`; you add
+ a one-line `changes do change ... end` block as shown above. Explicit and
+ inspectable — `Ash.Resource.Info.changes/1` will show the change.
+
+ ### Option B — auto-wire
+
+ Set `auto_wire: true` on the section and the change is injected for you:
+
+ guardedstruct auto_wire: true do
+ field :email, :string, derives: "sanitize(trim) validate(email_r)"
+ end
+
+ # no `changes do ... end` block needed — the transformer added it
+
+ Under the hood this calls `Ash.Resource.Builder.add_change/3` from a Spark
+ transformer that runs after our codegen. The result is identical to writing
+ the `changes do change ... end` block by hand — Ash's introspection sees
+ the change either way. `auto_wire` is `false` by default (no magic).
+
+ ## What this extension does NOT do
+
+ * **It does not generate `defstruct`.** Ash already does that.
+ * **It does not generate `builder/2`.** Ash uses changesets.
+ * **It does not generate `Error` exception modules.** Ash has its own error
+ classes (`Ash.Error.*`).
+
+ Instead, the extension adds a single function — `__guarded_change__/1` —
+ that takes a map of attrs and returns `{:ok, transformed_attrs}` or
+ `{:error, errors}`. The companion `GuardedStruct.AshResource.Change` module
+ wires it into the changeset; `GuardedStruct.AshResource.Info` provides
+ introspection.
+
+ ## Why `__guarded_change__` (not `__guarded_validate__`)
+
+ Earlier drafts called the function `__guarded_validate__/1`. We renamed it
+ because the function does more than validate — sanitize ops transform
+ values (trim, downcase, slugify), `auto:` MFAs fill defaults, derives
+ cast types. "Change" matches Ash's own terminology and is honest about
+ the side-effect.
+
+ ## Auto-map cascade
+
+ Every nested `sub_field` returns a plain map (not a struct) at every depth
+ when called through `__guarded_change__/1`. This is automatic and unique
+ to the Ash extension — standalone `use GuardedStruct` callers still get
+ structs from `builder/1`.
+
+ MyResource.__guarded_change__(%{
+ profile: %{address: %{geo: %{lat: 1.0, lng: 2.0}}}
+ })
+ # {:ok, %{profile: %{address: %{geo: %{lat: 1.0, lng: 2.0}}}}}
+ # ^^^^ plain map, NOT a struct
+
+ This matches Ash's `:map` attribute type, so validated output drops
+ directly into `changeset.attributes` without conversion. Implementation
+ is a process-local flag — concurrency-safe (sibling processes don't see
+ it), re-entrancy-safe (saved+restored across nested calls), zero overhead
+ for standalone callers.
+
+ ## Update actions — `require_atomic? false`
+
+ `GuardedStruct.AshResource.Change` runs an imperative Elixir pipeline.
+ Ash 3.x's update planner requires changes to declare atomic-safety, and
+ ours opts out via `atomic/3` returning `{:not_atomic, reason}`. On any
+ UPDATE action that uses this change, set `require_atomic? false`:
+
+ actions do
+ update :update do
+ accept [:email]
+ require_atomic? false
+ end
+ end
+
+ CREATE actions don't need this flag — Ash only enforces atomic mode on
+ updates.
+
+ ## sub_field vs Ash relationships
+
+ `sub_field` inside an Ash resource creates an **embedded value type**, not
+ a related Ash resource. The generated submodule is a standalone
+ GuardedStruct (it has `defstruct`, `builder/1`, full GuardedStruct API)
+ but it is NOT an Ash resource (no actions, no changesets, no table). Use
+ `sub_field` for nested map shapes inside a single resource's attrs. For
+ separate tables and relationships, use Ash's own `relationships do
+ has_one :preferences, ... end`.
+
+ ## Companion modules
+
+ * `GuardedStruct.AshResource.Change` — the `Ash.Resource.Change` module
+ that bridges `__guarded_change__/1` into the changeset pipeline.
+ * `GuardedStruct.AshResource.Info` — runtime introspection for the
+ `__guarded_*` namespace.
+
+ ## Example: introspect a resource's guarded fields
+
+ GuardedStruct.AshResource.Info.fields(MyApp.User)
+ # => [:email, :nickname, :preferences]
+ """
+
+ use Spark.Dsl.Extension,
+ sections: GuardedStruct.Dsl.sections(),
+ transformers: [
+ GuardedStruct.Transformers.ParseDerive,
+ GuardedStruct.Transformers.GenerateAshValidator,
+ GuardedStruct.Transformers.GenerateSubFieldModules,
+ GuardedStruct.Transformers.AutoWireAshChange
+ ],
+ verifiers: [
+ GuardedStruct.Verifiers.VerifyValidatorMFA,
+ GuardedStruct.Verifiers.VerifyAutoMFA,
+ GuardedStruct.Verifiers.VerifyAtomic
+ ]
+end
diff --git a/lib/guarded_struct/ash_resource/change.ex b/lib/guarded_struct/ash_resource/change.ex
new file mode 100644
index 0000000..0fb0b32
--- /dev/null
+++ b/lib/guarded_struct/ash_resource/change.ex
@@ -0,0 +1,176 @@
+defmodule GuardedStruct.AshResource.Change do
+ @moduledoc """
+ An `Ash.Resource.Change` module that plugs `__guarded_change__/1` into the
+ Ash changeset pipeline.
+
+ ## Usage
+
+ ### Manual wiring (Option A)
+
+ defmodule MyApp.User do
+ use Ash.Resource, extensions: [GuardedStruct.AshResource]
+
+ guardedstruct do
+ field :email, :string, derives: "sanitize(trim, downcase) validate(email_r)"
+ end
+
+ changes do
+ change GuardedStruct.AshResource.Change
+ end
+ end
+
+ By default Ash applies the change on every `:create` and `:update` action.
+ Use the standard `change ..., on: [:create]` / `where: [...]` options to
+ scope it.
+
+ ### Auto-wiring (Option B)
+
+ Set `auto_wire: true` on the `guardedstruct` section and the change is
+ injected for you — no `changes do ... end` block needed. See the
+ `GuardedStruct.AshResource` moduledoc for the trade-offs.
+
+ ## What it does
+
+ On every fire, this change:
+
+ 1. Reads `changeset.attributes`.
+ 2. Calls `resource.__guarded_change__/1` — runs the full GuardedStruct
+ pipeline (sanitize → validate → derive → main_validator).
+ 3. On `{:ok, transformed_attrs}`: calls `Ash.Changeset.force_change_attributes/2`.
+ 4. On `{:error, errs}`: appends each error to the changeset via
+ `Ash.Changeset.add_error/2`.
+
+ ## Ash callback support matrix
+
+ | Callback | Supported? |
+ |---|---|
+ | `change/3` | ✅ |
+ | `batch_change/3` | ✅ (works with `Ash.bulk_create/3` and `Ash.bulk_update/3`) |
+ | `atomic/3` | ✅ but always `{:not_atomic, …}` — see below |
+ | `before_batch/3` / `after_batch/3` | ❌ no-op pass-through wouldn't add value |
+ | `before_action/3` / `after_action/3` | ❌ use Ash's own lifecycle hooks |
+ | `validate/3` (Ash.Resource.Validation) | n/a — different behavior |
+
+ ## Atomic mode
+
+ `atomic/3` returns `{:not_atomic, reason}` unconditionally. The pipeline
+ runs arbitrary Elixir that can't be expressed as a single SQL
+ `UPDATE ... SET ...`. Users must set `require_atomic? false` on `update`
+ actions that include this change. See `GuardedStruct.AtomicClassifier`
+ and the `atomic: true` section option on `guardedstruct` for the
+ compile-time-verified atomic path.
+
+ ## Bulk usage
+
+ result =
+ Ash.bulk_create(input_list, MyApp.User, :create,
+ return_records?: true,
+ return_errors?: true
+ )
+
+ For `Ash.bulk_update/3` use `strategy: :stream` (atomic-stream isn't
+ possible while our change is non-atomic).
+ """
+
+ def has_change?, do: true
+ def has_atomic?, do: true
+ def has_batch_change?, do: true
+ def has_before_batch?, do: false
+ def has_after_batch?, do: false
+ def has_after_action?, do: false
+ def has_before_action?, do: false
+ def has_validate?, do: false
+ def has_around_action?, do: false
+ def has_init?, do: true
+
+ # Ash 3.x checks both `has_*?/0` and `*?/0` aliases in different paths.
+ def atomic?, do: false
+ def batch_change?, do: true
+ def before_batch?, do: false
+ def after_batch?, do: false
+ def after_action?, do: false
+ def before_action?, do: false
+ def validate?, do: false
+ def around_action?, do: false
+
+ @doc false
+ def init(opts), do: {:ok, opts}
+
+ @doc false
+ def batch_callbacks?(_, _, _), do: true
+
+ @doc """
+ The `Ash.Resource.Change` callback. Runs the GuardedStruct pipeline via
+ `resource.__guarded_change__/1` and either applies the transformed
+ attrs back to the changeset or adds errors.
+ """
+ def change(changeset, _opts, _context) do
+ resource = changeset.resource
+ attrs = changeset.attributes
+
+ case resource.__guarded_change__(attrs) do
+ {:ok, transformed_attrs} ->
+ force_change_attributes(changeset, transformed_attrs)
+
+ {:error, errs} when is_list(errs) ->
+ Enum.reduce(errs, changeset, fn err, cs -> add_error(cs, to_ash_error(err)) end)
+
+ {:error, err} ->
+ add_error(changeset, to_ash_error(err))
+ end
+ end
+
+ @doc """
+ Bulk-action entry. Maps `change/3` over each changeset — semantic
+ guarantee identical to calling `change/3` N times, one less function-call
+ hop per element.
+ """
+ def batch_change(changesets, opts, context) do
+ Enum.map(changesets, &change(&1, opts, context))
+ end
+
+ @doc """
+ Returns `{:not_atomic, reason}` unconditionally. Sanitize ops, `auto:`
+ MFAs, and `main_validator/1` run arbitrary Elixir; not SQL-translatable.
+ Use the `atomic: true` section flag for a compile-time-verified
+ atomic-only resource.
+ """
+ def atomic(_changeset, _opts, _context) do
+ {:not_atomic,
+ "GuardedStruct.AshResource.Change runs an imperative sanitize/validate " <>
+ "pipeline; not safe to express as atomic SQL. See moduledoc."}
+ end
+
+ # `apply/3` defers the Ash.* references to runtime so the module
+ # compiles without warnings when Ash isn't in the user's deps.
+ defp force_change_attributes(changeset, attrs),
+ do: apply(Ash.Changeset, :force_change_attributes, [changeset, attrs])
+
+ defp add_error(changeset, err),
+ do: apply(Ash.Changeset, :add_error, [changeset, err])
+
+ defp to_ash_error(%{field: field, message: message} = err) do
+ apply(Ash.Error.Changes.InvalidAttribute, :exception, [
+ [
+ field: field,
+ message: message,
+ value: Map.get(err, :value),
+ vars: vars_for(err)
+ ]
+ ])
+ end
+
+ defp to_ash_error(%{fields: fields, action: :required_fields} = _err) do
+ apply(Ash.Error.Changes.InvalidChanges, :exception, [
+ [
+ fields: fields,
+ message: "required by guardedstruct: #{Enum.join(fields, ", ")}"
+ ]
+ ])
+ end
+
+ defp to_ash_error(other), do: other
+
+ defp vars_for(%{action: action}) when is_atom(action), do: [validation: action]
+ defp vars_for(_), do: []
+end
diff --git a/lib/guarded_struct/ash_resource/info.ex b/lib/guarded_struct/ash_resource/info.ex
new file mode 100644
index 0000000..7d02d48
--- /dev/null
+++ b/lib/guarded_struct/ash_resource/info.ex
@@ -0,0 +1,74 @@
+defmodule GuardedStruct.AshResource.Info do
+ @moduledoc """
+ Runtime introspection for the `GuardedStruct.AshResource` extension.
+
+ Same shape as `GuardedStruct.Info` but reads from the `__guarded_*`
+ namespaced functions the Ash extension generates (so it doesn't
+ collide with Ash's own `Ash.Resource.Info` callbacks).
+
+ ## Example
+
+ defmodule MyApp.User do
+ use Ash.Resource,
+ domain: MyApp.MyDomain,
+ extensions: [GuardedStruct.AshResource]
+
+ guardedstruct do
+ field :nickname, :string, derives: "validate(string, max_len=20)"
+ end
+ end
+
+ GuardedStruct.AshResource.Info.fields(MyApp.User)
+ #=> [:nickname]
+
+ GuardedStruct.AshResource.Info.field(MyApp.User, :nickname)
+ #=> %{kind: :field, name: :nickname, derives: "validate(string, max_len=20)", ...}
+ """
+
+ use Spark.InfoGenerator,
+ extension: GuardedStruct.AshResource,
+ sections: [:guardedstruct]
+
+ @doc """
+ Return field, sub_field, and conditional_field names in declaration order.
+ """
+ def fields(module) do
+ module
+ |> guardedstruct()
+ |> Enum.map(& &1.name)
+ |> Enum.uniq()
+ end
+
+ @doc """
+ Return the runtime field metadata stored under `__guarded_fields__/0`.
+ """
+ def fields_meta(module), do: module.__guarded_fields__()
+
+ @doc """
+ Return metadata for a single field name, or `nil`.
+ """
+ def field(module, name) when is_atom(name) do
+ Enum.find(module.__guarded_fields__(), &(&1.name == name))
+ end
+
+ @doc """
+ True if a guardedstruct-declared field exists with this name.
+ """
+ def field?(module, name) when is_atom(name) do
+ Enum.any?(module.__guarded_fields__(), &(&1.name == name))
+ end
+
+ @doc """
+ Return the full information map (path, keys, enforce_keys, options, etc.).
+ """
+ def information(module), do: module.__guarded_information__()
+
+ @doc """
+ Run the validation pipeline on `attrs` and return `{:ok, validated_map}`
+ or `{:error, errors}`. Convenience wrapper over the resource's own
+ `__guarded_change__/1`.
+ """
+ def validate(module, attrs, error? \\ false) do
+ module.__guarded_change__(attrs, error?)
+ end
+end
diff --git a/lib/guarded_struct/atomic_classifier.ex b/lib/guarded_struct/atomic_classifier.ex
new file mode 100644
index 0000000..5b25ac2
--- /dev/null
+++ b/lib/guarded_struct/atomic_classifier.ex
@@ -0,0 +1,177 @@
+defmodule GuardedStruct.AtomicClassifier do
+ @moduledoc """
+ Classifies a single GuardedStruct derive op as either atomic-SQL safe
+ or unsafe (with a human-readable reason).
+
+ To declare a NEW op safe for atomic mode, add a clause near the top of
+ this file:
+
+ def classify_op({:validate, :my_new_op}), do: :safe
+
+ To mark an op UNSAFE with a specific reason, add a clause near its
+ category:
+
+ def classify_op({:validate, :my_dns_op}) do
+ {:unsafe, "validate(my_dns_op) performs a DNS lookup — needs I/O"}
+ end
+
+ The catch-all at the bottom rejects anything not enumerated. Be
+ conservative: when in doubt, an op is unsafe.
+
+ ## Op shape
+
+ The runtime represents derive ops as one of:
+
+ * `{:sanitize, :trim}` — sanitize, no arg
+ * `{:validate, :string}` — validate, no arg
+ * `{:validate, {:max_len, 20}}` — validate, with literal arg
+ * `{:validate, {enum: ["a", "b"]}}` — keyword-list arg variant
+ """
+
+ def classify_op({:sanitize, :trim}), do: :safe
+ def classify_op({:sanitize, :downcase}), do: :safe
+ def classify_op({:sanitize, :upcase}), do: :safe
+ def classify_op({:sanitize, :capitalize}), do: :safe
+ def classify_op({:sanitize, :string}), do: :safe
+ def classify_op({:sanitize, :integer}), do: :safe
+ def classify_op({:sanitize, :float}), do: :safe
+ def classify_op({:sanitize, :strip_tags}), do: :safe
+ def classify_op({:sanitize, :basic_html}), do: :safe
+ def classify_op({:sanitize, :html5}), do: :safe
+ def classify_op({:sanitize, :tag}), do: :safe
+ def classify_op({:sanitize, {:tag, _}}), do: :safe
+
+ def classify_op({:sanitize, op}) when is_atom(op) do
+ cond do
+ GuardedStruct.Derive.Registry.known_sanitize?(op) ->
+ {:unsafe,
+ "sanitize(#{op}) is a built-in op but not in the atomic-safe " <>
+ "registry. If you've verified it's SQL-translatable, add a " <>
+ "`def classify_op({:sanitize, :#{op}}), do: :safe` clause in " <>
+ "GuardedStruct.AtomicClassifier"}
+
+ true ->
+ {:unsafe,
+ "sanitize(#{op}) is NOT a known built-in op. Possible causes: " <>
+ "(1) typo of a built-in — check spelling against `mix help " <>
+ "guarded_struct` or `GuardedStruct.Derive.Registry.sanitize_ops/0`; " <>
+ "(2) custom op from `GuardedStruct.Derive.Extension` — custom " <>
+ "ops run arbitrary Elixir and can't be atomic-safe. Either fix " <>
+ "the typo or set `atomic: false`"}
+ end
+ end
+
+ def classify_op({:sanitize, op}) do
+ {:unsafe, "sanitize op #{inspect(op)} has an unrecognized shape"}
+ end
+
+ def classify_op({:validate, :string}), do: :safe
+ def classify_op({:validate, :integer}), do: :safe
+ def classify_op({:validate, :float}), do: :safe
+ def classify_op({:validate, :boolean}), do: :safe
+ def classify_op({:validate, :atom}), do: :safe
+ def classify_op({:validate, :list}), do: :safe
+ def classify_op({:validate, :map}), do: :safe
+ def classify_op({:validate, :tuple}), do: :safe
+ def classify_op({:validate, :record}), do: :safe
+ def classify_op({:validate, {:record, _tag}}), do: :safe
+
+ def classify_op({:validate, :not_empty}), do: :safe
+ def classify_op({:validate, :not_empty_string}), do: :safe
+ def classify_op({:validate, :not_flatten_empty_item}), do: :safe
+ def classify_op({:validate, {:max_len, _}}), do: :safe
+ def classify_op({:validate, {:min_len, _}}), do: :safe
+
+ def classify_op({:validate, {:max, _}}), do: :safe
+ def classify_op({:validate, {:min, _}}), do: :safe
+ def classify_op({:validate, {:equal, _}}), do: :safe
+
+ def classify_op({:validate, :uuid}), do: :safe
+ def classify_op({:validate, :email_r}), do: :safe
+ def classify_op({:validate, :url_r}), do: :safe
+ def classify_op({:validate, :ipv4}), do: :safe
+ def classify_op({:validate, :ipv6}), do: :safe
+ def classify_op({:validate, :string_boolean}), do: :safe
+ def classify_op({:validate, {:regex, _}}), do: :safe
+
+ def classify_op({:validate, :datetime}), do: :safe
+ def classify_op({:validate, :date}), do: :safe
+ def classify_op({:validate, :time}), do: :safe
+
+ def classify_op({:validate, {:enum, _}}), do: :safe
+
+ def classify_op({:validate, :email}) do
+ {:unsafe,
+ "validate(email) performs a DNS lookup via :email_checker. Use " <>
+ "validate(email_r) for atomic mode (regex-only check)"}
+ end
+
+ def classify_op({:validate, :url}) do
+ {:unsafe,
+ "validate(url) performs DNS / port checking via :ex_url. Use " <>
+ "validate(url_r) for atomic mode (regex-only check)"}
+ end
+
+ def classify_op({:validate, :geo}) do
+ {:unsafe,
+ "validate(geo) requires custom geo SQL functions. Not in the " <>
+ "default atomic-safe registry"}
+ end
+
+ def classify_op({:validate, :location}) do
+ {:unsafe,
+ "validate(location) requires custom geo SQL functions. Not in the " <>
+ "default atomic-safe registry"}
+ end
+
+ def classify_op({:validate, :type}) do
+ {:unsafe,
+ "validate(type) has variable interpretation. Use a specific type " <>
+ "validator (string, integer, list, ...) for atomic mode"}
+ end
+
+ def classify_op({:validate, {:tell, _country}}) do
+ {:unsafe,
+ "validate(tell, country_code) may require external library lookup. " <>
+ "Not in the default atomic-safe registry"}
+ end
+
+ def classify_op({:validate, op}) when is_atom(op) do
+ cond do
+ GuardedStruct.Derive.Registry.known_validate?(op) ->
+ {:unsafe,
+ "validate(#{op}) is a built-in op but not in the atomic-safe " <>
+ "registry. If you've verified it's SQL-translatable, add a " <>
+ "`def classify_op({:validate, :#{op}}), do: :safe` clause in " <>
+ "GuardedStruct.AtomicClassifier"}
+
+ true ->
+ {:unsafe,
+ "validate(#{op}) is NOT a known built-in op. Possible causes: " <>
+ "(1) typo of a built-in — check spelling against " <>
+ "`GuardedStruct.Derive.Registry.validate_ops/0`; " <>
+ "(2) custom op from `GuardedStruct.Derive.Extension` — custom " <>
+ "ops run arbitrary Elixir and can't be atomic-safe. Either fix " <>
+ "the typo or set `atomic: false`"}
+ end
+ end
+
+ def classify_op({:validate, {op, _arg}}) when is_atom(op) do
+ cond do
+ GuardedStruct.Derive.Registry.known_validate?(op) ->
+ {:unsafe,
+ "validate(#{op}=...) is a built-in op but not in the atomic-safe " <>
+ "registry. Add a classifier clause if it's SQL-translatable"}
+
+ true ->
+ {:unsafe,
+ "validate(#{op}=...) is NOT a known built-in op. Possible causes: " <>
+ "typo of a built-in or custom Derive.Extension op. Either fix " <>
+ "the typo or set `atomic: false`"}
+ end
+ end
+
+ def classify_op(other) do
+ {:unsafe, "unrecognized op shape: #{inspect(other)}"}
+ end
+end
diff --git a/lib/guarded_struct/derive/derive.ex b/lib/guarded_struct/derive/derive.ex
new file mode 100644
index 0000000..19cad68
--- /dev/null
+++ b/lib/guarded_struct/derive/derive.ex
@@ -0,0 +1,99 @@
+defmodule GuardedStruct.Derive do
+ @moduledoc false
+
+ alias GuardedStruct.Derive.{Parser, SanitizerDerive, ValidationDerive}
+
+ @type derive_input :: %{
+ required(:field) => atom(),
+ optional(:derive) => String.t() | nil,
+ optional(:derive_ops) => map() | nil,
+ optional(:hint) => any()
+ }
+
+ @doc """
+ Apply derive ops to each named field in `data`. Returns `{:ok, data'}` with
+ sanitised/validated values merged back, or `{:error, errors}` with a flat
+ list of `%{field, action, message}` maps.
+ """
+ @spec derive({:ok, map(), [derive_input]}) :: {:ok, map()} | {:error, [map()]}
+ def derive({:ok, data, derive_inputs}) do
+ reduced =
+ Enum.reduce(derive_inputs, %{}, fn input, acc ->
+ ops =
+ case Map.get(input, :derive_ops, :__missing__) do
+ :__missing__ -> Parser.parser(input.derive)
+ v -> v
+ end
+
+ field_value = Map.get(data, input.field)
+ hint = Map.get(input, :hint) || []
+
+ update(field_value, ops, hint, input, acc)
+ end)
+
+ case collect_errors(reduced) do
+ [] -> {:ok, Map.merge(data, reduced)}
+ errors -> {:error, errors}
+ end
+ end
+
+ defp update(nil, _ops, _hint, _input, acc), do: acc
+
+ defp update(field_value, ops, hints, input, acc)
+ when is_list(ops) and ops != [] do
+ list_data? = is_list(field_value) and length(field_value) == length(ops)
+
+ values =
+ if list_data?,
+ do: field_value,
+ else: List.duplicate(field_value, length(ops))
+
+ results =
+ [ops, values, hints]
+ |> Enum.zip()
+ |> Enum.map(fn {op, value, hint} ->
+ op = if op == [], do: nil, else: op
+ run_one(op, input.field, value, hint)
+ end)
+
+ {errors, ok_values} =
+ Enum.split_with(results, &match?({:error, _}, &1))
+
+ flat_errors = Enum.flat_map(errors, fn {:error, e} -> e end)
+
+ value_to_store =
+ cond do
+ list_data? and flat_errors != [] -> {:error, flat_errors}
+ list_data? -> ok_values
+ ok_values != [] -> List.first(ok_values)
+ true -> {:error, flat_errors}
+ end
+
+ Map.put(acc, input.field, value_to_store)
+ end
+
+ defp update(field_value, ops, hint, input, acc) do
+ ops = if ops == [], do: nil, else: ops
+ Map.put(acc, input.field, run_one(ops, input.field, field_value, hint))
+ end
+
+ defp run_one(ops, field, value, hint) do
+ {processed, errors} =
+ {field, value}
+ |> SanitizerDerive.call(get_in_ops(ops, :sanitize))
+ |> ValidationDerive.call(get_in_ops(ops, :validate), hint)
+
+ if errors == [], do: processed, else: {:error, errors}
+ end
+
+ defp get_in_ops(nil, _key), do: nil
+ defp get_in_ops(map, key) when is_map(map), do: Map.get(map, key)
+ defp get_in_ops(_, _), do: nil
+
+ defp collect_errors(reduced) do
+ reduced
+ |> Map.values()
+ |> Enum.filter(&match?({:error, _}, &1))
+ |> Enum.flat_map(fn {:error, e} -> e end)
+ end
+end
diff --git a/lib/guarded_struct/derive/extension.ex b/lib/guarded_struct/derive/extension.ex
new file mode 100644
index 0000000..0873a6e
--- /dev/null
+++ b/lib/guarded_struct/derive/extension.ex
@@ -0,0 +1,223 @@
+defmodule GuardedStruct.Derive.Extension do
+ @moduledoc """
+ Define custom derive validators / sanitizers via a Spark DSL.
+
+ ## Usage
+
+ defmodule MyApp.Derives do
+ use GuardedStruct.Derive.Extension
+
+ derives do
+ validator :slug, fn input ->
+ is_binary(input) and Regex.match?(~r/^[a-z0-9-]+$/, input)
+ end
+
+ sanitizer :slugify, fn input when is_binary(input) ->
+ input
+ |> String.downcase()
+ |> String.replace(~r/[^a-z0-9-]+/u, "-")
+ end
+ end
+ end
+
+ Register globally in `config/config.exs`:
+
+ config :guarded_struct, derive_extensions: [MyApp.Derives]
+
+ Then any GuardedStruct module can use the new ops:
+
+ defmodule Post do
+ use GuardedStruct
+
+ guardedstruct do
+ field :slug, String.t(), derives: "sanitize(slugify) validate(slug)"
+ end
+ end
+
+ ## Validator return shape
+
+ Validator functions return:
+
+ * `true` — input passes
+ * `false` — input fails (default error message generated)
+ * `{:error, field, action, message}` — explicit error tuple
+ * any other value — used as the validated value (for coercing validators)
+
+ ## Introspection
+
+ The `derives do ... end` block is introspectable via
+ `Spark.Dsl.Extension.get_entities/2`. Verifiers and transformers can
+ plug in at the standard Spark extension points.
+ """
+
+ use Spark.Dsl,
+ default_extensions: [extensions: [GuardedStruct.Derive.Extension.Dsl]]
+
+ @doc false
+ def __dispatch_validator__(true, input, _field, _name), do: input
+
+ def __dispatch_validator__(false, _input, field, name) do
+ {:error, field, name, "Invalid format in the #{field} field (#{name})"}
+ end
+
+ def __dispatch_validator__({:error, _, _, _} = e, _input, _field, _name), do: e
+
+ def __dispatch_validator__(other, _input, _field, _name), do: other
+
+ @doc """
+ Returns the list of registered extension modules from app config.
+ Loads each module and filters to only those that `use` this extension.
+ """
+ def registered_extensions, do: load_extensions(global_extensions())
+
+ defp global_extensions, do: Application.get_env(:guarded_struct, :derive_extensions, [])
+
+ defp load_extensions(list) do
+ list
+ |> List.wrap()
+ |> Enum.filter(&ensure_extension_loaded?/1)
+ |> Enum.filter(&function_exported?(&1, :__derive_extension__?, 0))
+ end
+
+ # `Code.ensure_compiled?/1` (not `ensure_loaded?`) handles the case
+ # where the extension and a module using it live in the same source
+ # file — the .beam isn't on disk yet during the parent's compile pass.
+ defp ensure_extension_loaded?(mod) when is_atom(mod) do
+ match?({:module, _}, Code.ensure_compiled(mod))
+ end
+
+ defp ensure_extension_loaded?(_), do: false
+
+ @doc """
+ Resolve a per-module `derive_extensions:` opt — the raw list user wrote
+ in `use GuardedStruct, derive_extensions: [...]` — into a flat list of
+ extension modules with `:config` expanded to the current global config
+ at the position it appears.
+
+ ## Resolution rules
+
+ * `nil` → fall back to the global Application config (no per-module opt set)
+ * `[]` → no extensions at all (intentional opt-out, ignores global)
+ * `[A, B]` (no `:config`) → these only; global is ignored
+ * `[:config, A]` → global ++ [A] (global wins on op-name collisions)
+ * `[A, :config]` → [A] ++ global (A wins on op-name collisions)
+ * `[A, :config, B]` → [A] ++ global ++ [B]
+ """
+ @spec resolve_opt(list() | nil) :: [module()]
+ def resolve_opt(nil), do: registered_extensions()
+
+ def resolve_opt(list) when is_list(list) do
+ list
+ |> Enum.flat_map(fn
+ :config -> global_extensions()
+ mod when is_atom(mod) -> [mod]
+ end)
+ |> load_extensions()
+ end
+
+ @doc """
+ Resolve the effective extension list for a specific user module.
+ """
+ @spec extensions_for(module() | nil) :: [module()]
+ def extensions_for(nil), do: registered_extensions()
+
+ def extensions_for(module) when is_atom(module) do
+ cond do
+ function_exported?(module, :__guarded_derive_extensions_opt__, 0) ->
+ resolve_opt(module.__guarded_derive_extensions_opt__())
+
+ Code.ensure_loaded?(module) and
+ function_exported?(module, :__guarded_derive_extensions_opt__, 0) ->
+ resolve_opt(module.__guarded_derive_extensions_opt__())
+
+ true ->
+ registered_extensions()
+ end
+ end
+
+ @doc """
+ Validate a `derive_extensions:` opt list at compile time. Raises
+ `ArgumentError` if entries are not modules / `:config`, or if `:config`
+ appears more than once.
+ """
+ def validate_opt!(nil), do: nil
+
+ def validate_opt!(list) when is_list(list) do
+ Enum.each(list, fn
+ :config ->
+ :ok
+
+ mod when is_atom(mod) ->
+ :ok
+
+ other ->
+ raise ArgumentError,
+ "derive_extensions: entries must be modules or :config, got #{inspect(other)}"
+ end)
+
+ config_count = Enum.count(list, &(&1 == :config))
+
+ if config_count > 1 do
+ raise ArgumentError,
+ "derive_extensions: contains :config more than once; specify it exactly once or remove it"
+ end
+
+ list
+ end
+
+ def validate_opt!(other) do
+ raise ArgumentError,
+ "derive_extensions: expected a list, got #{inspect(other)}"
+ end
+
+ @doc """
+ The user module currently being built. Set by `Runtime.with_telemetry/2`
+ around every top-level `builder/1` call; nested sub_field builds inherit
+ via the process dictionary.
+ """
+ def current_module, do: Process.get(:guarded_struct_current_module)
+
+ @doc "Try the current module's extensions for a validator op."
+ def dispatch_validate(op, input, field) do
+ dispatch_validate(op, input, field, extensions_for(current_module()))
+ end
+
+ @doc "Try a specific list of extensions for a validator op."
+ def dispatch_validate(op, input, field, extensions) when is_list(extensions) do
+ Enum.reduce_while(extensions, :__not_found__, fn mod, _ ->
+ case mod.__validate__(op, input, field) do
+ :__not_found__ -> {:cont, :__not_found__}
+ result -> {:halt, result}
+ end
+ end)
+ end
+
+ @doc "Try the current module's extensions for a sanitizer op."
+ def dispatch_sanitize(op, input) do
+ dispatch_sanitize(op, input, extensions_for(current_module()))
+ end
+
+ @doc "Try a specific list of extensions for a sanitizer op."
+ def dispatch_sanitize(op, input, extensions) when is_list(extensions) do
+ Enum.find_value(extensions, :__not_found__, fn mod ->
+ if op in mod.__sanitizers__(), do: mod.__sanitize__(op, input)
+ end)
+ end
+
+ @doc """
+ All validator op atoms known across every extension visible from the
+ given module.
+ """
+ def all_extension_validators(module \\ nil) do
+ extensions_for(module)
+ |> Enum.flat_map(& &1.__validators__())
+ |> MapSet.new()
+ end
+
+ @doc "All sanitizer op atoms known across every extension visible from `module`."
+ def all_extension_sanitizers(module \\ nil) do
+ extensions_for(module)
+ |> Enum.flat_map(& &1.__sanitizers__())
+ |> MapSet.new()
+ end
+end
diff --git a/lib/guarded_struct/derive/extension/dsl.ex b/lib/guarded_struct/derive/extension/dsl.ex
new file mode 100644
index 0000000..ba6a316
--- /dev/null
+++ b/lib/guarded_struct/derive/extension/dsl.ex
@@ -0,0 +1,85 @@
+defmodule GuardedStruct.Derive.Extension.Dsl do
+ @moduledoc false
+
+ alias GuardedStruct.Derive.Extension.Dsl.{Validator, Sanitizer}
+
+ @validator %Spark.Dsl.Entity{
+ name: :validator,
+ target: Validator,
+ args: [:name, :fun],
+ describe: """
+ Declare a custom validator op callable as `validate()` from
+ any GuardedStruct module that has this extension wired in.
+ """,
+ schema: [
+ name: [
+ type: :atom,
+ required: true,
+ doc: "Op name. Used as `validate()` in derive strings."
+ ],
+ fun: [
+ type: :quoted,
+ required: true,
+ doc: """
+ Single-arg function. Return value semantics:
+
+ * `true` — input passes
+ * `false` — input fails (default error message)
+ * `{:error, field, action, message}` — explicit error
+ * any other value — used as the validated (coerced) output
+ """
+ ]
+ ]
+ }
+
+ @sanitizer %Spark.Dsl.Entity{
+ name: :sanitizer,
+ target: Sanitizer,
+ args: [:name, :fun],
+ describe: """
+ Declare a custom sanitizer op callable as `sanitize()`. Runs
+ before validation in the derive pipeline; the return value replaces
+ the input.
+ """,
+ schema: [
+ name: [
+ type: :atom,
+ required: true,
+ doc: "Op name. Used as `sanitize()` in derive strings."
+ ],
+ fun: [
+ type: :quoted,
+ required: true,
+ doc: "Single-arg function. Return value replaces the input."
+ ]
+ ]
+ }
+
+ @section %Spark.Dsl.Section{
+ name: :derives,
+ describe: """
+ Container for custom validator and sanitizer ops.
+
+ ## Example
+
+ defmodule MyApp.Derives do
+ use GuardedStruct.Derive.Extension
+
+ derives do
+ validator :slug, fn input ->
+ is_binary(input) and Regex.match?(~r/^[a-z0-9-]+$/, input)
+ end
+
+ sanitizer :slugify, fn input when is_binary(input) ->
+ input |> String.downcase() |> String.replace(~r/[^a-z0-9-]+/u, "-")
+ end
+ end
+ end
+ """,
+ entities: [@validator, @sanitizer]
+ }
+
+ use Spark.Dsl.Extension,
+ sections: [@section],
+ transformers: [GuardedStruct.Derive.Extension.Transformers.Codegen]
+end
diff --git a/lib/guarded_struct/derive/extension/dsl/sanitizer.ex b/lib/guarded_struct/derive/extension/dsl/sanitizer.ex
new file mode 100644
index 0000000..762bc46
--- /dev/null
+++ b/lib/guarded_struct/derive/extension/dsl/sanitizer.ex
@@ -0,0 +1,10 @@
+defmodule GuardedStruct.Derive.Extension.Dsl.Sanitizer do
+ @moduledoc false
+
+ defstruct [:name, :fun, :__spark_metadata__]
+
+ @type t :: %__MODULE__{
+ name: atom(),
+ fun: term()
+ }
+end
diff --git a/lib/guarded_struct/derive/extension/dsl/validator.ex b/lib/guarded_struct/derive/extension/dsl/validator.ex
new file mode 100644
index 0000000..4e2a5a9
--- /dev/null
+++ b/lib/guarded_struct/derive/extension/dsl/validator.ex
@@ -0,0 +1,10 @@
+defmodule GuardedStruct.Derive.Extension.Dsl.Validator do
+ @moduledoc false
+
+ defstruct [:name, :fun, :__spark_metadata__]
+
+ @type t :: %__MODULE__{
+ name: atom(),
+ fun: term()
+ }
+end
diff --git a/lib/guarded_struct/derive/extension/transformers/codegen.ex b/lib/guarded_struct/derive/extension/transformers/codegen.ex
new file mode 100644
index 0000000..2c91eb3
--- /dev/null
+++ b/lib/guarded_struct/derive/extension/transformers/codegen.ex
@@ -0,0 +1,88 @@
+defmodule GuardedStruct.Derive.Extension.Transformers.Codegen do
+ @moduledoc false
+
+ use Spark.Dsl.Transformer
+
+ alias Spark.Dsl.Transformer
+ alias GuardedStruct.Derive.Extension.Dsl.{Validator, Sanitizer}
+
+ @impl true
+ def transform(dsl_state) do
+ entities = Transformer.get_entities(dsl_state, [:derives])
+ module = Transformer.get_persisted(dsl_state, :module)
+
+ validators = Enum.filter(entities, &match?(%Validator{}, &1))
+ sanitizers = Enum.filter(entities, &match?(%Sanitizer{}, &1))
+
+ warn_shadows(validators, sanitizers, module)
+
+ body = build_body(validators, sanitizers)
+ {:ok, Transformer.eval(dsl_state, [], body)}
+ end
+
+ defp build_body(validators, sanitizers) do
+ validator_names = Enum.map(validators, & &1.name)
+ sanitizer_names = Enum.map(sanitizers, & &1.name)
+
+ validator_clauses = Enum.map(validators, &validator_clause/1)
+ sanitizer_clauses = Enum.map(sanitizers, &sanitizer_clause/1)
+
+ quote do
+ def __validators__, do: unquote(validator_names)
+ def __sanitizers__, do: unquote(sanitizer_names)
+ def __derive_extension__?, do: true
+
+ unquote_splicing(validator_clauses)
+ def __validate__(_op, _input, _field), do: :__not_found__
+
+ unquote_splicing(sanitizer_clauses)
+ def __sanitize__(_op, input), do: input
+ end
+ end
+
+ defp validator_clause(%Validator{name: name, fun: fun_ast}) do
+ quote do
+ def __validate__(unquote(name), input, field) do
+ GuardedStruct.Derive.Extension.__dispatch_validator__(
+ unquote(fun_ast).(input),
+ input,
+ field,
+ unquote(name)
+ )
+ end
+ end
+ end
+
+ defp sanitizer_clause(%Sanitizer{name: name, fun: fun_ast}) do
+ quote do
+ def __sanitize__(unquote(name), input) do
+ unquote(fun_ast).(input)
+ end
+ end
+ end
+
+ defp warn_shadows(validators, sanitizers, module) do
+ Enum.each(validators, fn %Validator{name: name} = v ->
+ if GuardedStruct.Derive.Registry.known_validate?(name) do
+ Spark.Warning.warn(shadow_message(:validator, name, module), anno(v))
+ end
+ end)
+
+ Enum.each(sanitizers, fn %Sanitizer{name: name} = s ->
+ if GuardedStruct.Derive.Registry.known_sanitize?(name) do
+ Spark.Warning.warn(shadow_message(:sanitizer, name, module), anno(s))
+ end
+ end)
+ end
+
+ defp shadow_message(kind, name, module) do
+ op_kind = if kind == :validator, do: "validate", else: "sanitize"
+
+ "#{kind} #{inspect(name)} in #{inspect(module)} shadows a built-in " <>
+ "`#{op_kind}(#{name})` op. Built-in clauses match first, so this custom " <>
+ "#{kind} will NEVER be called. Rename it to avoid the shadow."
+ end
+
+ defp anno(%{__spark_metadata__: %{anno: anno}}), do: anno
+ defp anno(_), do: nil
+end
diff --git a/lib/guarded_struct/derive/op_evaluator.ex b/lib/guarded_struct/derive/op_evaluator.ex
new file mode 100644
index 0000000..f23fd9b
--- /dev/null
+++ b/lib/guarded_struct/derive/op_evaluator.ex
@@ -0,0 +1,107 @@
+defmodule GuardedStruct.Derive.OpEvaluator do
+ @moduledoc false
+
+ @spec preevaluate(nil | map()) :: nil | map()
+ def preevaluate(nil), do: nil
+
+ def preevaluate(ops) when is_map(ops) do
+ Map.new(ops, fn {key, op_list} -> {key, Enum.map(op_list, &rewrite/1)} end)
+ end
+
+ @spec rewrite_tuple(tuple()) :: tuple() | map()
+ def rewrite_tuple(op_tuple), do: rewrite(op_tuple)
+
+ defp rewrite({:enum, "String[" <> rest}) do
+ {:enum, split_to_list(strip_close(rest))}
+ end
+
+ defp rewrite({:enum, "Atom[" <> rest}) do
+ items = rest |> strip_close() |> split_to_list() |> Enum.map(&String.to_atom/1)
+ {:enum, items}
+ end
+
+ defp rewrite({:enum, "Integer[" <> rest}) do
+ items = rest |> strip_close() |> split_to_list() |> Enum.map(&String.to_integer/1)
+ {:enum, items}
+ end
+
+ defp rewrite({:enum, "Float[" <> rest}) do
+ items = rest |> strip_close() |> split_to_list() |> Enum.map(&String.to_float/1)
+ {:enum, items}
+ end
+
+ defp rewrite({:enum, "Map[" <> rest}) do
+ items = rest |> strip_close() |> split_to_list() |> Enum.map(&safe_eval/1)
+
+ if Enum.any?(items, &is_nil/1) do
+ {:enum, "Map[" <> rest}
+ else
+ {:enum, items}
+ end
+ end
+
+ defp rewrite({:enum, "Tuple[" <> rest}) do
+ items = rest |> strip_close() |> split_to_list() |> Enum.map(&safe_eval/1)
+
+ if Enum.any?(items, &is_nil/1) do
+ {:enum, "Tuple[" <> rest}
+ else
+ {:enum, items}
+ end
+ end
+
+ defp rewrite({:equal, "String::" <> value}), do: {:equal, value}
+
+ defp rewrite({:equal, "Integer::" <> value}) do
+ case Integer.parse(value) do
+ {n, ""} -> {:equal, n}
+ _ -> {:equal, "Integer::" <> value}
+ end
+ end
+
+ defp rewrite({:equal, "Float::" <> value}) do
+ case Float.parse(value) do
+ {f, ""} -> {:equal, f}
+ _ -> {:equal, "Float::" <> value}
+ end
+ end
+
+ defp rewrite({:equal, "Atom::" <> value}) do
+ {:equal, String.to_atom(value)}
+ end
+
+ defp rewrite({:equal, "Map::" <> value}) do
+ case safe_eval(value) do
+ nil -> {:equal, "Map::" <> value}
+ term -> {:equal, term}
+ end
+ end
+
+ defp rewrite({:equal, "Tuple::" <> value}) do
+ case safe_eval(value) do
+ nil -> {:equal, "Tuple::" <> value}
+ term -> {:equal, term}
+ end
+ end
+
+ defp rewrite(other), do: other
+
+ defp strip_close(s) do
+ case String.split(s, "]", parts: 2) do
+ [body, _rest] -> body
+ [body] -> body
+ end
+ end
+
+ defp split_to_list(s) do
+ s |> String.split("::", trim: true) |> Enum.map(&String.trim/1)
+ end
+
+ defp safe_eval(value) do
+ case Code.eval_string(value) do
+ {term, _} -> term
+ end
+ rescue
+ _ -> nil
+ end
+end
diff --git a/lib/guarded_struct/derive/op_param_validator.ex b/lib/guarded_struct/derive/op_param_validator.ex
new file mode 100644
index 0000000..1a840a6
--- /dev/null
+++ b/lib/guarded_struct/derive/op_param_validator.ex
@@ -0,0 +1,127 @@
+defmodule GuardedStruct.Derive.OpParamValidator do
+ @moduledoc false
+
+ # Spark.Error.DslError on bad param shapes for parameterised derive ops.
+ # Examples of the kind of typo this catches at compile time:
+ #
+ # validate(max_len=foo) — max_len needs an integer, got "foo"
+ # validate(min_len=-2) — min_len needs a non-negative integer
+ # validate(record=42) — record tag must be an atom-shaped string
+ #
+ # Bare atoms (e.g. :string, :not_empty) are not param-typed and pass.
+
+ @doc """
+ Validate the parameter types of an op-map (`%{validate: [...], sanitize: [...]}`).
+ Raises Spark.Error.DslError if anything's off; returns the input unchanged on success.
+ """
+ @spec validate!(map() | nil, atom(), module()) :: map() | nil
+ def validate!(nil, _field_name, _module), do: nil
+
+ def validate!(%{} = ops, field_name, module) do
+ ops
+ |> Map.get(:validate, [])
+ |> Enum.each(&check_validate(&1, field_name, module))
+
+ ops
+ |> Map.get(:sanitize, [])
+ |> Enum.each(&check_sanitize(&1, field_name, module))
+
+ ops
+ end
+
+ defp check_validate({:max_len, n}, _f, _m) when is_integer(n) and n >= 0, do: :ok
+
+ defp check_validate({:max_len, other}, field_name, module),
+ do: bad_param!(:max_len, "non-negative integer", other, field_name, module)
+
+ defp check_validate({:min_len, n}, _f, _m) when is_integer(n) and n >= 0, do: :ok
+
+ defp check_validate({:min_len, other}, field_name, module),
+ do: bad_param!(:min_len, "non-negative integer", other, field_name, module)
+
+ defp check_validate({:tell, n}, _f, _m) when is_integer(n), do: :ok
+
+ defp check_validate({:tell, other}, field_name, module),
+ do: bad_param!(:tell, "integer (country code)", other, field_name, module)
+
+ defp check_validate({:regex, value}, _f, _m)
+ when is_list(value) or is_binary(value),
+ do: :ok
+
+ defp check_validate({:regex, other}, field_name, module),
+ do: bad_param!(:regex, "charlist or string", other, field_name, module)
+
+ defp check_validate({:enum, list}, _f, _m) when is_list(list), do: :ok
+ defp check_validate({:enum, "String[" <> _}, _f, _m), do: :ok
+ defp check_validate({:enum, "Atom[" <> _}, _f, _m), do: :ok
+ defp check_validate({:enum, "Integer[" <> _}, _f, _m), do: :ok
+ defp check_validate({:enum, "Float[" <> _}, _f, _m), do: :ok
+ defp check_validate({:enum, "Map[" <> _}, _f, _m), do: :ok
+ defp check_validate({:enum, "Tuple[" <> _}, _f, _m), do: :ok
+
+ defp check_validate({:enum, other}, field_name, module),
+ do:
+ bad_param!(
+ :enum,
+ "Type[…] form (String/Atom/Integer/Float/Map/Tuple)",
+ other,
+ field_name,
+ module
+ )
+
+ defp check_validate({:equal, value}, _f, _m) when not is_binary(value), do: :ok
+ defp check_validate({:equal, "String::" <> _}, _f, _m), do: :ok
+ defp check_validate({:equal, "Integer::" <> _}, _f, _m), do: :ok
+ defp check_validate({:equal, "Float::" <> _}, _f, _m), do: :ok
+ defp check_validate({:equal, "Atom::" <> _}, _f, _m), do: :ok
+ defp check_validate({:equal, "Map::" <> _}, _f, _m), do: :ok
+ defp check_validate({:equal, "Tuple::" <> _}, _f, _m), do: :ok
+
+ defp check_validate({:equal, other}, field_name, module),
+ do:
+ bad_param!(
+ :equal,
+ "Type::value form (String/Integer/Float/Atom/Map/Tuple)",
+ other,
+ field_name,
+ module
+ )
+
+ defp check_validate({:record, tag}, _f, _m) when is_atom(tag), do: :ok
+ defp check_validate({:record, tag}, _f, _m) when is_binary(tag), do: :ok
+
+ defp check_validate({:record, other}, field_name, module),
+ do: bad_param!(:record, "atom or string tag", other, field_name, module)
+
+ defp check_validate({:custom, {mods, fun}}, _f, _m)
+ when is_list(mods) and is_atom(fun),
+ do: :ok
+
+ defp check_validate({:custom, value}, _f, _m) when is_binary(value), do: :ok
+
+ defp check_validate({:custom, other}, field_name, module),
+ do: bad_param!(:custom, "[Module.Path, :function_name] or string", other, field_name, module)
+
+ defp check_validate(%{either: list}, field_name, module) when is_list(list) do
+ Enum.each(list, &check_validate(&1, field_name, module))
+ end
+
+ defp check_validate(_other, _f, _m), do: :ok
+
+ defp check_sanitize({:tag, sub_op}, _f, _m) when is_atom(sub_op) or is_binary(sub_op),
+ do: :ok
+
+ defp check_sanitize({:tag, other}, field_name, module),
+ do: bad_param!(:tag, "atom (e.g. :strip_tags) or string", other, field_name, module)
+
+ defp check_sanitize(_other, _f, _m), do: :ok
+
+ defp bad_param!(op, expected, actual, field_name, module) do
+ raise Spark.Error.DslError,
+ message:
+ "invalid parameter for `#{op}` on field #{inspect(field_name)}: " <>
+ "expected #{expected}, got #{inspect(actual)}.",
+ path: [:guardedstruct, :field, field_name, :derive],
+ module: module
+ end
+end
diff --git a/lib/guarded_struct/derive/parser.ex b/lib/guarded_struct/derive/parser.ex
new file mode 100644
index 0000000..43eb733
--- /dev/null
+++ b/lib/guarded_struct/derive/parser.ex
@@ -0,0 +1,205 @@
+defmodule GuardedStruct.Derive.Parser do
+ @moduledoc false
+
+ @doc """
+ Parse a derive string into `%{sanitize: [...], validate: [...]}`.
+
+ Returns `nil` for `nil`/empty input or for strings the AST parser refuses.
+ """
+ @spec parser(nil | String.t() | [String.t()]) :: nil | map() | [map()]
+ def parser(nil), do: nil
+ def parser(""), do: nil
+
+ def parser(inputs) when is_list(inputs), do: Enum.map(inputs, &parser/1)
+
+ def parser(input) when is_binary(input) do
+ with {:ok, ast} <- to_block_ast(input) do
+ ast
+ |> normalize_block()
+ |> Enum.reduce(%{}, &collect_call/2)
+ |> nilify_empty()
+ else
+ _ -> nil
+ end
+ rescue
+ _ -> nil
+ end
+
+ defp to_block_ast(input) do
+ wrapped =
+ input
+ |> String.trim()
+ |> balance_parens()
+ |> String.replace(~r/\)\s+/u, ")\n")
+ |> then(&"(\n#{&1}\n)")
+
+ Code.string_to_quoted(wrapped, emit_warnings: false)
+ end
+
+ defp balance_parens(input) do
+ {depth, _state} =
+ input
+ |> :binary.bin_to_list()
+ |> Enum.reduce({0, :code}, fn ch, {d, state} ->
+ case {state, ch} do
+ {:in_string, ?\\} -> {d, :string_escape}
+ {:string_escape, _} -> {d, :in_string}
+ {:in_string, ?"} -> {d, :code}
+ {:in_string, _} -> {d, :in_string}
+ {:in_charlist, ?\\} -> {d, :charlist_escape}
+ {:charlist_escape, _} -> {d, :in_charlist}
+ {:in_charlist, ?'} -> {d, :code}
+ {:in_charlist, _} -> {d, :in_charlist}
+ {:code, ?"} -> {d, :in_string}
+ {:code, ?'} -> {d, :in_charlist}
+ {:code, ?(} -> {d + 1, :code}
+ {:code, ?)} -> {d - 1, :code}
+ {:code, _} -> {d, :code}
+ end
+ end)
+
+ if depth > 0, do: input <> String.duplicate(")", depth), else: input
+ end
+
+ defp normalize_block({:__block__, _, calls}), do: calls
+ defp normalize_block(single_call), do: [single_call]
+
+ defp nilify_empty(map) when map == %{}, do: nil
+ defp nilify_empty(map), do: map
+
+ defp collect_call({op, _meta, args}, acc)
+ when op in [:sanitize, :validate] and is_list(args) do
+ parsed = args |> Enum.map(&parse_arg/1) |> Enum.reject(&is_nil/1)
+ Map.update(acc, op, parsed, &(&1 ++ parsed))
+ end
+
+ defp collect_call(_other, acc), do: acc
+
+ defp parse_arg({atom, _meta, nil}) when is_atom(atom), do: atom
+
+ defp parse_arg({:=, _, [{:custom, _, nil}, value]}) when is_list(value) do
+ case value do
+ [{:__aliases__, _, mods}, {fun, _, nil}] when is_atom(fun) ->
+ {:custom, {mods, fun}}
+
+ _ ->
+ nil
+ end
+ end
+
+ defp parse_arg({:=, _, [{key, _, nil}, {value, _, nil}]})
+ when is_atom(key) and is_atom(value) do
+ {key, Atom.to_string(value)}
+ end
+
+ defp parse_arg({:=, _, [{key, _, nil}, value]})
+ when is_atom(key) and is_integer(value),
+ do: {key, value}
+
+ defp parse_arg({:=, _, [{key, _, nil}, value]})
+ when is_atom(key) and is_binary(value),
+ do: {key, value}
+
+ defp parse_arg({:=, _, [{key, _, nil}, value]})
+ when is_atom(key) and is_list(value) do
+ if Enum.any?(value, &is_tuple/1) do
+ inner = value |> Enum.map(&parse_arg/1) |> Enum.reject(&is_nil/1)
+ if inner == [], do: nil, else: %{key => inner}
+ else
+ {key, value}
+ end
+ end
+
+ defp parse_arg({:=, _, [{key, _, nil}, {_, _, [{:__aliases__, _, [type]} | _]} = value]})
+ when is_atom(key) and is_atom(type) do
+ {key, ast_to_string(value)}
+ end
+
+ defp parse_arg(_other), do: nil
+
+ defp ast_to_string(ast) do
+ ast |> Macro.update_meta(fn _ -> [] end) |> Macro.to_string()
+ end
+
+ @doc """
+ Recursively convert string keys to atoms in a map.
+
+ ## Atom-attack safety
+
+ This function is **doubly defensive** against atom-table-exhaustion DoS:
+
+ 1. It uses `String.to_existing_atom/1` — string keys are converted to
+ atoms ONLY if the atom already exists in the atom table. Unknown
+ keys (e.g. attacker-controlled inputs) stay as strings.
+
+ 2. `convert_to_atom_map/2` accepts an optional `passthrough_keys` list.
+ Values whose key is in that list are LEFT ENTIRELY UNTOUCHED — no
+ recursion, no key conversion at any depth. The runtime uses this
+ to mark `dynamic_field` values, so their free-form inner shapes
+ round-trip exactly as the user submitted them.
+
+ See the "Atom-attack safety" section of the `GuardedStruct` module
+ `@moduledoc` for the threat model and the recommended pattern when
+ consuming user-supplied data into a `dynamic_field`.
+ """
+ @spec convert_to_atom_map(
+ {:ok, map()} | {:error, any(), any()} | map(),
+ [atom()]
+ ) :: {:error, any(), any()} | map()
+ def convert_to_atom_map(map_or_result, passthrough_keys \\ [])
+
+ def convert_to_atom_map({:error, _, _} = error, _), do: error
+
+ def convert_to_atom_map({:ok, map}, pt) when is_map(map),
+ do: convert_to_atom_map(map, pt)
+
+ def convert_to_atom_map(map, pt) when is_struct(map) do
+ do_convert(Map.from_struct(map), pt)
+ end
+
+ def convert_to_atom_map(map, pt) when is_map(map) do
+ do_convert(map, pt)
+ end
+
+ defp do_convert(map, passthrough_keys) do
+ passthrough = MapSet.new(passthrough_keys)
+
+ for {k, v} <- map, into: %{} do
+ atom_key = convert_key(k)
+ new_value = if MapSet.member?(passthrough, atom_key), do: v, else: convert_value(v)
+ {atom_key, new_value}
+ end
+ end
+
+ # Convert binary keys to atoms ONLY if the atom already exists in the
+ # atom table. Unknown / attacker-controlled keys stay as strings.
+ defp convert_key(key) when is_binary(key) do
+ String.to_existing_atom(key)
+ rescue
+ ArgumentError -> key
+ end
+
+ defp convert_key(key), do: key
+
+ defp convert_value(%{__struct__: s} = m) when s in [NaiveDateTime, DateTime, Date], do: m
+ defp convert_value(%{} = m), do: convert_to_atom_map(m)
+ defp convert_value([]), do: []
+ defp convert_value(list) when is_list(list), do: Enum.map(list, &convert_value/1)
+ defp convert_value(value), do: value
+
+ @spec parse_core_keys_pattern(binary()) :: [atom()]
+ def parse_core_keys_pattern(pattern) do
+ pattern
+ |> String.trim()
+ |> String.split("::", trim: true)
+ |> Enum.map(&String.to_atom/1)
+ end
+
+ @spec convert_parameters(atom() | String.t(), any()) :: nil | %{optional(any()) => list()}
+ def convert_parameters(derive_key, parameters) when is_list(parameters) do
+ converted = parameters |> Enum.map(&parse_arg/1) |> Enum.reject(&is_nil/1)
+ if converted == [], do: nil, else: %{derive_key => converted}
+ end
+
+ def convert_parameters(_derive_key, _parameters), do: nil
+end
diff --git a/lib/guarded_struct/derive/registry.ex b/lib/guarded_struct/derive/registry.ex
new file mode 100644
index 0000000..7b5a279
--- /dev/null
+++ b/lib/guarded_struct/derive/registry.ex
@@ -0,0 +1,75 @@
+defmodule GuardedStruct.Derive.Registry do
+ @moduledoc false
+
+ @validate_ops MapSet.new([
+ :string,
+ :integer,
+ :list,
+ :atom,
+ :bitstring,
+ :boolean,
+ :exception,
+ :float,
+ :function,
+ :map,
+ :nil_value,
+ :not_nil_value,
+ :number,
+ :pid,
+ :port,
+ :reference,
+ :struct,
+ :tuple,
+ :not_empty,
+ :not_flatten_empty,
+ :not_flatten_empty_item,
+ :queue,
+ :max_len,
+ :min_len,
+ :url,
+ :tell,
+ :geo_url,
+ :email,
+ :email_r,
+ :location,
+ :string_boolean,
+ :datetime,
+ :range,
+ :date,
+ :regex,
+ :ipv4,
+ :not_empty_string,
+ :uuid,
+ :username,
+ :full_name,
+ :enum,
+ :equal,
+ :custom,
+ :either,
+ :record,
+ :string_float,
+ :string_integer,
+ :some_string_float,
+ :some_string_integer
+ ])
+
+ @sanitize_ops MapSet.new([
+ :trim,
+ :upcase,
+ :downcase,
+ :capitalize,
+ :basic_html,
+ :html5,
+ :markdown_html,
+ :strip_tags,
+ :tag,
+ :string_float,
+ :string_integer
+ ])
+
+ def validate_ops, do: @validate_ops
+ def sanitize_ops, do: @sanitize_ops
+
+ def known_validate?(name) when is_atom(name), do: MapSet.member?(@validate_ops, name)
+ def known_sanitize?(name) when is_atom(name), do: MapSet.member?(@sanitize_ops, name)
+end
diff --git a/lib/derive/sanitizer_derive.ex b/lib/guarded_struct/derive/sanitizer_derive.ex
similarity index 93%
rename from lib/derive/sanitizer_derive.ex
rename to lib/guarded_struct/derive/sanitizer_derive.ex
index 3e9f0d8..2a955b2 100644
--- a/lib/derive/sanitizer_derive.ex
+++ b/lib/guarded_struct/derive/sanitizer_derive.ex
@@ -84,6 +84,15 @@ defmodule GuardedStruct.Derive.SanitizerDerive do
end
def sanitize(action, input) do
+ case GuardedStruct.Derive.Extension.dispatch_sanitize(action, input) do
+ :__not_found__ -> fallback_dispatch(action, input)
+ result -> result
+ end
+ rescue
+ _ -> input
+ end
+
+ defp fallback_dispatch(action, input) do
case Application.get_env(:guarded_struct, :sanitize_derive) do
nil ->
input
@@ -94,8 +103,6 @@ defmodule GuardedStruct.Derive.SanitizerDerive do
derive_module ->
derive_module.sanitize(action, input)
end
- rescue
- _ -> input
end
defp custom_derive(derive_list, action, input) do
diff --git a/lib/derive/validation_derive.ex b/lib/guarded_struct/derive/validation_derive.ex
similarity index 91%
rename from lib/derive/validation_derive.ex
rename to lib/guarded_struct/derive/validation_derive.ex
index dd2d5d5..0d2ce90 100644
--- a/lib/derive/validation_derive.ex
+++ b/lib/guarded_struct/derive/validation_derive.ex
@@ -40,76 +40,35 @@ defmodule GuardedStruct.Derive.ValidationDerive do
end
@spec validate(atom() | tuple(), any(), atom()) :: any()
- def validate(:string, input, field) do
- is_type(field, is_binary(input), :string, input)
- end
-
- def validate(:integer, input, field) do
- is_type(field, is_integer(input), :integer, input)
- end
-
- def validate(:list, input, field) do
- is_type(field, is_list(input), :list, input)
- end
-
- def validate(:atom, input, field) do
- is_type(field, is_atom(input), :atom, input)
- end
-
- def validate(:bitstring, input, field) do
- is_type(field, is_bitstring(input), :bitstring, input)
- end
-
- def validate(:boolean, input, field) do
- is_type(field, is_boolean(input), :boolean, input)
- end
-
- def validate(:exception, input, field) do
- is_type(field, is_exception(input), :exception, input)
- end
-
- def validate(:float, input, field) do
- is_type(field, is_float(input), :float, input)
- end
-
- def validate(:function, input, field) do
- is_type(field, is_function(input), :function, input)
- end
- def validate(:map, input, field) do
- is_type(field, is_map(input), :map, input)
- end
-
- def validate(:nil_value, input, field) do
- is_type(field, is_nil(input), :nil_value, input)
+ type_predicates = [
+ string: :is_binary,
+ integer: :is_integer,
+ list: :is_list,
+ atom: :is_atom,
+ bitstring: :is_bitstring,
+ boolean: :is_boolean,
+ exception: :is_exception,
+ float: :is_float,
+ function: :is_function,
+ map: :is_map,
+ nil_value: :is_nil,
+ number: :is_number,
+ pid: :is_pid,
+ port: :is_port,
+ reference: :is_reference,
+ struct: :is_struct,
+ tuple: :is_tuple
+ ]
+
+ for {op, pred_name} <- type_predicates do
+ def validate(unquote(op), input, field) do
+ is_type(field, unquote(pred_name)(input), unquote(op), input)
+ end
end
def validate(:not_nil_value, input, field) do
- is_type(field, !is_nil(input), :not_nil_value, input)
- end
-
- def validate(:number, input, field) do
- is_type(field, is_number(input), :number, input)
- end
-
- def validate(:pid, input, field) do
- is_type(field, is_pid(input), :pid, input)
- end
-
- def validate(:port, input, field) do
- is_type(field, is_port(input), :port, input)
- end
-
- def validate(:reference, input, field) do
- is_type(field, is_reference(input), :reference, input)
- end
-
- def validate(:struct, input, field) do
- is_type(field, is_struct(input), :struct, input)
- end
-
- def validate(:tuple, input, field) do
- is_type(field, is_tuple(input), :tuple, input)
+ is_type(field, not is_nil(input), :not_nil_value, input)
end
def validate(:not_empty, input, field) when is_binary(input) do
@@ -447,6 +406,10 @@ defmodule GuardedStruct.Derive.ValidationDerive do
{:error, field, :full_name, translated_message(:full_name, field)}
end
+ def validate({:enum, list}, input, field) when is_list(list) do
+ convert_enum_output(list, input, field)
+ end
+
def validate({:enum, "String" <> list}, input, field) when is_binary(input) do
convert_enum(list)
|> convert_enum_output(input, field)
@@ -486,6 +449,10 @@ defmodule GuardedStruct.Derive.ValidationDerive do
{:error, field, :enum, translated_message(:enum, field)}
end
+ def validate({:equal, value}, input, field) when not is_binary(value) do
+ vlidate_equal(value, input, field)
+ end
+
def validate({:equal, "String::" <> value}, input, field) do
vlidate_equal(value, input, field)
end
@@ -603,7 +570,33 @@ defmodule GuardedStruct.Derive.ValidationDerive do
{:error, field, :some_string_integer, translated_message(:some_string_integer, field)}
end
+ def validate(:record, input, field) do
+ if record?(input),
+ do: input,
+ else: {:error, field, :record, translated_message(:record, field)}
+ end
+
+ def validate({:record, tag}, input, field) when is_atom(tag) do
+ if record?(input) and elem(input, 0) == tag,
+ do: input,
+ else: {:error, field, :record, translated_message(:record, field)}
+ end
+
+ def validate({:record, tag}, input, field) when is_binary(tag) do
+ validate({:record, String.to_atom(tag)}, input, field)
+ end
+
def validate(action, input, field) do
+ case GuardedStruct.Derive.Extension.dispatch_validate(action, input, field) do
+ :__not_found__ -> fallback_dispatch(action, input, field)
+ result -> result
+ end
+ rescue
+ _ ->
+ {:error, field, :type, translated_message(:validate_unexpected, field)}
+ end
+
+ defp fallback_dispatch(action, input, field) do
case Application.get_env(:guarded_struct, :validate_derive) do
nil ->
{:error, field, :type, translated_message(:validate_unexpected, field)}
@@ -614,9 +607,6 @@ defmodule GuardedStruct.Derive.ValidationDerive do
derive_module ->
derive_module.validate(action, input, field)
end
- rescue
- _ ->
- {:error, field, :type, translated_message(:validate_unexpected, field)}
end
if Code.ensure_loaded?(URL) do
@@ -635,6 +625,10 @@ defmodule GuardedStruct.Derive.ValidationDerive do
end
end
+ defp record?(input) do
+ is_tuple(input) and tuple_size(input) > 0 and is_atom(elem(input, 0))
+ end
+
defp is_type(field, status, type, input) do
if status,
do: input,
diff --git a/lib/guarded_struct/diff.ex b/lib/guarded_struct/diff.ex
new file mode 100644
index 0000000..15f7bda
--- /dev/null
+++ b/lib/guarded_struct/diff.ex
@@ -0,0 +1,114 @@
+defmodule GuardedStruct.Diff do
+ @moduledoc """
+ Field-level diffs between two GuardedStruct instances.
+
+ iex> GuardedStruct.Diff.diff(
+ ...> %User{name: "Alice", age: 30, role: "admin"},
+ ...> %User{name: "Alice", age: 31, role: "user"}
+ ...> )
+ %{
+ age: {:changed, 30, 31},
+ role: {:changed, "admin", "user"}
+ }
+
+ Equal fields are omitted. Nested struct fields recurse — `name: %User{} → %User{}`
+ produces a nested map, not a `:changed` tuple.
+
+ Useful for audit logs, CRM history, and "what changed" UIs.
+ """
+
+ @doc """
+ Diff two structs of the same module. Returns a map keyed by field name with
+ values of one of:
+
+ * `{:changed, old, new}` — primitive value differs
+ * `%{...}` — nested struct, recursively diffed (only if there ARE changes)
+
+ Equal fields are omitted from the result.
+
+ Returns `:not_comparable` if the two values are not the same struct module.
+ """
+ @spec diff(struct() | map() | any(), struct() | map() | any()) :: map() | :not_comparable
+ def diff(%mod{} = a, %mod{} = b), do: diff_keys(Map.from_struct(a), Map.from_struct(b))
+ def diff(%{} = a, %{} = b) when not is_struct(a) and not is_struct(b), do: diff_keys(a, b)
+ def diff(_, _), do: :not_comparable
+
+ defp diff_keys(a, b) do
+ keys = Map.keys(a) |> Enum.uniq()
+
+ Enum.reduce(keys, %{}, fn key, acc ->
+ av = Map.get(a, key)
+ bv = Map.get(b, key)
+
+ case compare(av, bv) do
+ :unchanged -> acc
+ change -> Map.put(acc, key, change)
+ end
+ end)
+ end
+
+ defp compare(v, v), do: :unchanged
+
+ defp compare(%mod{} = a, %mod{} = b) do
+ case diff(a, b) do
+ d when d == %{} -> :unchanged
+ d -> d
+ end
+ end
+
+ defp compare(a, b), do: {:changed, a, b}
+
+ @doc """
+ Apply a diff to a struct, returning the result of applying the new values.
+
+ iex> User.builder(%{name: "Alice", age: 30}) |> elem(1) |> GuardedStruct.Diff.apply(%{age: {:changed, 30, 31}})
+ %User{name: "Alice", age: 31}
+
+ Nested struct diffs apply recursively. `:changed` tuples replace the field
+ with their `new` value. Unknown keys in the diff are silently ignored.
+ """
+ @spec apply(struct() | map(), map()) :: struct() | map()
+ def apply(%_{} = struct, diff) when is_map(diff) do
+ Enum.reduce(diff, struct, fn
+ {key, {:changed, _old, new}}, acc when is_struct(acc) ->
+ if Map.has_key?(acc, key), do: Map.put(acc, key, new), else: acc
+
+ {key, %{} = nested}, acc when is_struct(acc) ->
+ case Map.get(acc, key) do
+ %_{} = current -> Map.put(acc, key, __MODULE__.apply(current, nested))
+ _ -> acc
+ end
+
+ _, acc ->
+ acc
+ end)
+ end
+
+ def apply(map, diff) when is_map(map) and is_map(diff) do
+ Enum.reduce(diff, map, fn
+ {key, {:changed, _, new}}, acc ->
+ Map.put(acc, key, new)
+
+ {key, %{} = nested}, acc ->
+ case Map.get(acc, key) do
+ %{} = sub -> Map.put(acc, key, __MODULE__.apply(sub, nested))
+ _ -> acc
+ end
+
+ _, acc ->
+ acc
+ end)
+ end
+
+ @doc """
+ Returns `true` if the two structs are equal field-by-field (same as
+ `diff(a, b) == %{}` but skips work for unchanged fields).
+ """
+ @spec equal?(any(), any()) :: boolean()
+ def equal?(a, b) do
+ case diff(a, b) do
+ :not_comparable -> false
+ %{} = d -> map_size(d) == 0
+ end
+ end
+end
diff --git a/lib/guarded_struct/dsl.ex b/lib/guarded_struct/dsl.ex
new file mode 100644
index 0000000..61a4a06
--- /dev/null
+++ b/lib/guarded_struct/dsl.ex
@@ -0,0 +1,244 @@
+defmodule GuardedStruct.Dsl do
+ @moduledoc false
+
+ @field %Spark.Dsl.Entity{
+ name: :field,
+ target: GuardedStruct.Dsl.Field,
+ args: [:name, :type],
+ schema: [
+ name: [type: :any, required: true],
+ type: [type: :quoted, required: true],
+ enforce: [type: :boolean],
+ default: [type: :quoted],
+ derives: [type: :string],
+ derive: [type: :string],
+ validator: [type: {:tuple, [:atom, :atom]}],
+ auto: [
+ type:
+ {:or,
+ [
+ {:tuple, [:atom, :atom]},
+ {:tuple, [:atom, :atom, :any]}
+ ]}
+ ],
+ from: [type: :string],
+ on: [type: :string],
+ domain: [type: :string],
+ struct: [type: :atom],
+ structs: [type: {:or, [:atom, :boolean]}],
+ hint: [type: :string],
+ priority: [type: :boolean]
+ ]
+ }
+
+ @virtual_field %Spark.Dsl.Entity{
+ name: :virtual_field,
+ target: GuardedStruct.Dsl.VirtualField,
+ args: [:name, :type],
+ schema: [
+ name: [type: :any, required: true],
+ type: [type: :quoted, required: true],
+ enforce: [type: :boolean],
+ default: [type: :quoted],
+ derives: [type: :string],
+ derive: [type: :string],
+ validator: [type: {:tuple, [:atom, :atom]}],
+ auto: [
+ type:
+ {:or,
+ [
+ {:tuple, [:atom, :atom]},
+ {:tuple, [:atom, :atom, :any]}
+ ]}
+ ],
+ from: [type: :string],
+ on: [type: :string],
+ domain: [type: :string],
+ hint: [type: :string]
+ ]
+ }
+
+ @dynamic_field %Spark.Dsl.Entity{
+ name: :dynamic_field,
+ target: GuardedStruct.Dsl.Field,
+ args: [:name],
+ # Marks every `dynamic_field` entry distinct from a regular `field`.
+ # The runtime uses this to skip recursive atom-conversion of the value.
+ auto_set_fields: [__dynamic__: true],
+ schema: [
+ name: [type: :any, required: true],
+ type: [type: :quoted, default: quote(do: map())],
+ enforce: [type: :boolean],
+ default: [type: :quoted, default: Macro.escape(%{})],
+ derives: [type: :string, default: "validate(map)"],
+ derive: [type: :string],
+ validator: [type: {:tuple, [:atom, :atom]}],
+ auto: [
+ type:
+ {:or,
+ [
+ {:tuple, [:atom, :atom]},
+ {:tuple, [:atom, :atom, :any]}
+ ]}
+ ],
+ from: [type: :string],
+ on: [type: :string],
+ domain: [type: :string],
+ hint: [type: :string]
+ ]
+ }
+
+ @sub_field_base %Spark.Dsl.Entity{
+ name: :sub_field,
+ target: GuardedStruct.Dsl.SubField,
+ args: [:name, :type],
+ schema: [
+ name: [type: :atom, required: true],
+ type: [type: :quoted, required: true],
+ enforce: [type: :boolean],
+ default: [type: :quoted],
+ derives: [type: :string],
+ derive: [type: :string],
+ validator: [type: {:tuple, [:atom, :atom]}],
+ auto: [
+ type:
+ {:or,
+ [
+ {:tuple, [:atom, :atom]},
+ {:tuple, [:atom, :atom, :any]}
+ ]}
+ ],
+ from: [type: :string],
+ on: [type: :string],
+ domain: [type: :string],
+ struct: [type: :atom],
+ structs: [type: {:or, [:atom, :boolean]}],
+ hint: [type: :string],
+ priority: [type: :boolean],
+ error: [type: :boolean],
+ authorized_fields: [type: :boolean],
+ main_validator: [type: {:tuple, [:atom, :atom]}]
+ ],
+ recursive_as: :sub_fields,
+ entities: [
+ fields: [@field],
+ sub_fields: [],
+ conditional_fields: []
+ ]
+ }
+
+ @conditional_field_base %Spark.Dsl.Entity{
+ name: :conditional_field,
+ target: GuardedStruct.Dsl.ConditionalField,
+ args: [:name, :type],
+ schema: [
+ name: [type: :atom, required: true],
+ type: [type: :quoted, required: true],
+ enforce: [type: :boolean],
+ default: [type: :quoted],
+ derives: [type: :string],
+ derive: [type: :string],
+ validator: [type: {:tuple, [:atom, :atom]}],
+ auto: [
+ type:
+ {:or,
+ [
+ {:tuple, [:atom, :atom]},
+ {:tuple, [:atom, :atom, :any]}
+ ]}
+ ],
+ from: [type: :string],
+ on: [type: :string],
+ domain: [type: :string],
+ struct: [type: :atom],
+ structs: [type: {:or, [:atom, :boolean]}],
+ hint: [type: :string],
+ priority: [type: :boolean]
+ ],
+ recursive_as: :conditional_fields,
+ entities: [
+ fields: [@field],
+ sub_fields: [@sub_field_base],
+ conditional_fields: []
+ ]
+ }
+
+ @sub_field %{
+ @sub_field_base
+ | entities:
+ @sub_field_base.entities
+ |> Keyword.put(:sub_fields, [@sub_field_base])
+ |> Keyword.put(:conditional_fields, [@conditional_field_base])
+ }
+
+ @conditional_field %{
+ @conditional_field_base
+ | entities:
+ @conditional_field_base.entities
+ |> Keyword.put(:conditional_fields, [@conditional_field_base])
+ |> Keyword.put(:sub_fields, [@sub_field])
+ }
+
+ @section %Spark.Dsl.Section{
+ name: :guardedstruct,
+ schema: [
+ enforce: [type: :boolean, default: false],
+ opaque: [type: :boolean, default: false],
+ module: [type: :quoted],
+ error: [type: :boolean, default: false],
+ authorized_fields: [type: :boolean, default: false],
+ main_validator: [type: {:tuple, [:atom, :atom]}],
+ validate_derive: [type: {:or, [:atom, {:list, :atom}]}],
+ sanitize_derive: [type: {:or, [:atom, {:list, :atom}]}],
+ json: [
+ type: :boolean,
+ default: false,
+ doc:
+ "When `true`, derives a JSON encoder. Uses `Jason.Encoder` if " <>
+ "`:jason` is in the user's deps; otherwise falls back to the " <>
+ "built-in `JSON.Encoder` on Elixir 1.18+. No-op if neither is " <>
+ "available."
+ ],
+ auto_wire: [
+ type: :boolean,
+ default: false,
+ doc:
+ "Only effective inside the `GuardedStruct.AshResource` extension. " <>
+ "When `true`, injects `GuardedStruct.AshResource.Change` into the " <>
+ "resource's top-level `changes` section so every `:create` and " <>
+ "`:update` action automatically runs the GuardedStruct pipeline. " <>
+ "Equivalent to writing `changes do change GuardedStruct.AshResource.Change end` " <>
+ "by hand. No-op outside the Ash extension."
+ ],
+ atomic: [
+ type: :boolean,
+ default: false,
+ doc:
+ "Opt into atomic-SQL mode. When `true`, the `VerifyAtomic` " <>
+ "verifier rejects at compile time any field whose derive ops, " <>
+ "per-field `validator:`, `auto:`, or top-level `main_validator/1` " <>
+ "callback can't translate to atomic SQL (e.g. `validate(email)` " <>
+ "which does DNS lookup, custom MFAs, custom Derive.Extension ops). " <>
+ "Errors point at the offending field with the exact reason. " <>
+ "Default `false` keeps the imperative path."
+ ]
+ ],
+ entities: [@field, @virtual_field, @dynamic_field, @sub_field, @conditional_field]
+ }
+
+ use Spark.Dsl.Extension,
+ sections: [@section],
+ transformers: [
+ GuardedStruct.Transformers.ParseDerive,
+ GuardedStruct.Transformers.ParseCoreKeys,
+ GuardedStruct.Transformers.ParseDomain,
+ GuardedStruct.Transformers.GenerateSubFieldModules,
+ GuardedStruct.Transformers.GenerateBuilder
+ ],
+ verifiers: [
+ GuardedStruct.Verifiers.VerifyValidatorMFA,
+ GuardedStruct.Verifiers.VerifyAutoMFA,
+ GuardedStruct.Verifiers.VerifyNoStructCycles,
+ GuardedStruct.Verifiers.VerifyAtomic
+ ]
+end
diff --git a/lib/guarded_struct/dsl/conditional_field.ex b/lib/guarded_struct/dsl/conditional_field.ex
new file mode 100644
index 0000000..d636c34
--- /dev/null
+++ b/lib/guarded_struct/dsl/conditional_field.ex
@@ -0,0 +1,55 @@
+defmodule GuardedStruct.Dsl.ConditionalField do
+ @moduledoc false
+
+ defstruct [
+ :name,
+ :type,
+ :enforce,
+ :default,
+ :derive,
+ :derives,
+ :validator,
+ :auto,
+ :from,
+ :on,
+ :domain,
+ :struct,
+ :structs,
+ :hint,
+ :priority,
+ fields: [],
+ sub_fields: [],
+ conditional_fields: [],
+ __spark_metadata__: nil,
+ __derive_ops__: nil,
+ __from_path__: nil,
+ __on_path__: nil,
+ __domain_ops__: nil
+ ]
+
+ @type t :: %__MODULE__{
+ name: atom(),
+ type: any(),
+ enforce: boolean() | nil,
+ default: any(),
+ derive: any(),
+ derives: String.t() | nil,
+ validator: {module(), atom()} | nil,
+ auto: tuple() | nil,
+ from: String.t() | nil,
+ on: String.t() | nil,
+ domain: String.t() | nil,
+ struct: module() | nil,
+ structs: module() | boolean() | nil,
+ hint: String.t() | nil,
+ priority: boolean() | nil,
+ fields: list(),
+ sub_fields: list(),
+ conditional_fields: list(),
+ __spark_metadata__: any(),
+ __derive_ops__: map() | nil,
+ __from_path__: [atom()] | nil,
+ __on_path__: [atom()] | nil,
+ __domain_ops__: list() | nil
+ }
+end
diff --git a/lib/guarded_struct/dsl/field.ex b/lib/guarded_struct/dsl/field.ex
new file mode 100644
index 0000000..15a162e
--- /dev/null
+++ b/lib/guarded_struct/dsl/field.ex
@@ -0,0 +1,55 @@
+defmodule GuardedStruct.Dsl.Field do
+ @moduledoc false
+
+ defstruct [
+ :name,
+ :type,
+ :enforce,
+ :default,
+ :derive,
+ :derives,
+ :validator,
+ :auto,
+ :from,
+ :on,
+ :domain,
+ :struct,
+ :structs,
+ :hint,
+ :priority,
+ :__spark_metadata__,
+ :__derive_ops__,
+ :__from_path__,
+ :__on_path__,
+ :__domain_ops__,
+ # Set to true ONLY for entries from the `dynamic_field` DSL keyword
+ # (via Spark `auto_set_fields:`). Used by the runtime to skip
+ # recursive atom-conversion of the value — preventing atom-table
+ # exhaustion from attacker-controlled keys inside the free-form map.
+ __dynamic__: false
+ ]
+
+ @type t :: %__MODULE__{
+ name: atom(),
+ type: any(),
+ enforce: boolean() | nil,
+ default: any(),
+ derive: any(),
+ derives: String.t() | nil,
+ validator: {module(), atom()} | nil,
+ auto: tuple() | nil,
+ from: String.t() | nil,
+ on: String.t() | nil,
+ domain: String.t() | nil,
+ struct: module() | nil,
+ structs: module() | boolean() | nil,
+ hint: String.t() | nil,
+ priority: boolean() | nil,
+ __spark_metadata__: any(),
+ __derive_ops__: map() | nil,
+ __from_path__: [atom()] | nil,
+ __on_path__: [atom()] | nil,
+ __domain_ops__: list() | nil,
+ __dynamic__: boolean()
+ }
+end
diff --git a/lib/guarded_struct/dsl/sub_field.ex b/lib/guarded_struct/dsl/sub_field.ex
new file mode 100644
index 0000000..ce19963
--- /dev/null
+++ b/lib/guarded_struct/dsl/sub_field.ex
@@ -0,0 +1,61 @@
+defmodule GuardedStruct.Dsl.SubField do
+ @moduledoc false
+
+ defstruct [
+ :name,
+ :type,
+ :enforce,
+ :default,
+ :derive,
+ :derives,
+ :validator,
+ :auto,
+ :from,
+ :on,
+ :domain,
+ :struct,
+ :structs,
+ :hint,
+ :priority,
+ :error,
+ :authorized_fields,
+ :main_validator,
+ fields: [],
+ sub_fields: [],
+ conditional_fields: [],
+ __spark_metadata__: nil,
+ __derive_ops__: nil,
+ __from_path__: nil,
+ __on_path__: nil,
+ __domain_ops__: nil
+ ]
+
+ @type t :: %__MODULE__{
+ name: atom(),
+ type: any(),
+ enforce: boolean() | nil,
+ default: any(),
+ derive: any(),
+ derives: String.t() | nil,
+ validator: {module(), atom()} | nil,
+ auto: tuple() | nil,
+ from: String.t() | nil,
+ on: String.t() | nil,
+ domain: String.t() | nil,
+ struct: module() | nil,
+ structs: module() | boolean() | nil,
+ hint: String.t() | nil,
+ priority: boolean() | nil,
+ error: boolean() | nil,
+ authorized_fields: boolean() | nil,
+ main_validator: {module(), atom()} | nil,
+ fields: list(),
+ sub_fields: list(),
+ conditional_fields: list(),
+ __spark_metadata__: any(),
+ __derive_ops__: map() | nil,
+ __from_path__: [atom()] | nil,
+ __on_path__: [atom()] | nil,
+ __domain_ops__: list() | nil
+ }
+end
diff --git a/lib/guarded_struct/dsl/virtual_field.ex b/lib/guarded_struct/dsl/virtual_field.ex
new file mode 100644
index 0000000..4c6147f
--- /dev/null
+++ b/lib/guarded_struct/dsl/virtual_field.ex
@@ -0,0 +1,43 @@
+defmodule GuardedStruct.Dsl.VirtualField do
+ @moduledoc false
+
+ defstruct [
+ :name,
+ :type,
+ :enforce,
+ :default,
+ :derive,
+ :derives,
+ :validator,
+ :auto,
+ :from,
+ :on,
+ :domain,
+ :hint,
+ :__spark_metadata__,
+ :__derive_ops__,
+ :__from_path__,
+ :__on_path__,
+ :__domain_ops__
+ ]
+
+ @type t :: %__MODULE__{
+ name: atom(),
+ type: any(),
+ enforce: boolean() | nil,
+ default: any(),
+ derive: any(),
+ derives: String.t() | nil,
+ validator: {module(), atom()} | nil,
+ auto: tuple() | nil,
+ from: String.t() | nil,
+ on: String.t() | nil,
+ domain: String.t() | nil,
+ hint: String.t() | nil,
+ __spark_metadata__: any(),
+ __derive_ops__: map() | nil,
+ __from_path__: [atom()] | nil,
+ __on_path__: [atom()] | nil,
+ __domain_ops__: list() | nil
+ }
+end
diff --git a/lib/guarded_struct/errors.ex b/lib/guarded_struct/errors.ex
new file mode 100644
index 0000000..0bae460
--- /dev/null
+++ b/lib/guarded_struct/errors.ex
@@ -0,0 +1,70 @@
+defmodule GuardedStruct.Errors do
+ @moduledoc """
+ Splode error aggregator for GuardedStruct runtime errors.
+
+ `builder/1` returns errors as `{:error, [%{field, action, message, ...}]}`.
+ This module wraps that list into Splode exceptions, giving you
+ `traverse_errors/2`, `to_class/1`, `set_path/2`, and JSON serialization.
+
+ ## Usage
+
+ case MyStruct.builder(input) do
+ {:ok, _} = ok ->
+ ok
+
+ {:error, errs} ->
+ {:error, GuardedStruct.Errors.from_tuple(errs)}
+ end
+
+ Or build a single error directly:
+
+ GuardedStruct.Errors.Validation.exception(
+ field: :email,
+ action: :email_r,
+ message: "Invalid email format"
+ )
+ """
+
+ use Splode,
+ error_classes: [
+ invalid: GuardedStruct.Errors.Invalid
+ ],
+ unknown_error: GuardedStruct.Errors.Unknown
+
+ @doc """
+ Convert an error tuple list into a Splode error class. Accepts either the
+ inner list or the full `{:error, list}` tuple.
+ """
+ @spec from_tuple({:error, list()} | list()) :: Splode.Error.t()
+ def from_tuple({:error, errors}) when is_list(errors), do: from_tuple(errors)
+
+ def from_tuple(errors) when is_list(errors) do
+ errors
+ |> Enum.map(&to_splode/1)
+ |> to_class()
+ end
+
+ defp to_splode(%{field: field, errors: child_errors, action: :conditionals})
+ when is_list(child_errors) do
+ GuardedStruct.Errors.Validation.exception(
+ field: field,
+ action: :conditionals,
+ message: "Conditional field validation failed",
+ child_errors: Enum.map(child_errors, &to_splode/1)
+ )
+ end
+
+ defp to_splode(%{field: field, action: action} = m) do
+ GuardedStruct.Errors.Validation.exception(
+ field: field,
+ action: action,
+ message: Map.get(m, :message),
+ hint: Map.get(m, :__hint__),
+ vars: Map.drop(m, [:field, :action, :message, :__hint__]) |> Enum.to_list()
+ )
+ end
+
+ defp to_splode(other) do
+ GuardedStruct.Errors.Unknown.exception(error: other, message: inspect(other))
+ end
+end
diff --git a/lib/guarded_struct/errors/invalid.ex b/lib/guarded_struct/errors/invalid.ex
new file mode 100644
index 0000000..02008e4
--- /dev/null
+++ b/lib/guarded_struct/errors/invalid.ex
@@ -0,0 +1,5 @@
+defmodule GuardedStruct.Errors.Invalid do
+ @moduledoc false
+
+ use Splode.ErrorClass, class: :invalid
+end
diff --git a/lib/guarded_struct/errors/unknown.ex b/lib/guarded_struct/errors/unknown.ex
new file mode 100644
index 0000000..3b2661c
--- /dev/null
+++ b/lib/guarded_struct/errors/unknown.ex
@@ -0,0 +1,9 @@
+defmodule GuardedStruct.Errors.Unknown do
+ @moduledoc false
+
+ use Splode.Error, fields: [:error, :message], class: :invalid
+
+ @impl true
+ def message(%{message: msg}) when is_binary(msg), do: msg
+ def message(%{error: e}), do: inspect(e)
+end
diff --git a/lib/guarded_struct/errors/validation.ex b/lib/guarded_struct/errors/validation.ex
new file mode 100644
index 0000000..d6ad30c
--- /dev/null
+++ b/lib/guarded_struct/errors/validation.ex
@@ -0,0 +1,11 @@
+defmodule GuardedStruct.Errors.Validation do
+ @moduledoc "Single field-level validation error."
+
+ use Splode.Error,
+ fields: [:field, :action, :message, :hint, :child_errors],
+ class: :invalid
+
+ @impl true
+ def message(%{message: m}) when is_binary(m), do: m
+ def message(%{field: f, action: a}), do: "validation failed on #{inspect(f)} (#{inspect(a)})"
+end
diff --git a/lib/helper/extra.ex b/lib/guarded_struct/helper/extra.ex
similarity index 100%
rename from lib/helper/extra.ex
rename to lib/guarded_struct/helper/extra.ex
diff --git a/lib/guarded_struct/info.ex b/lib/guarded_struct/info.ex
new file mode 100644
index 0000000..620e32e
--- /dev/null
+++ b/lib/guarded_struct/info.ex
@@ -0,0 +1,373 @@
+defmodule GuardedStruct.Info do
+ @moduledoc """
+ Runtime introspection of guardedstruct DSL state.
+
+ Built on `Spark.InfoGenerator`, which auto-generates accessors for every
+ section option in the DSL (e.g. `guardedstruct_enforce!/1`,
+ `guardedstruct_json!/1`). On top of those, this module exposes ergonomic
+ helpers so callers don't have to walk `__fields__/0` maps themselves.
+
+ ## Helper categories
+
+ * **Field-level lookups** — `field_kind/2`, `field_default/2`,
+ `field_derives/2`, `field_validator/2`, `field_auto/2`, `enforce?/2`,
+ `virtual?/2`, `dynamic?/2`
+ * **Collections by kind** — `sub_fields/1`, `virtual_fields/1`,
+ `dynamic_fields/1`, `conditional_fields/1`, `conditional_keys/1`,
+ `pattern_keyed?/1`
+ * **Section-option shorthands** — `enforce?/1`, `opaque?/1`,
+ `authorized_fields?/1`, `json?/1`, `error?/1`
+ * **Navigation** — `sub_module/2`, `conditional_children/2`
+
+ ## Example
+
+ defmodule MyApp.User do
+ use GuardedStruct
+ guardedstruct enforce: true do
+ field :name, String.t()
+ virtual_field :password_confirm, String.t()
+ sub_field :address, struct() do
+ field :city, String.t()
+ end
+ end
+ end
+
+ GuardedStruct.Info.fields(MyApp.User) #=> [:name, :password_confirm, :address]
+ GuardedStruct.Info.virtual_fields(MyApp.User) #=> [:password_confirm]
+ GuardedStruct.Info.sub_fields(MyApp.User) #=> [:address]
+ GuardedStruct.Info.field_kind(MyApp.User, :name) #=> :field
+ GuardedStruct.Info.enforce?(MyApp.User, :name) #=> true
+ GuardedStruct.Info.sub_module(MyApp.User, :address) #=> MyApp.User.Address
+ """
+
+ alias GuardedStruct.Transformers.Codegen
+
+ use Spark.InfoGenerator,
+ extension: GuardedStruct.Dsl,
+ sections: [:guardedstruct]
+
+ # ────────────────────────────────────────────────────────────────────────
+ # Existing API
+ # ────────────────────────────────────────────────────────────────────────
+
+ @doc """
+ Return the user-declared field, sub_field, virtual_field, dynamic_field
+ and conditional_field names in declaration order. Works on both the
+ top-level module and any generated sub_field submodule.
+ """
+ def fields(module) do
+ module.__fields__() |> Enum.map(& &1.name) |> Enum.uniq()
+ end
+
+ @doc "Return the list of enforced field names."
+ def enforce_keys(module), do: module.enforce_keys()
+
+ @doc """
+ Return the runtime field metadata — same shape as the generated module's
+ `__fields__/0`.
+ """
+ def fields_meta(module), do: module.__fields__()
+
+ @doc "Return the field metadata for a single name, or `nil` if absent."
+ def field(module, name) when is_atom(name) do
+ Enum.find(module.__fields__(), &(&1.name == name))
+ end
+
+ @doc "True if the field exists on this module (or any sub_field cascade)."
+ def field?(module, name) when is_atom(name) do
+ name in module.keys() or Enum.any?(module.__fields__(), &(&1.name == name))
+ end
+
+ # ────────────────────────────────────────────────────────────────────────
+ # Field-level lookups
+ # ────────────────────────────────────────────────────────────────────────
+
+ @doc """
+ Return the kind of a field: `:field`, `:sub_field`, `:virtual_field`,
+ `:dynamic_field`, `:conditional_field`, or `:pattern_field`. `nil` if
+ the field doesn't exist.
+ """
+ def field_kind(module, name) when is_atom(name) do
+ case field(module, name) do
+ nil -> nil
+ meta -> meta.kind
+ end
+ end
+
+ @doc "Return the field's `default:`, or `nil` if none or field absent."
+ def field_default(module, name) when is_atom(name) do
+ case field(module, name) do
+ nil -> nil
+ meta -> Map.get(meta, :default)
+ end
+ end
+
+ @doc """
+ Return the original derive string for a field (the canonical
+ `derives:` option, falling back to the legacy `derive:`).
+ """
+ def field_derives(module, name) when is_atom(name) do
+ case field(module, name) do
+ nil -> nil
+ meta -> Map.get(meta, :derive)
+ end
+ end
+
+ @doc """
+ Return the `{Mod, fun}` per-field validator MFA, or `nil` if none.
+ """
+ def field_validator(module, name) when is_atom(name) do
+ case field(module, name) do
+ nil -> nil
+ meta -> Map.get(meta, :validator)
+ end
+ end
+
+ @doc "Return the `{Mod, fun}` `auto:` MFA, or `nil` if none."
+ def field_auto(module, name) when is_atom(name) do
+ case field(module, name) do
+ nil -> nil
+ meta -> Map.get(meta, :auto)
+ end
+ end
+
+ @doc "True if the field is enforced (member of `enforce_keys/0`)."
+ def enforce?(module, name) when is_atom(name) do
+ name in module.enforce_keys()
+ end
+
+ @doc "True if `name` is a `virtual_field`."
+ def virtual?(module, name) when is_atom(name), do: field_kind(module, name) == :virtual_field
+
+ @doc "True if `name` is a `dynamic_field`."
+ def dynamic?(module, name) when is_atom(name), do: field_kind(module, name) == :dynamic_field
+
+ # ────────────────────────────────────────────────────────────────────────
+ # Collections by kind
+ # ────────────────────────────────────────────────────────────────────────
+
+ @doc "Names of all `sub_field` entries on this module."
+ def sub_fields(module), do: names_of_kind(module, :sub_field)
+
+ @doc "Names of all `virtual_field` entries on this module."
+ def virtual_fields(module), do: names_of_kind(module, :virtual_field)
+
+ @doc "Names of all `dynamic_field` entries on this module."
+ def dynamic_fields(module), do: names_of_kind(module, :dynamic_field)
+
+ @doc "Names of all `conditional_field` entries on this module."
+ def conditional_fields(module), do: names_of_kind(module, :conditional_field)
+
+ @doc """
+ Names of conditional_field entries, sourced from `__information__/0`'s
+ `:conditional_keys` (matches `conditional_fields/1` for normal modules).
+ """
+ def conditional_keys(module), do: module.__information__().conditional_keys
+
+ @doc """
+ True if this module was generated for a pattern-keyed map (its only
+ `field` was a regex). Pattern-keyed modules return a map from `builder/1`,
+ not a struct.
+ """
+ def pattern_keyed?(module),
+ do: Map.get(module.__information__(), :shape) == :pattern_map
+
+ defp names_of_kind(module, kind) do
+ module.__fields__() |> Enum.filter(&(&1.kind == kind)) |> Enum.map(& &1.name)
+ end
+
+ # ────────────────────────────────────────────────────────────────────────
+ # Section-option shorthands
+ # ────────────────────────────────────────────────────────────────────────
+
+ @doc "True if the section was declared with `enforce: true`."
+ def enforce?(module), do: guardedstruct_enforce!(module) == true
+
+ @doc "True if the section was declared with `opaque: true`."
+ def opaque?(module), do: guardedstruct_opaque!(module) == true
+
+ @doc "True if the section was declared with `authorized_fields: true`."
+ def authorized_fields?(module), do: guardedstruct_authorized_fields!(module) == true
+
+ @doc "True if the section was declared with `json: true`."
+ def json?(module), do: guardedstruct_json!(module) == true
+
+ @doc "True if the section was declared with `error: true`."
+ def error?(module) do
+ case guardedstruct_error(module) do
+ {:ok, value} -> value == true
+ _ -> false
+ end
+ end
+
+ # ────────────────────────────────────────────────────────────────────────
+ # Navigation
+ # ────────────────────────────────────────────────────────────────────────
+
+ @doc """
+ Return the generated submodule for a `sub_field`, or `nil` if the name
+ isn't a sub_field. The submodule path is the parent module concatenated
+ with the camelized field name (or with the section's `module:` override).
+
+ Info.sub_module(MyApp.User, :address)
+ #=> MyApp.User.Address
+ """
+ def sub_module(module, name) when is_atom(name) do
+ case field_kind(module, name) do
+ :sub_field -> Module.concat(module, Codegen.atom_to_module(name))
+ _ -> nil
+ end
+ end
+
+ @doc """
+ Return the children variants of a `conditional_field`, or `nil` if the
+ name isn't a conditional. Each child is a meta map with `:kind`,
+ `:name`, and any associated options.
+ """
+ def conditional_children(module, name) when is_atom(name) do
+ case field(module, name) do
+ %{kind: :conditional_field, children: children} -> children
+ _ -> nil
+ end
+ end
+
+ # ────────────────────────────────────────────────────────────────────────
+ # Everything-in-one-map
+ # ────────────────────────────────────────────────────────────────────────
+
+ @doc """
+ Return the FULL introspection map for a module: every section option,
+ every field's complete metadata, every derived flag, in one structure.
+
+ Works on both the top-level module and any generated sub_field submodule.
+ Section-option keys whose values were not declared are present as `nil`,
+ so the shape is uniform.
+
+ ## Returned map keys
+
+ * `:module` — the module
+ * `:path` — module path from root (empty list for the top-level)
+ * `:key` — the field name corresponding to this module (or `:root`)
+ * `:shape` — `:struct` or `:pattern_map`
+ * `:pattern_keyed?` — convenience boolean
+ * `:patterns` — for pattern-map shapes, the list of regex field names
+ * `:keys` — struct-bound key names (excludes virtuals)
+ * `:enforce_keys` — names of enforced keys
+ * `:conditional_keys` — names of conditional_field entries
+ * `:options` — map of every section option (with `nil` for absent values)
+ * `:fields` — list of per-field meta maps (one per declared entity),
+ each augmented with `:enforce?` (membership in enforce_keys) and,
+ for sub_field entries, `:sub_module` (the generated submodule)
+
+ ## Example
+
+ Info.describe(MyApp.User)
+ #=> %{
+ # module: MyApp.User,
+ # path: [],
+ # key: :root,
+ # shape: :struct,
+ # pattern_keyed?: false,
+ # patterns: [],
+ # keys: [:id, :name, :address, ...],
+ # enforce_keys: [:name, :address],
+ # conditional_keys: [:billing],
+ # options: %{
+ # enforce: true, opaque: false, module: nil, error: false,
+ # authorized_fields: true, main_validator: nil,
+ # validate_derive: nil, sanitize_derive: nil, json: true
+ # },
+ # fields: [
+ # %{name: :id, kind: :field, enforce?: false, ...},
+ # %{name: :address, kind: :sub_field, enforce?: true,
+ # sub_module: MyApp.User.Address, ...},
+ # ...
+ # ]
+ # }
+ """
+ def describe(module) do
+ info = module.__information__()
+ enforce_keys = module.enforce_keys()
+ raw_fields = module.__fields__()
+
+ fields = Enum.map(raw_fields, &enrich_field(&1, enforce_keys, module))
+
+ %{
+ module: module,
+ path: info.path,
+ key: info.key,
+ shape: Map.get(info, :shape, :struct),
+ pattern_keyed?: Map.get(info, :shape) == :pattern_map,
+ patterns: Map.get(info, :patterns, []),
+ keys: info.keys,
+ enforce_keys: enforce_keys,
+ conditional_keys: info.conditional_keys,
+ options: section_options(module, info),
+ fields: fields
+ }
+ end
+
+ defp enrich_field(meta, enforce_keys, parent_module) do
+ # Pattern-field metadata uses `:pattern` (a regex) instead of `:name`,
+ # and is not subject to struct-key enforcement.
+ base =
+ case Map.get(meta, :name) do
+ nil -> Map.put(meta, :enforce?, false)
+ name -> Map.put(meta, :enforce?, name in enforce_keys)
+ end
+
+ case meta.kind do
+ :sub_field ->
+ Map.put(
+ base,
+ :sub_module,
+ Module.concat(parent_module, Codegen.atom_to_module(meta.name))
+ )
+
+ _ ->
+ base
+ end
+ end
+
+ # For the top-level module, every section option is reachable via the
+ # Spark-generated accessor — including ones the user didn't declare
+ # (default applies, or `:error` for non-default options). For sub_field
+ # submodules, only `authorized_fields` and `json` are tracked in the
+ # local `__information__/0.options` map; everything else is `nil`.
+ defp section_options(module, info) do
+ if info.path == [] do
+ %{
+ enforce: opt(module, &guardedstruct_enforce/1),
+ opaque: opt(module, &guardedstruct_opaque/1),
+ module: opt(module, &guardedstruct_module/1),
+ error: opt(module, &guardedstruct_error/1),
+ authorized_fields: opt(module, &guardedstruct_authorized_fields/1),
+ main_validator: opt(module, &guardedstruct_main_validator/1),
+ validate_derive: opt(module, &guardedstruct_validate_derive/1),
+ sanitize_derive: opt(module, &guardedstruct_sanitize_derive/1),
+ json: opt(module, &guardedstruct_json/1)
+ }
+ else
+ sub_opts = Map.get(info, :options, %{})
+
+ %{
+ enforce: nil,
+ opaque: nil,
+ module: nil,
+ error: nil,
+ authorized_fields: Map.get(sub_opts, :authorized_fields),
+ main_validator: nil,
+ validate_derive: nil,
+ sanitize_derive: nil,
+ json: Map.get(sub_opts, :json)
+ }
+ end
+ end
+
+ defp opt(module, fun) do
+ case fun.(module) do
+ {:ok, v} -> v
+ _ -> nil
+ end
+ end
+end
diff --git a/lib/guarded_struct/runtime.ex b/lib/guarded_struct/runtime.ex
new file mode 100644
index 0000000..6f6f73d
--- /dev/null
+++ b/lib/guarded_struct/runtime.ex
@@ -0,0 +1,1284 @@
+defmodule GuardedStruct.Runtime do
+ @moduledoc false
+
+ import GuardedStruct.Messages, only: [translated_message: 1, translated_message: 2]
+
+ alias GuardedStruct.Derive
+ alias GuardedStruct.Derive.Parser
+ alias GuardedStruct.Derive.ValidationDerive
+
+ @doc """
+ Run the validation pipeline and return `{:ok, attrs_map}` (NOT a struct).
+ Used by `GuardedStruct.AshResource` — Ash resources have their own struct,
+ so we don't try to make one of ours.
+ """
+ @spec validate(module(), map() | tuple(), boolean()) ::
+ {:ok, map()} | {:error, any()}
+ def validate(module, attrs, error? \\ false)
+
+ def validate(module, attrs, error?) when is_map(attrs) do
+ # Auto-map cascade: every nested wrap call (sub_field, list-of-sub_field,
+ # external `struct:` ref, conditional) returns a plain map instead of a
+ # struct. Implemented via a process-dict flag so we don't have to thread
+ # the option through every function signature.
+ #
+ # Safety: `Process.put/2` returns the PRIOR value (or `nil`). We save it
+ # and restore on `after` so re-entrant calls (e.g. a validator MFA that
+ # itself calls `__guarded_change__/1` on a related resource) don't
+ # clobber the outer context. Concurrency-safe because process dicts are
+ # process-local — sibling tasks don't see this flag.
+ #
+ # Speed: one `Process.put` + one `Process.put`/`Process.delete` per
+ # top-level call. The wrap closure short-circuits on `build_struct? =
+ # false` so non-Ash callers pay zero dict lookups.
+ prior = Process.put(:guarded_as_map?, true)
+
+ try do
+ do_pipeline(module, attrs, attrs, :add, error?, [], _build_struct? = false)
+ after
+ case prior do
+ nil -> Process.delete(:guarded_as_map?)
+ v -> Process.put(:guarded_as_map?, v)
+ end
+ end
+ end
+
+ def validate(_module, _attrs, _error?) do
+ {:error, %{message: translated_message(:builder), action: :bad_parameters}}
+ end
+
+ @spec build(module(), map() | struct() | tuple(), boolean()) ::
+ {:ok, struct()} | {:error, any()}
+ def build(module, attrs, error?)
+
+ def build(module, attrs, error?) when is_struct(attrs) do
+ build(module, Map.from_struct(attrs), error?)
+ end
+
+ def build(module, attrs, error?) when is_map(attrs) do
+ with_telemetry(module, fn ->
+ do_build(module, attrs, attrs, :add, error?)
+ end)
+ end
+
+ def build(module, {key, attrs}, error?) when is_atom(key) or is_list(key) do
+ with_telemetry(module, fn ->
+ do_build_with_key(module, key, attrs, :add, error?)
+ end)
+ end
+
+ def build(module, {key, attrs, type}, error?)
+ when (is_atom(key) or is_list(key)) and type in [:add, :edit] do
+ with_telemetry(module, fn ->
+ do_build_with_key(module, key, attrs, type, error?)
+ end)
+ end
+
+ def build(module, {:__nested__, local_attrs, full_attrs, path, type}, error?) do
+ do_build(module, local_attrs, full_attrs, type, error?, path)
+ end
+
+ def build(_module, _attrs, _error?) do
+ {:error, %{message: translated_message(:builder), action: :bad_parameters}}
+ end
+
+ defp with_telemetry(module, fun) do
+ start = System.monotonic_time()
+ metadata = %{module: module}
+
+ # Push the current module onto the process dictionary so per-module
+ # `derive_extensions` lookups in Extension.dispatch_*/2,3 can find it.
+ # Nested sub_field builds inherit; external `struct: Other` calls push
+ # their own and restore the previous on return.
+ previous_module = Process.get(:guarded_struct_current_module)
+ Process.put(:guarded_struct_current_module, module)
+
+ :telemetry.execute(
+ [:guarded_struct, :builder, :start],
+ %{system_time: System.system_time()},
+ metadata
+ )
+
+ try do
+ result = fun.()
+ duration = System.monotonic_time() - start
+
+ :telemetry.execute(
+ [:guarded_struct, :builder, :stop],
+ %{duration: duration},
+ Map.merge(metadata, telemetry_result(result))
+ )
+
+ result
+ rescue
+ e ->
+ duration = System.monotonic_time() - start
+
+ :telemetry.execute(
+ [:guarded_struct, :builder, :exception],
+ %{duration: duration},
+ Map.merge(metadata, %{kind: :error, reason: e, stacktrace: __STACKTRACE__})
+ )
+
+ reraise(e, __STACKTRACE__)
+ after
+ case previous_module do
+ nil -> Process.delete(:guarded_struct_current_module)
+ prev -> Process.put(:guarded_struct_current_module, prev)
+ end
+ end
+ end
+
+ defp telemetry_result({:ok, _}), do: %{result: :ok}
+
+ defp telemetry_result({:error, errs}) when is_list(errs),
+ do: %{result: :error, error_count: length(errs)}
+
+ defp telemetry_result({:error, _}), do: %{result: :error, error_count: 1}
+ defp telemetry_result(_), do: %{}
+
+ @spec build_pattern_map(module(), map(), boolean()) ::
+ {:ok, map()} | {:error, list()}
+ def build_pattern_map(module, attrs, error?)
+
+ def build_pattern_map(_module, attrs, _error?) when not is_map(attrs) do
+ {:error, %{message: translated_message(:builder), action: :bad_parameters}}
+ end
+
+ def build_pattern_map(module, attrs, error?) do
+ fields_meta = module.__fields__()
+
+ pattern_fields =
+ Enum.filter(fields_meta, &(Map.get(&1, :kind) == :pattern_field))
+
+ with {:ok, _} <- run_pattern_whole_map_derive(attrs, pattern_fields),
+ {:ok, validated} <- process_pattern_entries(attrs, pattern_fields, module) do
+ {:ok, validated}
+ else
+ {:error, errs} -> handle_error({:error, errs}, module, error?)
+ end
+ end
+
+ defp run_pattern_whole_map_derive(attrs, pattern_fields) do
+ case Enum.find(pattern_fields, &Map.get(&1, :__derive_ops__)) do
+ nil ->
+ {:ok, attrs}
+
+ f ->
+ ops = f.__derive_ops__
+ input = %{field: :__map__, derive_ops: ops}
+
+ case Derive.derive({:ok, %{__map__: attrs}, [input]}) do
+ {:ok, %{__map__: validated}} -> {:ok, validated}
+ {:error, errs} -> {:error, errs}
+ end
+ end
+ end
+
+ defp process_pattern_entries(attrs, pattern_fields, _module) do
+ {results, errors} =
+ Enum.reduce(attrs, {%{}, []}, fn {key, value}, {ok, errs} ->
+ key_str = if is_atom(key), do: Atom.to_string(key), else: to_string(key)
+
+ case Enum.find(pattern_fields, &Regex.match?(&1.pattern, key_str)) do
+ nil ->
+ {ok,
+ [
+ %{
+ field: :__map__,
+ key: key_str,
+ action: :key_pattern,
+ message: "key #{inspect(key_str)} does not match any declared pattern"
+ }
+ | errs
+ ]}
+
+ %{} = pf ->
+ case process_pattern_value(pf, value, key_str) do
+ {:ok, validated_value} -> {Map.put(ok, key_str, validated_value), errs}
+ {:error, value_errs} -> {ok, value_errs ++ errs}
+ end
+ end
+ end)
+
+ case errors do
+ [] -> {:ok, results}
+ _ -> {:error, Enum.reverse(errors)}
+ end
+ end
+
+ defp process_pattern_value(%{struct: target_mod} = _pf, value, key_str)
+ when is_atom(target_mod) and not is_nil(target_mod) do
+ case target_mod.builder(value) do
+ {:ok, built} -> {:ok, built}
+ {:error, errs} when is_list(errs) -> {:error, prefix_key(errs, key_str)}
+ {:error, err} -> {:error, prefix_key([err], key_str)}
+ end
+ end
+
+ defp process_pattern_value(%{validator: {mod, fun}}, value, key_str)
+ when is_atom(mod) and is_atom(fun) do
+ case apply(mod, fun, [key_str, value]) do
+ {:ok, _key, validated} -> {:ok, validated}
+ {:ok, validated} -> {:ok, validated}
+ {:error, _key, message} -> {:error, [%{key: key_str, action: :validator, message: message}]}
+ {:error, message} -> {:error, [%{key: key_str, action: :validator, message: message}]}
+ _ -> {:ok, value}
+ end
+ end
+
+ defp process_pattern_value(_pf, value, _key_str), do: {:ok, value}
+
+ defp prefix_key(errs, key_str) do
+ Enum.map(errs, fn
+ %{} = e -> Map.put(e, :key, key_str)
+ other -> %{key: key_str, error: other}
+ end)
+ end
+
+ defp do_build_with_key(module, :root, attrs, type, error?),
+ do: do_build(module, attrs, attrs, type, error?)
+
+ defp do_build_with_key(module, [:root], attrs, type, error?),
+ do: do_build(module, attrs, attrs, type, error?)
+
+ defp do_build_with_key(module, key, attrs, type, error?) when is_list(key) do
+ case get_in(attrs, key) do
+ sub when is_map(sub) -> do_build(module, sub, attrs, type, error?)
+ _ -> {:error, %{message: translated_message(:builder), action: :bad_parameters}}
+ end
+ end
+
+ defp do_build_with_key(module, key, attrs, type, error?) when is_atom(key) do
+ case Map.get(attrs, key) do
+ sub when is_map(sub) -> do_build(module, sub, attrs, type, error?)
+ _ -> {:error, %{message: translated_message(:builder), action: :bad_parameters}}
+ end
+ end
+
+ defp do_build(module, attrs, full_attrs, type, error?, path \\ [])
+
+ defp do_build(module, attrs, full_attrs, type, error?, path) when not is_map(attrs) do
+ do_build(module, %{}, full_attrs, type, error?, path)
+ end
+
+ defp do_build(module, attrs, full_attrs, type, error?, path) when is_map(attrs) do
+ do_pipeline(module, attrs, full_attrs, type, error?, path, _build_struct? = true)
+ end
+
+ defp do_pipeline(module, attrs, full_attrs, type, error?, path, build_struct?)
+ when is_map(attrs) do
+ {info, fields_meta} = read_metadata(module)
+ section_opts = section_options_from(info)
+
+ keys = info.keys
+ enforce_keys = info.enforce_keys
+
+ # Names of `dynamic_field` entries — their inner map values are left
+ # UNTOUCHED during atom-conversion. See the "Atom-attack safety"
+ # section of the `GuardedStruct` module `@moduledoc` for the rationale.
+ dynamic_field_names =
+ fields_meta
+ |> Enum.filter(&(&1[:kind] == :dynamic_field))
+ |> Enum.map(& &1.name)
+
+ full_attrs_atomized = Parser.convert_to_atom_map(full_attrs, dynamic_field_names)
+
+ with {:ok, normalized} <- normalize_keys(attrs, dynamic_field_names),
+ {:ok, attrs_after_authorized} <-
+ authorized_fields(normalized, keys, section_opts.authorized_fields),
+ :ok <- check_enforce_keys(attrs_after_authorized, enforce_keys),
+ attrs1 = apply_auto(attrs_after_authorized, fields_meta, type),
+ {:ok, _} <- check_domain(full_attrs_atomized, attrs1, fields_meta),
+ {:ok, _} <- check_on(attrs1, fields_meta, full_attrs_atomized),
+ attrs2 = apply_from(attrs1, fields_meta, full_attrs_atomized) do
+ {:ok, sub_field_data, sub_errors} =
+ build_sub_fields(attrs2, fields_meta, module, full_attrs_atomized, path)
+
+ already_errored = Enum.map(sub_errors, & &1.field)
+ attrs_for_validation = Map.drop(attrs2, already_errored)
+
+ {validator_attrs, validator_errors} =
+ run_per_field_validators_collect(attrs_for_validation, fields_meta, module)
+
+ all_errors = sub_errors ++ validator_errors
+
+ virtual_names =
+ fields_meta
+ |> Enum.filter(&(&1[:kind] == :virtual_field))
+ |> Enum.map(& &1.name)
+
+ # The Ash-extension entry point (`validate/3`) sets `:guarded_as_map?`
+ # on the process dict, forcing every nested build to return a map
+ # rather than a struct — regardless of the `build_struct?` arg the
+ # submodule passes in via its own `builder/1`. Short-circuit:
+ # `build_struct? = false` is the original non-struct path (`validate/3`
+ # itself), so we don't need to consult the dict in that case.
+ wrap_as_struct? =
+ build_struct? and not Process.get(:guarded_as_map?, false)
+
+ wrap = fn merged ->
+ merged = Map.drop(merged, virtual_names)
+ if wrap_as_struct?, do: struct(module, merged), else: merged
+ end
+
+ {derive_errors, struct_value} =
+ case run_main_validator(validator_attrs, module) do
+ {:ok, after_main} ->
+ merged = Map.merge(after_main, sub_field_data)
+
+ # Pass 1 — derive on the raw merged map for VIRTUAL fields only.
+ # Virtuals are dropped by `wrap.()`, so this is the one chance
+ # to validate them. Defaults aren't relevant here (virtuals
+ # don't get default-substituted through struct/2).
+ virtual_meta = Enum.filter(fields_meta, &(&1[:kind] == :virtual_field))
+
+ virtual_errs =
+ case run_derives(merged, virtual_meta) do
+ {:ok, _} -> []
+ {:error, errs} -> errs
+ end
+
+ # Pass 2 — wrap into struct (so struct/2 applies field defaults),
+ # then derive on the wrapped struct for NON-virtual fields.
+ sv = wrap.(merged)
+ non_virtual_meta = Enum.reject(fields_meta, &(&1[:kind] == :virtual_field))
+
+ case run_derives(sv, non_virtual_meta) do
+ {:ok, derived} -> {virtual_errs, derived}
+ {:error, errs} -> {virtual_errs ++ errs, sv}
+ end
+
+ {:error, errs} when is_list(errs) ->
+ {errs, wrap.(Map.merge(validator_attrs, sub_field_data))}
+
+ {:error, err} ->
+ {[err], wrap.(Map.merge(validator_attrs, sub_field_data))}
+ end
+
+ final_errors = derive_errors ++ all_errors
+
+ cond do
+ final_errors != [] ->
+ handle_error({:error, final_errors}, module, error?)
+
+ true ->
+ {:ok, struct_value}
+ end
+ else
+ {:error, %{action: :authorized_fields}} = err ->
+ handle_error(err, module, error?)
+
+ {:error, %{action: :required_fields}} = err ->
+ handle_error(err, module, error?)
+
+ {:error, errs} when is_list(errs) ->
+ handle_error({:error, errs}, module, error?)
+
+ {:error, _} = err ->
+ handle_error(err, module, error?)
+ end
+ end
+
+ defp read_metadata(module) do
+ cond do
+ function_exported?(module, :__information__, 0) ->
+ {module.__information__(), module.__fields__()}
+
+ function_exported?(module, :__guarded_information__, 0) ->
+ {module.__guarded_information__(), module.__guarded_fields__()}
+
+ true ->
+ raise ArgumentError,
+ "module #{inspect(module)} doesn't appear to be a GuardedStruct or " <>
+ "an Ash resource using GuardedStruct.AshResource"
+ end
+ end
+
+ defp section_options_from(info) do
+ Map.get(info, :options, %{authorized_fields: false})
+ end
+
+ defp normalize_keys(attrs, dynamic_field_names) when is_map(attrs) do
+ case Map.keys(attrs) |> List.first() do
+ nil -> {:ok, attrs}
+ _ -> {:ok, Parser.convert_to_atom_map(attrs, dynamic_field_names)}
+ end
+ end
+
+ defp authorized_fields(attrs, _keys, false), do: {:ok, attrs}
+ defp authorized_fields(attrs, _keys, nil), do: {:ok, attrs}
+
+ defp authorized_fields(attrs, keys, true) do
+ extras = Enum.filter(Map.keys(attrs), &(&1 not in keys))
+
+ if extras == [] do
+ {:ok, attrs}
+ else
+ {:error,
+ %{
+ message: translated_message(:authorized_fields),
+ fields: extras,
+ action: :authorized_fields
+ }}
+ end
+ end
+
+ defp check_enforce_keys(_attrs, []), do: :ok
+
+ defp check_enforce_keys(attrs, enforce_keys) do
+ missing = Enum.reject(enforce_keys, &Map.has_key?(attrs, &1))
+
+ if missing == [] do
+ :ok
+ else
+ {:error,
+ %{
+ message: translated_message(:required_fields),
+ fields: missing,
+ action: :required_fields
+ }}
+ end
+ end
+
+ defp apply_auto(attrs, fields_meta, type) do
+ Enum.reduce(fields_meta, attrs, fn meta, acc ->
+ case meta.auto do
+ nil ->
+ acc
+
+ {mod, fun} ->
+ if type == :edit and not is_nil(Map.get(acc, meta.name)) do
+ acc
+ else
+ Map.put(acc, meta.name, apply(mod, fun, []))
+ end
+
+ {mod, fun, arg} when is_list(arg) ->
+ if type == :edit and not is_nil(Map.get(acc, meta.name)) do
+ acc
+ else
+ Map.put(acc, meta.name, apply(mod, fun, arg))
+ end
+
+ {mod, fun, arg} ->
+ if type == :edit and not is_nil(Map.get(acc, meta.name)) do
+ acc
+ else
+ Map.put(acc, meta.name, apply(mod, fun, [arg]))
+ end
+ end
+ end)
+ end
+
+ defp check_on(attrs, fields_meta, full_attrs) do
+ errors =
+ fields_meta
+ |> Enum.reverse()
+ |> Enum.flat_map(fn
+ %{on: nil} ->
+ []
+
+ %{on: pattern, name: name} = f ->
+ [check_on_pattern(name, pattern, attrs, full_attrs, Map.get(f, :__on_path__))]
+
+ _ ->
+ []
+ end)
+ |> Enum.reject(&is_nil/1)
+
+ if errors == [], do: {:ok, attrs}, else: {:error, errors}
+ end
+
+ defp check_on_pattern(field_name, pattern, attrs, full_attrs, pre_parsed) do
+ [head | rest] = path = pre_parsed || Parser.parse_core_keys_pattern(pattern)
+ field_value = Map.get(full_attrs, field_name) || Map.get(attrs, field_name)
+
+ if is_nil(field_value) do
+ nil
+ else
+ target =
+ if head == :root,
+ do: get_in(full_attrs, rest),
+ else: get_in(attrs, path)
+
+ if is_nil(target) do
+ %{
+ message: translated_message(:check_dependent_keys, {field_name, path}),
+ field: field_name,
+ action: :dependent_keys
+ }
+ end
+ end
+ end
+
+ defp apply_from(attrs, fields_meta, full_attrs) do
+ Enum.reduce(fields_meta, attrs, fn
+ %{from: nil}, acc ->
+ acc
+
+ %{from: pattern, name: name} = f, acc ->
+ [head | rest] =
+ path = Map.get(f, :__from_path__) || Parser.parse_core_keys_pattern(pattern)
+
+ source =
+ if head == :root,
+ do: get_in(full_attrs, rest),
+ else: get_in(acc, path)
+
+ case source do
+ nil -> acc
+ value -> Map.put(acc, name, value)
+ end
+
+ _, acc ->
+ acc
+ end)
+ end
+
+ defp check_domain(full_attrs, attrs, fields_meta) do
+ errors =
+ fields_meta
+ |> Enum.flat_map(fn
+ %{domain: nil} ->
+ []
+
+ %{name: name} = f ->
+ rules = Map.get(f, :__domain_ops__) || []
+ run_domain_rules(rules, name, full_attrs, attrs)
+ end)
+ |> List.flatten()
+
+ if errors == [], do: {:ok, attrs}, else: {:error, errors}
+ end
+
+ defp run_domain_rules([], _key, _full_attrs, _attrs), do: []
+
+ defp run_domain_rules(rules, key, full_attrs, attrs) do
+ case Map.get(full_attrs, key) || Map.get(attrs, key) do
+ nil ->
+ []
+
+ _ ->
+ Enum.map(rules, &run_domain_rule(&1, key, full_attrs)) |> Enum.reject(&is_nil/1)
+ end
+ end
+
+ defp run_domain_rule(
+ %{field_path: field, validator: validator, required?: required?},
+ key,
+ full_attrs
+ ) do
+ domain_field = get_domain_field(field, full_attrs)
+
+ cond do
+ not is_nil(domain_field) ->
+ case ValidationDerive.validate(validator, domain_field, key) do
+ data when is_tuple(data) and elem(data, 0) == :error ->
+ %{
+ message: translated_message(:domain_field_status, key),
+ field_path: field,
+ field: key,
+ action: :domain_parameters
+ }
+
+ _ ->
+ nil
+ end
+
+ not required? ->
+ nil
+
+ true ->
+ %{
+ message: translated_message(:force_domain_field_status, key),
+ field_path: field,
+ field: key,
+ action: :domain_parameters
+ }
+ end
+ end
+
+ defp get_domain_field(field, attrs) do
+ field
+ |> String.trim()
+ |> String.split(".", trim: true)
+ |> Enum.map(&String.to_atom/1)
+ |> then(&get_in(attrs, &1))
+ end
+
+ defp build_sub_fields(attrs, fields_meta, parent_module, full_attrs, parent_path) do
+ by_name =
+ Enum.reduce(fields_meta, %{}, fn f, acc ->
+ Map.update(acc, f.name, [f], &(&1 ++ [f]))
+ end)
+
+ embedded =
+ attrs
+ |> Map.keys()
+ |> Enum.flat_map(fn k ->
+ case Map.get(by_name, k) do
+ nil ->
+ []
+
+ [first | _] ->
+ embedded? =
+ first.kind in [:sub_field, :conditional_field] or
+ not is_nil(Map.get(first, :struct)) or
+ (not is_nil(Map.get(first, :structs)) and Map.get(first, :structs) != false)
+
+ if embedded?, do: [first], else: []
+ end
+ end)
+
+ Enum.reduce(embedded, {:ok, %{}, []}, fn meta, {:ok, ok_acc, err_acc} ->
+ case Map.get(attrs, meta.name) do
+ nil ->
+ {:ok, ok_acc, err_acc}
+
+ value ->
+ case run_pre_validator(meta, value, parent_module) do
+ {:ok, validated} ->
+ if meta.kind == :conditional_field do
+ dispatch(
+ meta,
+ validated,
+ parent_module,
+ ok_acc,
+ err_acc,
+ full_attrs,
+ parent_path
+ )
+ else
+ case pre_derive(meta, validated) do
+ {:ok, sanitized} ->
+ dispatch(
+ meta,
+ sanitized,
+ parent_module,
+ ok_acc,
+ err_acc,
+ full_attrs,
+ parent_path
+ )
+
+ {:error, errs} ->
+ {:ok, ok_acc, err_acc ++ [%{field: meta.name, errors: errs}]}
+ end
+ end
+
+ {:error, err} ->
+ {:ok, ok_acc, err_acc ++ [%{field: meta.name, errors: err}]}
+ end
+ end
+ end)
+ end
+
+ defp run_pre_validator(%{validator: {mod, fun}, name: name}, value, _parent)
+ when is_atom(mod) and is_atom(fun) do
+ apply_validator(mod, fun, name, value)
+ end
+
+ defp run_pre_validator(%{struct: m} = meta, value, _parent)
+ when is_atom(m) and not is_nil(m) do
+ apply_caller_validator(m, meta.name, value)
+ end
+
+ defp run_pre_validator(%{structs: m} = meta, value, _parent)
+ when is_atom(m) and m not in [nil, true, false] do
+ apply_caller_validator(m, meta.name, value)
+ end
+
+ defp run_pre_validator(%{kind: :sub_field, name: name}, value, parent) do
+ apply_caller_validator(parent, name, value)
+ end
+
+ defp run_pre_validator(_meta, value, _parent), do: {:ok, value}
+
+ defp apply_validator(mod, fun, field, value) do
+ case apply(mod, fun, [field, value]) do
+ {:ok, _key, new_value} ->
+ {:ok, new_value}
+
+ {:error, key, message} ->
+ {:error, %{field: key, message: message, action: :validator}}
+ end
+ end
+
+ defp apply_caller_validator(mod, field, value) do
+ if Code.ensure_loaded?(mod) and function_exported?(mod, :validator, 2) do
+ case apply(mod, :validator, [field, value]) do
+ {:ok, _key, new_value} -> {:ok, new_value}
+ {:error, key, message} -> {:error, %{field: key, message: message, action: :validator}}
+ _ -> {:ok, value}
+ end
+ else
+ {:ok, value}
+ end
+ end
+
+ defp pre_derive(%{derive: nil}, value), do: {:ok, value}
+
+ defp pre_derive(%{name: name} = meta, value) do
+ ops = Map.get(meta, :__derive_ops__)
+ str = Map.get(meta, :derive)
+
+ cond do
+ is_nil(ops) and is_nil(str) ->
+ {:ok, value}
+
+ true ->
+ input = %{field: name, derive: str, derive_ops: ops}
+
+ case Derive.derive({:ok, %{name => value}, [input]}) do
+ {:ok, %{^name => sanitized}} -> {:ok, sanitized}
+ {:error, errs} -> {:error, errs}
+ end
+ end
+ end
+
+ defp dispatch(meta, value, parent_module, ok_acc, err_acc, full_attrs, parent_path) do
+ cond do
+ meta.kind == :conditional_field ->
+ dispatch_conditional(meta, value, parent_module, ok_acc, err_acc, full_attrs, parent_path)
+
+ is_atom(meta.structs) and meta.structs not in [nil, true, false] and is_list(value) ->
+ build_list(meta.structs, meta.name, value, ok_acc, err_acc, full_attrs, parent_path)
+
+ is_atom(meta.struct) and not is_nil(meta.struct) and is_map(value) ->
+ build_single(meta.struct, meta.name, value, ok_acc, err_acc, full_attrs, parent_path)
+
+ meta.kind == :sub_field and Map.get(meta, :list?) == true and is_list(value) ->
+ submodule = Module.concat(parent_module, atom_to_module(meta.name))
+ build_list(submodule, meta.name, value, ok_acc, err_acc, full_attrs, parent_path)
+
+ meta.kind == :sub_field and is_map(value) ->
+ submodule = Module.concat(parent_module, atom_to_module(meta.name))
+ build_single(submodule, meta.name, value, ok_acc, err_acc, full_attrs, parent_path)
+
+ true ->
+ {:ok, ok_acc, err_acc}
+ end
+ end
+
+ defp dispatch_conditional(meta, value, parent_module, ok_acc, err_acc, full_attrs, parent_path) do
+ children = meta.children
+ new_path = parent_path ++ [meta.name]
+
+ case run_child_derive(meta, value) do
+ {:ok, value} ->
+ do_dispatch_conditional(
+ meta,
+ value,
+ parent_module,
+ ok_acc,
+ err_acc,
+ full_attrs,
+ new_path,
+ children
+ )
+
+ {:error, derive_errs} ->
+ {:ok, ok_acc,
+ err_acc ++ [%{field: meta.name, errors: derive_errs, action: :conditionals}]}
+ end
+ end
+
+ defp do_dispatch_conditional(
+ meta,
+ value,
+ parent_module,
+ ok_acc,
+ err_acc,
+ full_attrs,
+ new_path,
+ children
+ ) do
+ if meta.list? == true and is_list(value) do
+ results =
+ Enum.map(value, fn item ->
+ try_conditional_children(item, children, meta, parent_module, full_attrs, new_path)
+ end)
+
+ collect_list_conditional_results(results, meta, ok_acc, err_acc)
+ else
+ case try_conditional_children(value, children, meta, parent_module, full_attrs, new_path) do
+ {:ok, built} ->
+ {:ok, Map.put(ok_acc, meta.name, built), err_acc}
+
+ {:error, child_errors} ->
+ final_errors =
+ if Map.get(meta, :priority) == true and child_errors != [] do
+ [List.first(child_errors)]
+ else
+ child_errors
+ end
+
+ {:ok, ok_acc,
+ err_acc ++
+ [%{field: meta.name, errors: final_errors, action: :conditionals}]}
+ end
+ end
+ end
+
+ defp try_conditional_children(value, children, parent_meta, parent_module, full_attrs, path) do
+ Enum.reduce_while(children, {:error, []}, fn child, {:error, errs} ->
+ case try_conditional_child(child, value, parent_meta, parent_module, full_attrs, path) do
+ {:ok, _} = ok ->
+ {:halt, ok}
+
+ {:error, e} ->
+ hinted = hint_error(e, child)
+ new_errs = if is_list(hinted), do: errs ++ hinted, else: errs ++ [hinted]
+ {:cont, {:error, new_errs}}
+ end
+ end)
+ end
+
+ defp hint_error(err, %{hint: nil}), do: err
+
+ defp hint_error(errs, %{hint: h}) when is_list(errs) and is_binary(h),
+ do: Enum.map(errs, &Map.put(&1, :__hint__, h))
+
+ defp hint_error(err, %{hint: h}) when is_map(err) and is_binary(h),
+ do: Map.put(err, :__hint__, h)
+
+ defp hint_error(err, _), do: err
+
+ defp try_conditional_child(%{kind: :field} = child, value, _parent, _module, _full, _path) do
+ case run_child_validator(child, value) do
+ {:ok, validated} ->
+ cond do
+ is_atom(Map.get(child, :struct)) and not is_nil(Map.get(child, :struct)) ->
+ if is_map(validated) do
+ child.struct.builder(validated)
+ else
+ {:error,
+ %{
+ message: translated_message(:builder),
+ action: :bad_parameters
+ }}
+ end
+
+ is_atom(Map.get(child, :structs)) and
+ Map.get(child, :structs) not in [nil, true, false] ->
+ if is_list(validated) do
+ mod = child.structs
+
+ built =
+ Enum.flat_map(validated, fn
+ item when is_list(item) ->
+ Enum.map(item, fn v -> mod.builder(v) end)
+
+ item ->
+ [mod.builder(item)]
+ end)
+
+ case Enum.find(built, &(elem(&1, 0) == :error)) do
+ nil -> {:ok, Enum.map(built, &elem(&1, 1))}
+ {:error, errs} -> {:error, errs}
+ end
+ else
+ {:error,
+ %{
+ message: translated_message(:list_builder_type),
+ field: child.name,
+ action: :type
+ }}
+ end
+
+ true ->
+ case run_child_derive(child, validated) do
+ {:ok, sanitized} -> {:ok, sanitized}
+ {:error, errs} -> {:error, errs}
+ end
+ end
+
+ {:error, err} ->
+ {:error, err}
+ end
+ end
+
+ defp try_conditional_child(
+ %{kind: :sub_field, name: name} = child,
+ value,
+ parent,
+ parent_module,
+ full_attrs,
+ path
+ ) do
+ with {:ok, validated} <- run_child_validator(child, value),
+ {:ok, sanitized} <- run_child_derive(child, validated) do
+ sub_index = Map.get(child, :sub_field_index)
+
+ submodule_name =
+ if sub_index do
+ "#{parent.name}#{sub_index}" |> String.to_atom() |> atom_to_module()
+ else
+ atom_to_module(name)
+ end
+
+ submodule = Module.concat(parent_module, submodule_name)
+ nested_input_for = fn v -> {:__nested__, v, full_attrs, path, :add} end
+
+ cond do
+ Map.get(child, :list?) == true and is_list(sanitized) ->
+ built =
+ Enum.flat_map(sanitized, fn
+ item when is_map(item) ->
+ [submodule.builder(nested_input_for.(item))]
+
+ item when is_list(item) ->
+ Enum.map(item, fn v -> submodule.builder(nested_input_for.(v)) end)
+
+ _ ->
+ [
+ {:error,
+ %{
+ message: translated_message(:builder),
+ action: :bad_parameters
+ }}
+ ]
+ end)
+
+ case Enum.find(built, &(elem(&1, 0) == :error)) do
+ nil -> {:ok, Enum.map(built, &elem(&1, 1))}
+ {:error, errs} -> {:error, errs}
+ end
+
+ Map.get(child, :list?) == true ->
+ {:error,
+ %{
+ message: translated_message(:list_builder_type),
+ field: name,
+ action: :type
+ }}
+
+ is_map(sanitized) ->
+ submodule.builder(nested_input_for.(sanitized))
+
+ true ->
+ {:error, %{message: translated_message(:builder), action: :bad_parameters}}
+ end
+ end
+ end
+
+ defp try_conditional_child(
+ %{kind: :conditional_field} = child,
+ value,
+ _parent,
+ parent_module,
+ full_attrs,
+ path
+ ) do
+ children = child.children
+ is_list_cond = Map.get(child, :structs) == true or Map.get(child, :list?) == true
+
+ cond do
+ is_list_cond and is_list(value) ->
+ results =
+ Enum.map(value, fn item ->
+ try_conditional_children(item, children, child, parent_module, full_attrs, path)
+ end)
+
+ case Enum.split_with(results, &match?({:ok, _}, &1)) do
+ {oks, []} ->
+ {:ok, Enum.map(oks, fn {:ok, v} -> v end)}
+
+ {_oks, errs} ->
+ collected =
+ errs
+ |> Enum.flat_map(fn {:error, e} -> e end)
+ |> Enum.uniq()
+
+ {:error, %{field: child.name, errors: collected, action: :conditionals}}
+ end
+
+ is_list_cond ->
+ {:error,
+ %{
+ field: child.name,
+ message: translated_message(:list_builder_type),
+ action: :type
+ }}
+
+ true ->
+ case try_conditional_children(value, children, child, parent_module, full_attrs, path) do
+ {:ok, _} = ok -> ok
+ {:error, errs} -> {:error, %{field: child.name, errors: errs, action: :conditionals}}
+ end
+ end
+ end
+
+ defp run_child_derive(%{derive: nil}, value), do: {:ok, value}
+
+ defp run_child_derive(%{name: name} = child, value) do
+ ops = Map.get(child, :__derive_ops__)
+ str = Map.get(child, :derive)
+
+ cond do
+ is_nil(ops) and is_nil(str) ->
+ {:ok, value}
+
+ true ->
+ input = %{field: name, derive: str, derive_ops: ops}
+
+ case Derive.derive({:ok, %{name => value}, [input]}) do
+ {:ok, %{^name => sanitized}} -> {:ok, sanitized}
+ {:error, errs} -> {:error, errs}
+ end
+ end
+ end
+
+ defp run_child_derive(_child, value), do: {:ok, value}
+
+ defp run_child_validator(%{validator: {mod, fun}, name: name}, value)
+ when is_atom(mod) and is_atom(fun) do
+ case apply(mod, fun, [name, value]) do
+ {:ok, _key, new_value} -> {:ok, new_value}
+ {:error, key, message} -> {:error, %{field: key, message: message, action: :validator}}
+ end
+ end
+
+ defp run_child_validator(_child, value), do: {:ok, value}
+
+ defp collect_list_conditional_results(results, meta, ok_acc, err_acc) do
+ {oks, errs} =
+ Enum.reduce(results, {[], []}, fn
+ {:ok, val}, {oks, errs} -> {oks ++ [val], errs}
+ {:error, errors}, {oks, errs} -> {oks, errs ++ errors}
+ end)
+
+ deduped = Enum.uniq(errs)
+
+ final_errs =
+ if Map.get(meta, :priority) == true and deduped != [] do
+ [List.first(deduped)]
+ else
+ deduped
+ end
+
+ cond do
+ final_errs != [] ->
+ {:ok, ok_acc, err_acc ++ [%{field: meta.name, errors: final_errs, action: :conditionals}]}
+
+ true ->
+ {:ok, Map.put(ok_acc, meta.name, oks), err_acc}
+ end
+ end
+
+ defp build_single(module, field_name, value, ok_acc, err_acc, full_attrs, parent_path) do
+ new_path = parent_path ++ [field_name]
+ nested_input = {:__nested__, value, full_attrs, new_path, :add}
+
+ case with_module_context(module, fn -> module.builder(nested_input) end) do
+ {:ok, built} ->
+ {:ok, Map.put(ok_acc, field_name, built), err_acc}
+
+ {:error, errs} ->
+ {:ok, ok_acc, err_acc ++ [%{field: field_name, errors: errs}]}
+ end
+ end
+
+ defp build_list(module, field_name, list, ok_acc, err_acc, full_attrs, parent_path) do
+ new_path = parent_path ++ [field_name]
+
+ built =
+ Enum.map(list, fn item ->
+ with_module_context(module, fn ->
+ module.builder({:__nested__, item, full_attrs, new_path, :add})
+ end)
+ end)
+
+ case Enum.find(built, &(elem(&1, 0) == :error)) do
+ nil ->
+ {:ok, Map.put(ok_acc, field_name, Enum.map(built, &elem(&1, 1))), err_acc}
+
+ {:error, errs} ->
+ {:ok, ok_acc, err_acc ++ [%{field: field_name, errors: errs}]}
+ end
+ end
+
+ # Switches the process-dict current module for the duration of `fun.()`
+ # ONLY when `module` is a separate user module (one declared with
+ # `use GuardedStruct`). Auto-generated sub_field submodules don't have
+ # `__guarded_derive_extensions_opt__/0` defined, so we leave pdict alone
+ # for them — they inherit the root user module's per-module opt.
+ defp with_module_context(module, fun) do
+ if function_exported?(module, :__guarded_derive_extensions_opt__, 0) do
+ previous = Process.get(:guarded_struct_current_module)
+ Process.put(:guarded_struct_current_module, module)
+
+ try do
+ fun.()
+ after
+ case previous do
+ nil -> Process.delete(:guarded_struct_current_module)
+ prev -> Process.put(:guarded_struct_current_module, prev)
+ end
+ end
+ else
+ fun.()
+ end
+ end
+
+ defp run_per_field_validators_collect(attrs, fields_meta, module) do
+ Enum.reduce(attrs, {%{}, []}, fn {key, value}, {ok_acc, err_acc} ->
+ meta = Enum.find(fields_meta, fn f -> f.name == key end)
+
+ cond do
+ embedded?(meta) ->
+ {Map.put(ok_acc, key, value), err_acc}
+
+ true ->
+ case run_field_validator(meta, key, value, module) do
+ {:ok, new_value} ->
+ {Map.put(ok_acc, key, new_value), err_acc}
+
+ {:error, message} ->
+ {ok_acc, err_acc ++ [%{field: key, message: message, action: :validator}]}
+ end
+ end
+ end)
+ end
+
+ defp embedded?(nil), do: false
+
+ defp embedded?(meta) do
+ meta.kind == :sub_field or
+ not is_nil(Map.get(meta, :struct)) or
+ (not is_nil(Map.get(meta, :structs)) and Map.get(meta, :structs) != false)
+ end
+
+ defp run_field_validator(nil, _key, value, _module), do: {:ok, value}
+ defp run_field_validator(%{kind: :sub_field}, _key, value, _module), do: {:ok, value}
+
+ defp run_field_validator(%{validator: {mod, fun}}, key, value, _module)
+ when is_atom(mod) and is_atom(fun) do
+ case apply(mod, fun, [key, value]) do
+ {:ok, _key, new_value} -> {:ok, new_value}
+ {:error, _key, message} -> {:error, message}
+ other -> other
+ end
+ end
+
+ defp run_field_validator(_meta, key, value, module) do
+ if function_exported?(module, :validator, 2) do
+ case apply(module, :validator, [key, value]) do
+ {:ok, _key, new_value} -> {:ok, new_value}
+ {:error, _key, message} -> {:error, message}
+ _ -> {:ok, value}
+ end
+ else
+ {:ok, value}
+ end
+ end
+
+ defp run_main_validator(attrs, module) do
+ cond do
+ function_exported?(module, :main_validator, 1) ->
+ case apply(module, :main_validator, [attrs]) do
+ {:ok, value} -> {:ok, value}
+ {:error, errs} when is_list(errs) -> {:error, errs}
+ {:error, err} -> {:error, [err]}
+ _ -> {:ok, attrs}
+ end
+
+ true ->
+ {:ok, attrs}
+ end
+ end
+
+ defp run_derives(value, fields_meta) do
+ derive_inputs =
+ Enum.flat_map(fields_meta, fn f ->
+ ops = Map.get(f, :__derive_ops__)
+ str = Map.get(f, :derive)
+
+ cond do
+ is_nil(ops) and is_nil(str) -> []
+ true -> [%{field: f.name, derive: str, derive_ops: ops}]
+ end
+ end)
+
+ if derive_inputs == [] do
+ {:ok, value}
+ else
+ {data_map, rewrap} =
+ if is_struct(value) do
+ {Map.from_struct(value), &struct(value.__struct__, &1)}
+ else
+ {value, & &1}
+ end
+
+ case Derive.derive({:ok, data_map, derive_inputs}) do
+ {:ok, processed} -> {:ok, rewrap.(processed)}
+ {:error, errors} -> {:error, errors}
+ end
+ end
+ end
+
+ defp handle_error({:error, errs} = result, module, true) do
+ error_module = Module.concat(module, Error)
+
+ if Code.ensure_loaded?(error_module) do
+ raise error_module, errors: errs, term: nil
+ else
+ result
+ end
+ end
+
+ defp handle_error(result, _module, _error?), do: result
+
+ @doc false
+ def all_keys(module) do
+ info = module.__information__()
+ fields_meta = module.__fields__()
+
+ Enum.map(info.keys, fn k ->
+ meta = Enum.find(fields_meta, fn f -> f.name == k end)
+
+ case meta do
+ %{kind: :sub_field} ->
+ submodule = Module.concat(module, atom_to_module(k))
+
+ if Code.ensure_loaded?(submodule) and function_exported?(submodule, :__information__, 0),
+ do: %{k => all_keys(submodule)},
+ else: k
+
+ _ ->
+ k
+ end
+ end)
+ end
+
+ @doc false
+ def all_enforce_keys(module) do
+ info = module.__information__()
+ fields_meta = module.__fields__()
+
+ Enum.flat_map(info.enforce_keys, fn k ->
+ meta = Enum.find(fields_meta, fn f -> f.name == k end)
+
+ case meta do
+ %{kind: :sub_field} ->
+ submodule = Module.concat(module, atom_to_module(k))
+
+ nested =
+ if Code.ensure_loaded?(submodule) and
+ function_exported?(submodule, :__information__, 0),
+ do: all_keys(submodule),
+ else: []
+
+ [%{k => nested}]
+
+ _ ->
+ [k]
+ end
+ end)
+ end
+
+ defp atom_to_module(field_atom) do
+ field_atom |> Atom.to_string() |> Macro.camelize() |> String.to_atom()
+ end
+end
diff --git a/lib/guarded_struct/transformers/auto_wire_ash_change.ex b/lib/guarded_struct/transformers/auto_wire_ash_change.ex
new file mode 100644
index 0000000..d7a4893
--- /dev/null
+++ b/lib/guarded_struct/transformers/auto_wire_ash_change.ex
@@ -0,0 +1,36 @@
+defmodule GuardedStruct.Transformers.AutoWireAshChange do
+ @moduledoc false
+
+ # Injects `GuardedStruct.AshResource.Change` into the resource's
+ # top-level `changes` section when `auto_wire: true` is set on the
+ # guardedstruct section. Equivalent to writing
+ # `changes do change GuardedStruct.AshResource.Change end` by hand.
+
+ use Spark.Dsl.Transformer
+
+ alias Spark.Dsl.Transformer
+
+ @impl true
+ def after?(GuardedStruct.Transformers.GenerateAshValidator), do: true
+ def after?(_), do: false
+
+ @impl true
+ def transform(dsl_state) do
+ auto_wire? = Transformer.get_option(dsl_state, [:guardedstruct], :auto_wire, false) == true
+
+ cond do
+ not auto_wire? ->
+ {:ok, dsl_state}
+
+ not Code.ensure_loaded?(Ash.Resource.Builder) ->
+ {:ok, dsl_state}
+
+ true ->
+ apply(Ash.Resource.Builder, :add_change, [
+ dsl_state,
+ GuardedStruct.AshResource.Change,
+ []
+ ])
+ end
+ end
+end
diff --git a/lib/guarded_struct/transformers/codegen.ex b/lib/guarded_struct/transformers/codegen.ex
new file mode 100644
index 0000000..4ad9fd0
--- /dev/null
+++ b/lib/guarded_struct/transformers/codegen.ex
@@ -0,0 +1,585 @@
+defmodule GuardedStruct.Transformers.Codegen do
+ @moduledoc false
+
+ alias GuardedStruct.Dsl.{Field, SubField, ConditionalField, VirtualField}
+
+ @doc """
+ Public entry point — also used by the Ash extension's transformer.
+ """
+ def struct_pieces(entities, block_enforce), do: build_struct_pieces(entities, block_enforce)
+
+ @doc """
+ Build the codegen body for a guardedstruct module.
+
+ * `entities` — list of `%Field{}` and `%SubField{}` (and later
+ `%ConditionalField{}`) entities collected from DSL state.
+ * `block_enforce` — section-level `enforce: true` flag.
+ * `opaque?` — section-level `opaque: true` flag.
+ * `error?` — section-level `error: true` flag (generates an `Error` exception).
+ * `path` — for nested submodules, the list of atoms representing the path from
+ the root user module down to this submodule (used in `__information__/0`).
+ """
+ def build_body(entities, block_enforce, opaque?, error?, path \\ [], options \\ %{}) do
+ case classify_shape(entities) do
+ :pattern_map ->
+ build_pattern_map_body(entities, error?, path, options)
+
+ :struct ->
+ build_struct_body(entities, block_enforce, opaque?, error?, path, options)
+
+ {:mixed, atom_names, regex_names} ->
+ raise Spark.Error.DslError,
+ message:
+ "cannot mix atom-keyed and regex-keyed `field` declarations in the same " <>
+ "guardedstruct.\n" <>
+ "Atom fields create fixed slots on a struct (#{inspect(atom_names)}); " <>
+ "regex fields create entries in a free-form map " <>
+ "(#{inspect(Enum.map(regex_names, &Regex.source/1))}).\n" <>
+ "These shapes can't both fit in one Elixir struct. Either keep just one " <>
+ "shape, or extract the regex part into a separate module and reference it " <>
+ "via `struct:`.",
+ path: [:guardedstruct]
+ end
+ end
+
+ defp classify_shape(entities) do
+ {atoms, regexes} =
+ Enum.reduce(entities, {[], []}, fn entity, {a, r} ->
+ case entity_name(entity) do
+ name when is_atom(name) and not is_nil(name) -> {[name | a], r}
+ %Regex{} = pattern -> {a, [pattern | r]}
+ _ -> {a, r}
+ end
+ end)
+
+ cond do
+ atoms == [] and regexes != [] -> :pattern_map
+ atoms != [] and regexes != [] -> {:mixed, Enum.reverse(atoms), Enum.reverse(regexes)}
+ true -> :struct
+ end
+ end
+
+ defp build_struct_body(entities, block_enforce, opaque?, error?, path, options) do
+ {keys, defstruct_kw, types, enforce_keys, fields_runtime} =
+ build_struct_pieces(entities, block_enforce)
+
+ json? = Map.get(options, :json, false) == true
+
+ # `json: true` opts into JSON encoding. Precedence:
+ # 1. Jason.Encoder — if user has `:jason` in their deps
+ # 2. JSON.Encoder — built-in on Elixir 1.18+
+ # 3. no-op — neither available
+ derive_json_ast =
+ if json? do
+ quote do
+ cond do
+ Code.ensure_loaded?(Jason.Encoder) -> @derive Jason.Encoder
+ Code.ensure_loaded?(JSON.Encoder) -> @derive JSON.Encoder
+ true -> :ok
+ end
+ end
+ end
+
+ example_pairs =
+ entities
+ |> Enum.reject(&match?(%VirtualField{}, &1))
+ |> Enum.uniq_by(& &1.name)
+ |> Enum.map(fn entity -> {entity.name, example_value_ast(entity, path)} end)
+
+ conditional_keys =
+ entities
+ |> Enum.filter(&match?(%ConditionalField{}, &1))
+ |> Enum.map(& &1.name)
+ |> Enum.uniq()
+
+ info_map =
+ Macro.escape(%{
+ path: path,
+ key: if(path == [], do: :root, else: List.last(path)),
+ keys: keys,
+ enforce_keys: enforce_keys,
+ conditional_keys: conditional_keys,
+ options: options
+ })
+
+ quote do
+ unquote(derive_json_ast)
+ @enforce_keys unquote(enforce_keys)
+ defstruct unquote(defstruct_kw)
+
+ if unquote(opaque?) do
+ @opaque t() :: %__MODULE__{unquote_splicing(types)}
+ else
+ @type t() :: %__MODULE__{unquote_splicing(types)}
+ end
+
+ if Module.defines?(__MODULE__, {:keys, 0}, :def),
+ do: defoverridable(keys: 0)
+
+ if Module.defines?(__MODULE__, {:keys, 1}, :def),
+ do: defoverridable(keys: 1)
+
+ if Module.defines?(__MODULE__, {:enforce_keys, 0}, :def),
+ do: defoverridable(enforce_keys: 0)
+
+ if Module.defines?(__MODULE__, {:enforce_keys, 1}, :def),
+ do: defoverridable(enforce_keys: 1)
+
+ if Module.defines?(__MODULE__, {:__information__, 0}, :def),
+ do: defoverridable(__information__: 0)
+
+ if Module.defines?(__MODULE__, {:__fields__, 0}, :def),
+ do: defoverridable(__fields__: 0)
+
+ if Module.defines?(__MODULE__, {:builder, 1}, :def),
+ do: defoverridable(builder: 1)
+
+ if Module.defines?(__MODULE__, {:builder, 2}, :def),
+ do: defoverridable(builder: 2)
+
+ def keys, do: unquote(keys)
+ def keys(:all), do: GuardedStruct.Runtime.all_keys(__MODULE__)
+ def keys(field) when is_atom(field), do: field in unquote(keys)
+
+ def enforce_keys, do: unquote(enforce_keys)
+ def enforce_keys(:all), do: GuardedStruct.Runtime.all_enforce_keys(__MODULE__)
+ def enforce_keys(field) when is_atom(field), do: field in unquote(enforce_keys)
+
+ def __information__ do
+ Map.put(unquote(info_map), :module, __MODULE__)
+ end
+
+ def __fields__, do: unquote(Macro.escape(fields_runtime))
+
+ def builder(attrs_or_input, error \\ false)
+
+ def builder({_, _} = input, error),
+ do: GuardedStruct.Runtime.build(__MODULE__, input, error)
+
+ def builder({_, _, _} = input, error),
+ do: GuardedStruct.Runtime.build(__MODULE__, input, error)
+
+ def builder(attrs, error),
+ do: GuardedStruct.Runtime.build(__MODULE__, attrs, error)
+
+ @doc "A sample %#{inspect(__MODULE__)}{} populated from defaults + type-based fallbacks."
+ def example, do: struct(__MODULE__, unquote(example_pairs))
+
+ if unquote(error?) do
+ defmodule Error do
+ defexception [:errors, :term]
+
+ @impl true
+ def message(%{errors: errs, term: term}) do
+ """
+ #{GuardedStruct.Messages.translated_message(:message_exception)}
+ Term: #{inspect(term)}
+ Errors: #{inspect(errs)}
+ """
+ end
+ end
+ end
+ end
+ end
+
+ defp build_pattern_map_body(entities, error?, _path, options) do
+ patterns = Enum.map(entities, & &1.name)
+
+ fields_runtime =
+ Enum.map(entities, fn %Field{} = f ->
+ %{
+ kind: :pattern_field,
+ pattern: f.name,
+ type: Macro.to_string(f.type),
+ enforce: f.enforce,
+ derive: f.derives || f.derive,
+ __derive_ops__: f.__derive_ops__,
+ validator: f.validator,
+ struct: f.struct,
+ structs: f.structs,
+ hint: f.hint,
+ default: f.default
+ }
+ end)
+
+ info_map =
+ Macro.escape(%{
+ path: [],
+ key: :pattern,
+ keys: [],
+ enforce_keys: [],
+ conditional_keys: [],
+ patterns: patterns,
+ options: options,
+ shape: :pattern_map
+ })
+
+ quote do
+ def keys, do: []
+ def keys(_), do: false
+ def enforce_keys, do: []
+ def enforce_keys(_), do: false
+
+ def __information__ do
+ Map.put(unquote(info_map), :module, __MODULE__)
+ end
+
+ def __fields__, do: unquote(Macro.escape(fields_runtime))
+
+ def example, do: %{}
+
+ def builder(attrs, error \\ false)
+
+ def builder({:__nested__, local_attrs, _full_attrs, _path, _type}, error),
+ do: GuardedStruct.Runtime.build_pattern_map(__MODULE__, local_attrs, error)
+
+ def builder({_, _} = input, error),
+ do: GuardedStruct.Runtime.build_pattern_map(__MODULE__, input, error)
+
+ def builder({_, _, _} = input, error),
+ do: GuardedStruct.Runtime.build_pattern_map(__MODULE__, input, error)
+
+ def builder(attrs, error),
+ do: GuardedStruct.Runtime.build_pattern_map(__MODULE__, attrs, error)
+
+ if unquote(error?) do
+ defmodule Error do
+ defexception [:errors, :term]
+
+ @impl true
+ def message(%{errors: errs, term: term}) do
+ """
+ #{GuardedStruct.Messages.translated_message(:message_exception)}
+ Term: #{inspect(term)}
+ Errors: #{inspect(errs)}
+ """
+ end
+ end
+ end
+ end
+ end
+
+ @doc """
+ Raise `ArgumentError` for non-atom non-regex field names or duplicate names.
+ """
+ def validate_entities!(entities) do
+ Enum.reduce(entities, [], fn entity, seen ->
+ name = entity_name(entity)
+
+ cond do
+ is_atom(name) and not is_nil(name) ->
+ if name in seen do
+ raise ArgumentError, "the field #{inspect(name)} is already set"
+ end
+
+ [name | seen]
+
+ is_struct(name, Regex) ->
+ [name | seen]
+
+ is_number(name) or is_binary(name) ->
+ raise ArgumentError, "a field name must be an atom, got #{inspect(name)}"
+
+ true ->
+ seen
+ end
+ end)
+
+ :ok
+ end
+
+ defp entity_name(%Field{name: n}), do: n
+ defp entity_name(%SubField{name: n}), do: n
+ defp entity_name(other), do: Map.get(other, :name)
+
+ defp build_struct_pieces(entities, block_enforce) do
+ {virtual_entities, struct_entities} =
+ Enum.split_with(entities, &match?(%VirtualField{}, &1))
+
+ unique_entities =
+ Enum.uniq_by(struct_entities, & &1.name)
+
+ keys = Enum.map(unique_entities, & &1.name)
+
+ defstruct_kw =
+ Enum.map(unique_entities, fn
+ %Field{} = f -> {f.name, f.default}
+ %SubField{} = sf -> {sf.name, sf.default}
+ %ConditionalField{} = cf -> {cf.name, cf.default}
+ other -> {other.name, nil}
+ end)
+
+ enforce_keys =
+ Enum.flat_map(unique_entities, fn
+ %Field{} = f ->
+ enforce_for_field(f, block_enforce)
+
+ %SubField{} = sf ->
+ enforce_for_field(sf, block_enforce)
+
+ %ConditionalField{} = cf ->
+ if cf.enforce == true, do: [cf.name], else: []
+
+ other ->
+ if Map.get(other, :enforce) == true, do: [other.name], else: []
+ end)
+ |> Enum.reverse()
+
+ types =
+ Enum.map(unique_entities, fn entity ->
+ nullable? = entity.name not in enforce_keys
+
+ type_ast =
+ case entity do
+ %Field{type: t} -> t
+ %SubField{type: t} -> t
+ %ConditionalField{type: t} -> t
+ other -> Map.get(other, :type)
+ end
+
+ {entity.name, if(nullable?, do: nullable_type(type_ast), else: type_ast)}
+ end)
+
+ fields_runtime =
+ Enum.map(struct_entities, fn
+ %Field{} = f ->
+ %{
+ kind: if(f.__dynamic__, do: :dynamic_field, else: :field),
+ name: f.name,
+ type: Macro.to_string(f.type),
+ enforce: f.enforce,
+ derive: f.derives || f.derive,
+ __derive_ops__: f.__derive_ops__,
+ __from_path__: f.__from_path__,
+ __on_path__: f.__on_path__,
+ __domain_ops__: f.__domain_ops__,
+ validator: f.validator,
+ auto: f.auto,
+ on: f.on,
+ from: f.from,
+ domain: f.domain,
+ struct: f.struct,
+ structs: f.structs,
+ hint: f.hint,
+ priority: f.priority,
+ default: f.default
+ }
+
+ %SubField{} = sf ->
+ %{
+ kind: :sub_field,
+ name: sf.name,
+ type: Macro.to_string(sf.type),
+ enforce: sf.enforce,
+ derive: sf.derives || sf.derive,
+ __derive_ops__: sf.__derive_ops__,
+ __from_path__: sf.__from_path__,
+ __on_path__: sf.__on_path__,
+ __domain_ops__: sf.__domain_ops__,
+ validator: sf.validator,
+ auto: sf.auto,
+ on: sf.on,
+ from: sf.from,
+ domain: sf.domain,
+ struct: sf.struct,
+ structs: sf.structs,
+ hint: sf.hint,
+ priority: sf.priority,
+ default: sf.default,
+ error: sf.error,
+ authorized_fields: sf.authorized_fields,
+ main_validator: sf.main_validator,
+ list?: sf.structs == true
+ }
+
+ %ConditionalField{} = cf ->
+ %{
+ kind: :conditional_field,
+ name: cf.name,
+ type: Macro.to_string(cf.type),
+ enforce: cf.enforce,
+ derive: cf.derives || cf.derive,
+ __derive_ops__: cf.__derive_ops__,
+ __from_path__: cf.__from_path__,
+ __on_path__: cf.__on_path__,
+ __domain_ops__: cf.__domain_ops__,
+ validator: cf.validator,
+ auto: cf.auto,
+ on: cf.on,
+ from: cf.from,
+ domain: cf.domain,
+ struct: cf.struct,
+ structs: cf.structs,
+ hint: cf.hint,
+ priority: cf.priority,
+ default: cf.default,
+ list?: cf.structs == true,
+ children: encode_children(merge_children_in_source_order(cf))
+ }
+
+ other ->
+ %{kind: :unknown, name: other.name}
+ end)
+
+ virtual_runtime =
+ Enum.map(virtual_entities, fn %VirtualField{} = vf ->
+ %{
+ kind: :virtual_field,
+ name: vf.name,
+ type: Macro.to_string(vf.type),
+ enforce: vf.enforce,
+ derive: vf.derives || vf.derive,
+ __derive_ops__: vf.__derive_ops__,
+ __from_path__: vf.__from_path__,
+ __on_path__: vf.__on_path__,
+ __domain_ops__: vf.__domain_ops__,
+ validator: vf.validator,
+ auto: vf.auto,
+ on: vf.on,
+ from: vf.from,
+ domain: vf.domain,
+ hint: vf.hint,
+ default: vf.default
+ }
+ end)
+
+ {keys, defstruct_kw, types, enforce_keys, fields_runtime ++ virtual_runtime}
+ end
+
+ # Spark partitions conditional_field children into separate :fields,
+ # :sub_fields, :conditional_fields lists; sort by `:type` AST line metadata
+ # to restore source-declaration order.
+ defp merge_children_in_source_order(%ConditionalField{} = cf) do
+ (cf.fields ++ cf.sub_fields ++ cf.conditional_fields)
+ |> Enum.sort_by(&entity_line/1)
+ end
+
+ defp entity_line(%{type: type_ast}) do
+ extract_line(type_ast)
+ end
+
+ defp entity_line(_), do: 0
+
+ defp extract_line({_, meta, _}) when is_list(meta), do: Keyword.get(meta, :line, 0)
+ defp extract_line({_, meta, _, _}) when is_list(meta), do: Keyword.get(meta, :line, 0)
+ defp extract_line(_), do: 0
+
+ defp encode_children(entities) do
+ {result, _} =
+ Enum.reduce(entities, {[], 0}, fn
+ %Field{} = f, {acc, sf_count} ->
+ encoded = %{
+ kind: :field,
+ name: f.name,
+ derive: f.derives || f.derive,
+ __derive_ops__: f.__derive_ops__,
+ validator: f.validator,
+ struct: f.struct,
+ structs: f.structs,
+ hint: f.hint
+ }
+
+ {acc ++ [encoded], sf_count}
+
+ %SubField{} = sf, {acc, sf_count} ->
+ new_count = sf_count + 1
+
+ encoded = %{
+ kind: :sub_field,
+ name: sf.name,
+ sub_field_index: new_count,
+ derive: sf.derives || sf.derive,
+ __derive_ops__: sf.__derive_ops__,
+ validator: sf.validator,
+ structs: sf.structs,
+ hint: sf.hint,
+ list?: sf.structs == true
+ }
+
+ {acc ++ [encoded], new_count}
+
+ %ConditionalField{} = cf, {acc, sf_count} ->
+ encoded = %{
+ kind: :conditional_field,
+ name: cf.name,
+ derive: cf.derives || cf.derive,
+ __derive_ops__: cf.__derive_ops__,
+ validator: cf.validator,
+ hint: cf.hint,
+ structs: cf.structs,
+ list?: cf.structs == true,
+ children: encode_children(merge_children_in_source_order(cf))
+ }
+
+ {acc ++ [encoded], sf_count}
+ end)
+
+ result
+ end
+
+ defp enforce_for_field(field, block_enforce) do
+ cond do
+ field.enforce == false -> []
+ field.enforce == true -> [field.name]
+ block_enforce and is_nil(field.default) -> [field.name]
+ true -> []
+ end
+ end
+
+ defp nullable_type(type_ast), do: {:|, [], [type_ast, nil]}
+
+ @doc """
+ Camelize an atom field name into a submodule name component.
+
+ iex> GuardedStruct.Transformers.Codegen.atom_to_module(:my_field)
+ :MyField
+ """
+ def atom_to_module(field_atom) do
+ field_atom |> Atom.to_string() |> Macro.camelize() |> String.to_atom()
+ end
+
+ defp example_value_ast(%Field{default: default}, _path) when not is_nil(default), do: default
+
+ defp example_value_ast(%Field{struct: mod}, _path) when is_atom(mod) and not is_nil(mod) do
+ quote do: unquote(mod).example()
+ end
+
+ defp example_value_ast(%Field{structs: mod}, _path)
+ when is_atom(mod) and mod not in [nil, true, false] do
+ quote do: [unquote(mod).example()]
+ end
+
+ defp example_value_ast(%Field{type: type}, _path), do: type_default_ast(type)
+
+ defp example_value_ast(%SubField{default: default}, _path) when not is_nil(default),
+ do: default
+
+ defp example_value_ast(%SubField{name: name}, _path) do
+ component = atom_to_module(name)
+ quote do: Module.concat(__MODULE__, unquote(component)).example()
+ end
+
+ defp example_value_ast(%ConditionalField{default: default}, _path) when not is_nil(default),
+ do: default
+
+ defp example_value_ast(%ConditionalField{}, _path), do: nil
+
+ defp example_value_ast(_other, _path), do: nil
+
+ # Heuristic placeholder values for common type ASTs. Anything we don't
+ # recognise falls back to nil — the user can always set `default:` to
+ # override.
+ defp type_default_ast({{:., _, [{:__aliases__, _, [:String]}, :t]}, _, _}), do: ""
+ defp type_default_ast({:integer, _, _}), do: 0
+ defp type_default_ast({:non_neg_integer, _, _}), do: 0
+ defp type_default_ast({:pos_integer, _, _}), do: 1
+ defp type_default_ast({:float, _, _}), do: 0.0
+ defp type_default_ast({:number, _, _}), do: 0
+ defp type_default_ast({:boolean, _, _}), do: false
+ defp type_default_ast({:atom, _, _}), do: :placeholder
+ defp type_default_ast({:list, _, _}), do: []
+ defp type_default_ast({:map, _, _}), do: Macro.escape(%{})
+ defp type_default_ast({:any, _, _}), do: nil
+ defp type_default_ast({:term, _, _}), do: nil
+ defp type_default_ast(_other), do: nil
+end
diff --git a/lib/guarded_struct/transformers/generate_ash_validator.ex b/lib/guarded_struct/transformers/generate_ash_validator.ex
new file mode 100644
index 0000000..6ff77fd
--- /dev/null
+++ b/lib/guarded_struct/transformers/generate_ash_validator.ex
@@ -0,0 +1,94 @@
+defmodule GuardedStruct.Transformers.GenerateAshValidator do
+ @moduledoc false
+
+ # Codegen for the `GuardedStruct.AshResource` extension. Mirrors
+ # `GuardedStruct.Transformers.GenerateBuilder` but emits
+ # `__guarded_change__/1` and `__guarded_fields__/0` (plus the runtime
+ # metadata accessor `__guarded_information__/0`) instead of `defstruct`
+ # + `builder/2`.
+ #
+ # The function is called `__guarded_change__` (not `__guarded_validate__`)
+ # because it can both *validate* AND *transform* values — sanitize ops
+ # trim/downcase/slugify, derive auto-fills, etc. "Change" matches Ash's
+ # terminology (the function fires inside an `Ash.Resource.Change`).
+ #
+ # Function names are namespaced with `__guarded_*` so they don't collide
+ # with Ash's `__resource__/1`, `__struct__/1`, etc. Code that needs them
+ # imports the `GuardedStruct.AshResource.Info` module.
+
+ use Spark.Dsl.Transformer
+
+ alias Spark.Dsl.Transformer
+ alias GuardedStruct.Dsl.ConditionalField
+ alias GuardedStruct.Transformers.Codegen
+
+ @impl true
+ def transform(dsl_state) do
+ entities = Transformer.get_entities(dsl_state, [:guardedstruct])
+
+ block_enforce = Transformer.get_option(dsl_state, [:guardedstruct], :enforce, false)
+
+ Codegen.validate_entities!(entities)
+
+ section_options = %{
+ authorized_fields:
+ Transformer.get_option(dsl_state, [:guardedstruct], :authorized_fields, false)
+ }
+
+ {keys, _defstruct_kw, _types, enforce_keys, fields_runtime} =
+ Codegen.struct_pieces(entities, block_enforce)
+
+ conditional_keys =
+ entities
+ |> Enum.filter(&match?(%ConditionalField{}, &1))
+ |> Enum.map(& &1.name)
+ |> Enum.uniq()
+
+ info_map =
+ Macro.escape(%{
+ path: [],
+ key: :root,
+ keys: keys,
+ enforce_keys: enforce_keys,
+ conditional_keys: conditional_keys,
+ options: section_options
+ })
+
+ body =
+ quote do
+ if Module.defines?(__MODULE__, {:__guarded_information__, 0}, :def),
+ do: defoverridable(__guarded_information__: 0)
+
+ if Module.defines?(__MODULE__, {:__guarded_fields__, 0}, :def),
+ do: defoverridable(__guarded_fields__: 0)
+
+ if Module.defines?(__MODULE__, {:__guarded_change__, 1}, :def),
+ do: defoverridable(__guarded_change__: 1)
+
+ if Module.defines?(__MODULE__, {:__guarded_change__, 2}, :def),
+ do: defoverridable(__guarded_change__: 2)
+
+ def __guarded_information__ do
+ Map.put(unquote(info_map), :module, __MODULE__)
+ end
+
+ def __guarded_fields__, do: unquote(Macro.escape(fields_runtime))
+
+ @doc """
+ Apply the full GuardedStruct pipeline (sanitize → validate → derive →
+ main_validator) to `attrs` and return either `{:ok, transformed_attrs}`
+ or `{:error, errors}`. Wire this into an `Ash.Resource.Change` to plug
+ guardedstruct rules into Ash's changeset pipeline.
+
+ The function is named `__guarded_change__` because it does more than
+ validate — it can also transform values (trim, downcase, slugify,
+ auto-fill, etc.) before they reach the data layer.
+ """
+ def __guarded_change__(attrs, error? \\ false) do
+ GuardedStruct.Runtime.validate(__MODULE__, attrs, error?)
+ end
+ end
+
+ {:ok, Transformer.eval(dsl_state, [], body)}
+ end
+end
diff --git a/lib/guarded_struct/transformers/generate_builder.ex b/lib/guarded_struct/transformers/generate_builder.ex
new file mode 100644
index 0000000..f11fb28
--- /dev/null
+++ b/lib/guarded_struct/transformers/generate_builder.ex
@@ -0,0 +1,44 @@
+defmodule GuardedStruct.Transformers.GenerateBuilder do
+ @moduledoc false
+
+ use Spark.Dsl.Transformer
+
+ alias Spark.Dsl.Transformer
+ alias GuardedStruct.Transformers.Codegen
+
+ @impl true
+ def transform(dsl_state) do
+ entities = Transformer.get_entities(dsl_state, [:guardedstruct])
+
+ block_enforce = Transformer.get_option(dsl_state, [:guardedstruct], :enforce, false)
+ opaque? = Transformer.get_option(dsl_state, [:guardedstruct], :opaque, false)
+ error? = Transformer.get_option(dsl_state, [:guardedstruct], :error, false)
+ module_opt = Transformer.get_option(dsl_state, [:guardedstruct], :module)
+
+ Codegen.validate_entities!(entities)
+
+ section_options = %{
+ authorized_fields:
+ Transformer.get_option(dsl_state, [:guardedstruct], :authorized_fields, false),
+ json: Transformer.get_option(dsl_state, [:guardedstruct], :json, false)
+ }
+
+ body =
+ Codegen.build_body(entities, block_enforce, opaque?, error?, [], section_options)
+
+ injected =
+ case module_opt do
+ nil ->
+ body
+
+ mod_ast ->
+ quote do
+ defmodule unquote(mod_ast) do
+ unquote(body)
+ end
+ end
+ end
+
+ {:ok, Transformer.eval(dsl_state, [], injected)}
+ end
+end
diff --git a/lib/guarded_struct/transformers/generate_sub_field_modules.ex b/lib/guarded_struct/transformers/generate_sub_field_modules.ex
new file mode 100644
index 0000000..0629e82
--- /dev/null
+++ b/lib/guarded_struct/transformers/generate_sub_field_modules.ex
@@ -0,0 +1,110 @@
+defmodule GuardedStruct.Transformers.GenerateSubFieldModules do
+ @moduledoc false
+
+ use Spark.Dsl.Transformer
+
+ alias Spark.Dsl.Transformer
+ alias GuardedStruct.Dsl.{SubField, ConditionalField}
+ alias GuardedStruct.Transformers.Codegen
+
+ @impl true
+ def before?(GuardedStruct.Transformers.GenerateBuilder), do: true
+ def before?(_), do: false
+
+ @impl true
+ def transform(dsl_state) do
+ parent = Transformer.get_persisted(dsl_state, :module)
+ module_opt = Transformer.get_option(dsl_state, [:guardedstruct], :module)
+ entities = Transformer.get_entities(dsl_state, [:guardedstruct])
+
+ base_module =
+ case module_opt do
+ nil -> parent
+ ast -> resolve_module_ast(parent, ast)
+ end
+
+ json? = Transformer.get_option(dsl_state, [:guardedstruct], :json) == true
+
+ # Walk the entity tree and submit each Module.create as an async
+ # compile task on the dsl_state. Spark awaits all tasks before the
+ # next transformer (GenerateBuilder) runs, so sibling submodules
+ # compile in parallel while preserving the parent → builder order.
+ dsl_state = generate_for_entities(entities, [base_module], json?, dsl_state)
+
+ {:ok, dsl_state}
+ end
+
+ defp resolve_module_ast(parent, {:__aliases__, _, parts}) when is_list(parts) do
+ Module.concat([parent | parts])
+ end
+
+ defp resolve_module_ast(parent, name) when is_atom(name), do: Module.concat(parent, name)
+ defp resolve_module_ast(_parent, mod) when is_atom(mod), do: mod
+
+ defp generate_for_entities(entities, parent_path, json?, dsl_state) do
+ Enum.reduce(entities, dsl_state, fn
+ %SubField{} = sf, acc ->
+ generate_sub_field(sf, parent_path, json?, acc)
+
+ %ConditionalField{} = cf, acc ->
+ acc =
+ cf.sub_fields
+ |> Enum.with_index(1)
+ |> Enum.reduce(acc, fn {inner_sf, idx}, inner_acc ->
+ numbered_name = "#{cf.name}#{idx}" |> String.to_atom()
+ renamed = %{inner_sf | name: numbered_name}
+ generate_sub_field(renamed, parent_path, json?, inner_acc)
+ end)
+
+ Enum.reduce(cf.conditional_fields, acc, fn inner_cf, inner_acc ->
+ generate_for_entities([inner_cf], parent_path, json?, inner_acc)
+ end)
+
+ _, acc ->
+ acc
+ end)
+ end
+
+ defp generate_sub_field(%SubField{} = sf, parent_path, json?, dsl_state) do
+ submodule = Module.concat(parent_path ++ [Codegen.atom_to_module(sf.name)])
+ new_path = parent_path ++ [Codegen.atom_to_module(sf.name)]
+
+ # Recurse first so child submodule tasks are registered on dsl_state
+ # before the parent's own task is added. Order of task creation is
+ # cosmetic only (Spark awaits all of them); the parent's compiled
+ # output doesn't reference children at compile time.
+ dsl_state =
+ generate_for_entities(sf.sub_fields ++ sf.conditional_fields, new_path, json?, dsl_state)
+
+ Codegen.validate_entities!(sf.fields ++ sf.sub_fields ++ sf.conditional_fields)
+
+ body =
+ Codegen.build_body(
+ sf.fields ++ sf.sub_fields ++ sf.conditional_fields,
+ sf.enforce == true,
+ false,
+ sf.error == true,
+ info_path(submodule),
+ %{authorized_fields: sf.authorized_fields == true, json: json?}
+ )
+
+ file = file_for(sf)
+ line = line_for(sf)
+
+ Transformer.async_compile(dsl_state, fn ->
+ Module.create(submodule, body, file: file, line: line)
+ end)
+ end
+
+ defp info_path(submodule), do: Module.split(submodule) |> Enum.map(&String.to_atom/1)
+
+ defp file_for(%{__spark_metadata__: %{anno: anno}}) when is_map(anno),
+ do: Map.get(anno, :file, "nofile")
+
+ defp file_for(_), do: "nofile"
+
+ defp line_for(%{__spark_metadata__: %{anno: anno}}) when is_map(anno),
+ do: Map.get(anno, :line, 1)
+
+ defp line_for(_), do: 1
+end
diff --git a/lib/guarded_struct/transformers/parse_core_keys.ex b/lib/guarded_struct/transformers/parse_core_keys.ex
new file mode 100644
index 0000000..e8776fe
--- /dev/null
+++ b/lib/guarded_struct/transformers/parse_core_keys.ex
@@ -0,0 +1,64 @@
+defmodule GuardedStruct.Transformers.ParseCoreKeys do
+ @moduledoc false
+
+ use Spark.Dsl.Transformer
+
+ alias Spark.Dsl.Transformer
+ alias GuardedStruct.Dsl.{Field, SubField, ConditionalField, VirtualField}
+ alias GuardedStruct.Derive.Parser
+
+ @impl true
+ def before?(GuardedStruct.Transformers.GenerateBuilder), do: true
+ def before?(GuardedStruct.Transformers.GenerateSubFieldModules), do: true
+ def before?(_), do: false
+
+ @impl true
+ def transform(dsl_state) do
+ entities = Transformer.get_entities(dsl_state, [:guardedstruct])
+ new_entities = Enum.map(entities, &parse/1)
+
+ {:ok,
+ Enum.reduce(new_entities, dsl_state, fn new_entity, acc ->
+ Transformer.replace_entity(acc, [:guardedstruct], new_entity, fn old ->
+ old.name == new_entity.name and old.__struct__ == new_entity.__struct__
+ end)
+ end)}
+ end
+
+ defp parse(%Field{} = f) do
+ %{f | __from_path__: parse_path(f.from), __on_path__: parse_path(f.on)}
+ end
+
+ defp parse(%VirtualField{} = vf) do
+ %{vf | __from_path__: parse_path(vf.from), __on_path__: parse_path(vf.on)}
+ end
+
+ defp parse(%SubField{} = sf) do
+ %{
+ sf
+ | __from_path__: parse_path(sf.from),
+ __on_path__: parse_path(sf.on),
+ fields: Enum.map(sf.fields, &parse/1),
+ sub_fields: Enum.map(sf.sub_fields, &parse/1),
+ conditional_fields: Enum.map(sf.conditional_fields, &parse/1)
+ }
+ end
+
+ defp parse(%ConditionalField{} = cf) do
+ %{
+ cf
+ | __from_path__: parse_path(cf.from),
+ __on_path__: parse_path(cf.on),
+ fields: Enum.map(cf.fields, &parse/1),
+ sub_fields: Enum.map(cf.sub_fields, &parse/1),
+ conditional_fields: Enum.map(cf.conditional_fields, &parse/1)
+ }
+ end
+
+ defp parse(other), do: other
+
+ defp parse_path(nil), do: nil
+ defp parse_path(""), do: nil
+ defp parse_path(str) when is_binary(str), do: Parser.parse_core_keys_pattern(str)
+ defp parse_path(_), do: nil
+end
diff --git a/lib/guarded_struct/transformers/parse_derive.ex b/lib/guarded_struct/transformers/parse_derive.ex
new file mode 100644
index 0000000..9fe29ae
--- /dev/null
+++ b/lib/guarded_struct/transformers/parse_derive.ex
@@ -0,0 +1,104 @@
+defmodule GuardedStruct.Transformers.ParseDerive do
+ @moduledoc false
+
+ use Spark.Dsl.Transformer
+
+ alias Spark.Dsl.Transformer
+ alias GuardedStruct.Dsl.{Field, SubField, ConditionalField, VirtualField}
+ alias GuardedStruct.Derive.{Parser, OpEvaluator, OpParamValidator}
+
+ @impl true
+ def before?(GuardedStruct.Transformers.GenerateBuilder), do: true
+ def before?(GuardedStruct.Transformers.GenerateSubFieldModules), do: true
+ def before?(_), do: false
+
+ @impl true
+ def transform(dsl_state) do
+ module = Transformer.get_persisted(dsl_state, :module)
+ entities = Transformer.get_entities(dsl_state, [:guardedstruct])
+
+ new_entities = Enum.map(entities, &parse_entity(&1, module))
+
+ {:ok,
+ Enum.reduce(new_entities, dsl_state, fn new_entity, acc ->
+ Transformer.replace_entity(acc, [:guardedstruct], new_entity, fn old ->
+ old.name == new_entity.name and old.__struct__ == new_entity.__struct__
+ end)
+ end)}
+ end
+
+ defp parse_entity(%Field{} = f, module) do
+ %{f | __derive_ops__: parse_or_raise(resolve(f, module), f.name, module)}
+ end
+
+ defp parse_entity(%VirtualField{} = vf, module) do
+ %{vf | __derive_ops__: parse_or_raise(resolve(vf, module), vf.name, module)}
+ end
+
+ defp parse_entity(%SubField{} = sf, module) do
+ %{
+ sf
+ | __derive_ops__: parse_or_raise(resolve(sf, module), sf.name, module),
+ fields: Enum.map(sf.fields, &parse_entity(&1, module)),
+ sub_fields: Enum.map(sf.sub_fields, &parse_entity(&1, module)),
+ conditional_fields: Enum.map(sf.conditional_fields, &parse_entity(&1, module))
+ }
+ end
+
+ defp parse_entity(%ConditionalField{} = cf, module) do
+ %{
+ cf
+ | __derive_ops__: parse_or_raise(resolve(cf, module), cf.name, module),
+ fields: Enum.map(cf.fields, &parse_entity(&1, module)),
+ sub_fields: Enum.map(cf.sub_fields, &parse_entity(&1, module)),
+ conditional_fields: Enum.map(cf.conditional_fields, &parse_entity(&1, module))
+ }
+ end
+
+ defp parse_entity(other, _module), do: other
+
+ # Prefer `derives:` (canonical). Fall back to legacy `derive:` with a
+ # soft-deprecation warning via Spark's deprecation helper.
+ defp resolve(%{derives: derives}, _module) when is_binary(derives) and derives != "",
+ do: derives
+
+ defp resolve(%{derive: derive, name: name} = entity, module)
+ when is_binary(derive) and derive != "" do
+ warn_deprecated_derive(name, entity, module)
+ derive
+ end
+
+ defp resolve(_entity, _module), do: nil
+
+ defp warn_deprecated_derive(field_name, entity, module) do
+ location = Map.get(entity, :__spark_metadata__) |> get_anno()
+
+ Spark.Warning.warn_deprecated(
+ "`derive:` option on field #{inspect(field_name)} of #{inspect(module)}",
+ "Use `derives:` instead. `derive:` will be removed in a future release.",
+ location,
+ nil
+ )
+ end
+
+ defp get_anno(%{anno: anno}), do: anno
+ defp get_anno(_), do: nil
+
+ defp parse_or_raise(nil, _field_name, _module), do: nil
+ defp parse_or_raise("", _field_name, _module), do: nil
+
+ defp parse_or_raise(str, field_name, module) when is_binary(str) do
+ str
+ |> Parser.parser()
+ |> OpEvaluator.preevaluate()
+ |> OpParamValidator.validate!(field_name, module)
+ end
+
+ defp parse_or_raise(other, field_name, module) do
+ raise Spark.Error.DslError,
+ message:
+ "invalid derives on field #{inspect(field_name)}: expected a string, got #{inspect(other)}",
+ path: [:guardedstruct, :field, field_name, :derives],
+ module: module
+ end
+end
diff --git a/lib/guarded_struct/transformers/parse_domain.ex b/lib/guarded_struct/transformers/parse_domain.ex
new file mode 100644
index 0000000..8433d91
--- /dev/null
+++ b/lib/guarded_struct/transformers/parse_domain.ex
@@ -0,0 +1,112 @@
+defmodule GuardedStruct.Transformers.ParseDomain do
+ @moduledoc false
+
+ use Spark.Dsl.Transformer
+
+ alias Spark.Dsl.Transformer
+ alias GuardedStruct.Dsl.{Field, SubField, ConditionalField, VirtualField}
+ alias GuardedStruct.Derive.{Parser, OpEvaluator}
+
+ @impl true
+ def before?(GuardedStruct.Transformers.GenerateBuilder), do: true
+ def before?(GuardedStruct.Transformers.GenerateSubFieldModules), do: true
+ def before?(_), do: false
+
+ @impl true
+ def transform(dsl_state) do
+ entities = Transformer.get_entities(dsl_state, [:guardedstruct])
+ new_entities = Enum.map(entities, &parse/1)
+
+ {:ok,
+ Enum.reduce(new_entities, dsl_state, fn new_entity, acc ->
+ Transformer.replace_entity(acc, [:guardedstruct], new_entity, fn old ->
+ old.name == new_entity.name and old.__struct__ == new_entity.__struct__
+ end)
+ end)}
+ end
+
+ defp parse(%Field{} = f), do: %{f | __domain_ops__: parse_domain(f.domain)}
+ defp parse(%VirtualField{} = vf), do: %{vf | __domain_ops__: parse_domain(vf.domain)}
+
+ defp parse(%SubField{} = sf) do
+ %{
+ sf
+ | __domain_ops__: parse_domain(sf.domain),
+ fields: Enum.map(sf.fields, &parse/1),
+ sub_fields: Enum.map(sf.sub_fields, &parse/1),
+ conditional_fields: Enum.map(sf.conditional_fields, &parse/1)
+ }
+ end
+
+ defp parse(%ConditionalField{} = cf) do
+ %{
+ cf
+ | __domain_ops__: parse_domain(cf.domain),
+ fields: Enum.map(cf.fields, &parse/1),
+ sub_fields: Enum.map(cf.sub_fields, &parse/1),
+ conditional_fields: Enum.map(cf.conditional_fields, &parse/1)
+ }
+ end
+
+ defp parse(other), do: other
+
+ defp parse_domain(nil), do: nil
+ defp parse_domain(""), do: nil
+
+ defp parse_domain(pattern) when is_binary(pattern) do
+ pattern
+ |> String.trim()
+ |> String.split("::", trim: true)
+ |> Enum.map(&parse_rule/1)
+ |> Enum.reject(&is_nil/1)
+ end
+
+ defp parse_domain(_), do: nil
+
+ defp parse_rule(rule) do
+ case String.split(rule, "=", parts: 2) do
+ ["!" <> field_path, pattern] ->
+ %{required?: true, field_path: field_path, validator: build_validator(pattern)}
+
+ ["?" <> field_path, pattern] ->
+ %{required?: false, field_path: field_path, validator: build_validator(pattern)}
+
+ _ ->
+ nil
+ end
+ end
+
+ defp build_validator(pattern) do
+ pattern |> convert_pattern() |> OpEvaluator.rewrite_tuple()
+ end
+
+ defp convert_pattern("Tuple" <> list), do: {:enum, "Tuple[#{eval_re_structure(list)}]"}
+ defp convert_pattern("Map" <> list), do: {:enum, "Map[#{eval_re_structure(list)}]"}
+
+ defp convert_pattern("Equal" <> data) do
+ {:equal, data |> String.replace(["[", "]"], "") |> String.replace(">>", "::")}
+ end
+
+ defp convert_pattern("Either" <> list) do
+ converted =
+ list
+ |> String.replace("enum>>", "enum=")
+ |> String.replace(">>", "::")
+ |> Code.string_to_quoted!()
+ |> then(&Parser.convert_parameters("parsed_string", &1))
+
+ %{either: converted["parsed_string"]}
+ end
+
+ defp convert_pattern("Custom" <> list), do: {:custom, list}
+ defp convert_pattern(plain), do: {:enum, re_structure(plain)}
+
+ defp re_structure(data) do
+ data |> String.split(",", trim: true) |> Enum.map(&String.trim/1) |> Enum.join("::")
+ end
+
+ defp eval_re_structure(data) do
+ {converted, []} = Code.eval_string(data)
+ Enum.reduce(converted, "", fn item, acc -> acc <> "#{Macro.to_string(item)}::" end)
+ end
+end
diff --git a/lib/guarded_struct/validate.ex b/lib/guarded_struct/validate.ex
new file mode 100644
index 0000000..1bd20de
--- /dev/null
+++ b/lib/guarded_struct/validate.ex
@@ -0,0 +1,294 @@
+defmodule GuardedStruct.Validate do
+ @moduledoc """
+ Standalone validators that reuse a `GuardedStruct` schema without going
+ through the full `builder/1` pipeline.
+
+ Three tiers:
+
+ * `run/2` — derive op-string against a single value, no module needed.
+ * `field/3,4` — validate one named field of a `guardedstruct` module.
+ Cross-field dependencies (`on:`, `domain:`) honoured by mode.
+ * `partial/2` — validate a subset of fields together. Missing fields
+ skipped (no `enforce_keys` check). Useful for form-as-you-type and
+ PATCH-style endpoints.
+
+ Returns the validated value (or partial map) on success, an error list
+ with the same shape as `builder/1` on failure.
+ """
+
+ import GuardedStruct.Messages, only: [translated_message: 2]
+
+ alias GuardedStruct.Derive
+ alias GuardedStruct.Derive.{Parser, OpEvaluator, ValidationDerive}
+
+ @type error :: %{
+ required(:field) => atom(),
+ required(:action) => atom(),
+ required(:message) => String.t(),
+ optional(any()) => any()
+ }
+
+ @doc """
+ Validate a value against a derive op-string. No module needed.
+
+ iex> GuardedStruct.Validate.run("validate(string, max_len=80)", "hi")
+ {:ok, "hi"}
+ """
+ @spec run(String.t(), any()) :: {:ok, any()} | {:error, [error]}
+ def run(derive_string, value) when is_binary(derive_string) do
+ ops = derive_string |> Parser.parser() |> OpEvaluator.preevaluate()
+
+ if is_nil(ops) do
+ {:ok, value}
+ else
+ input = %{field: :__value__, derive: derive_string, derive_ops: ops}
+
+ case Derive.derive({:ok, %{__value__: value}, [input]}) do
+ {:ok, %{__value__: validated}} -> {:ok, validated}
+ {:error, errs} -> {:error, errs}
+ end
+ end
+ end
+
+ @doc """
+ Validate a single named field of a `guardedstruct` module.
+
+ ## Modes
+
+ * `:strict` (default) — honour `on:` and `domain:` core keys. Errors if
+ a cross-field dependency can't be resolved.
+ * `:isolated` — skip cross-field deps. Run only `derive:` + `validator:`.
+
+ ## Context
+
+ Pass `context: %{other_field: ...}` to provide values for cross-field
+ dependency resolution.
+ """
+ @spec field(module(), atom(), any(), keyword()) :: {:ok, any()} | {:error, [error]}
+ def field(module, field_name, value, opts \\ [])
+ when is_atom(module) and is_atom(field_name) do
+ fields = module.__fields__()
+
+ case Enum.find(fields, &(&1.name == field_name)) do
+ nil ->
+ {:error,
+ [
+ %{
+ field: field_name,
+ action: :unknown_field,
+ message: "field #{inspect(field_name)} is not defined on #{inspect(module)}"
+ }
+ ]}
+
+ meta ->
+ do_field_validate(meta, value, opts, module)
+ end
+ end
+
+ @doc """
+ Validate a partial map of fields. Missing fields are skipped (no
+ `enforce_keys` check). Cross-field deps resolve from the same input.
+ """
+ @spec partial(module(), map()) :: {:ok, map()} | {:error, [error]}
+ def partial(_module, attrs) when not is_map(attrs) do
+ {:error, [%{field: :__value__, action: :bad_parameters, message: "input must be a map"}]}
+ end
+
+ def partial(module, attrs) do
+ attrs = Parser.convert_to_atom_map(attrs)
+ fields = module.__fields__()
+ present = Enum.filter(fields, &Map.has_key?(attrs, &1.name))
+
+ {ok_acc, err_acc} =
+ Enum.reduce(present, {%{}, []}, fn meta, {ok, errs} ->
+ value = Map.get(attrs, meta.name)
+
+ case do_field_validate(meta, value, [context: attrs, mode: :strict], module) do
+ {:ok, validated} -> {Map.put(ok, meta.name, validated), errs}
+ {:error, e} -> {ok, errs ++ List.wrap(e)}
+ end
+ end)
+
+ if err_acc == [], do: {:ok, ok_acc}, else: {:error, err_acc}
+ end
+
+ defp do_field_validate(meta, value, opts, module) do
+ mode = Keyword.get(opts, :mode, :strict)
+ context = Keyword.get(opts, :context, %{})
+
+ cross_field_check =
+ case mode do
+ :isolated -> :ok
+ _ -> check_cross_field_deps(meta, context)
+ end
+
+ with :ok <- cross_field_check,
+ {:ok, sanitized} <- run_pre_derive(meta, value),
+ {:ok, validated} <- run_field_validator(meta, sanitized, module) do
+ {:ok, validated}
+ end
+ end
+
+ defp check_cross_field_deps(meta, context) do
+ errors = []
+
+ errors =
+ case Map.get(meta, :__on_path__) || parse_path(Map.get(meta, :on)) do
+ nil ->
+ errors
+
+ path ->
+ if path_present?(path, context) do
+ errors
+ else
+ errors ++
+ [
+ %{
+ field: meta.name,
+ action: :dependent_keys,
+ message: translated_message(:check_dependent_keys, {meta.name, path})
+ }
+ ]
+ end
+ end
+
+ errors =
+ case Map.get(meta, :__domain_ops__) do
+ nil ->
+ errors
+
+ rules ->
+ rules
+ |> Enum.flat_map(fn rule -> run_domain_rule(rule, meta.name, context) end)
+ |> Kernel.++(errors)
+ end
+
+ case errors do
+ [] -> :ok
+ errs -> {:error, errs}
+ end
+ end
+
+ defp parse_path(nil), do: nil
+ defp parse_path(""), do: nil
+ defp parse_path(str) when is_binary(str), do: Parser.parse_core_keys_pattern(str)
+ defp parse_path(_), do: nil
+
+ defp path_present?([:root | rest], context) do
+ not is_nil(get_in(context, rest))
+ end
+
+ defp path_present?(path, context) do
+ not is_nil(get_in(context, path))
+ end
+
+ defp run_domain_rule(
+ %{field_path: field_path, validator: validator, required?: required?},
+ key,
+ context
+ ) do
+ target =
+ field_path
+ |> String.split(".", trim: true)
+ |> Enum.map(&String.to_existing_atom/1)
+ |> then(&get_in(context, &1))
+
+ cond do
+ not is_nil(target) ->
+ case ValidationDerive.validate(validator, target, key) do
+ data when is_tuple(data) and elem(data, 0) == :error ->
+ [
+ %{
+ field: key,
+ action: :domain_parameters,
+ message: translated_message(:domain_field_status, key)
+ }
+ ]
+
+ _ ->
+ []
+ end
+
+ not required? ->
+ []
+
+ true ->
+ [
+ %{
+ field: key,
+ action: :domain_parameters,
+ message: translated_message(:force_domain_field_status, key)
+ }
+ ]
+ end
+ rescue
+ _ -> []
+ end
+
+ defp run_pre_derive(%{__derive_ops__: ops, name: name}, value)
+ when is_map(ops) and map_size(ops) > 0 do
+ input = %{field: name, derive_ops: ops}
+
+ case Derive.derive({:ok, %{name => value}, [input]}) do
+ {:ok, %{^name => validated}} -> {:ok, validated}
+ {:error, errs} -> {:error, errs}
+ end
+ end
+
+ defp run_pre_derive(%{derive: str, name: name}, value) when is_binary(str) do
+ ops = str |> Parser.parser() |> OpEvaluator.preevaluate()
+
+ if is_nil(ops) do
+ {:ok, value}
+ else
+ input = %{field: name, derive: str, derive_ops: ops}
+
+ case Derive.derive({:ok, %{name => value}, [input]}) do
+ {:ok, %{^name => validated}} -> {:ok, validated}
+ {:error, errs} -> {:error, errs}
+ end
+ end
+ end
+
+ defp run_pre_derive(_meta, value), do: {:ok, value}
+
+ defp run_field_validator(%{validator: {mod, fun}, name: name}, value, _module)
+ when is_atom(mod) and is_atom(fun) do
+ case apply(mod, fun, [name, value]) do
+ {:ok, _, validated} ->
+ {:ok, validated}
+
+ {:ok, validated} ->
+ {:ok, validated}
+
+ {:error, _, message} ->
+ {:error, [%{field: name, action: :validator, message: message}]}
+
+ {:error, message} ->
+ {:error, [%{field: name, action: :validator, message: message}]}
+
+ _ ->
+ {:ok, value}
+ end
+ end
+
+ defp run_field_validator(%{name: name}, value, module) do
+ if function_exported?(module, :validator, 2) do
+ case apply(module, :validator, [name, value]) do
+ {:ok, _, validated} ->
+ {:ok, validated}
+
+ {:ok, validated} ->
+ {:ok, validated}
+
+ {:error, _, message} ->
+ {:error, [%{field: name, action: :validator, message: message}]}
+
+ _ ->
+ {:ok, value}
+ end
+ else
+ {:ok, value}
+ end
+ end
+end
diff --git a/lib/guarded_struct/verifiers/verify_atomic.ex b/lib/guarded_struct/verifiers/verify_atomic.ex
new file mode 100644
index 0000000..57aa250
--- /dev/null
+++ b/lib/guarded_struct/verifiers/verify_atomic.ex
@@ -0,0 +1,232 @@
+defmodule GuardedStruct.Verifiers.VerifyAtomic do
+ @moduledoc """
+ Compile-time verifier that rejects `atomic: true` resources whose
+ derive ops can't translate to atomic SQL.
+
+ Runs only when the section's `atomic` option is `true`. Walks every
+ field/sub_field/conditional_field/virtual_field, classifies each op
+ via `GuardedStruct.AtomicClassifier`, and aggregates blockers. If any
+ found, raises `Spark.Error.DslError` with one bullet per blocker.
+
+ One pattern-match clause per entity type — contributors extending the
+ DSL just add a new `check_entity/2` clause (or add a classifier rule
+ in `GuardedStruct.AtomicClassifier`).
+ """
+
+ use Spark.Dsl.Verifier
+
+ alias Spark.Dsl.Verifier
+ alias GuardedStruct.AtomicClassifier
+ alias GuardedStruct.Dsl.{Field, SubField, ConditionalField, VirtualField}
+
+ @impl true
+ def verify(dsl_state) do
+ if atomic_opted_in?(dsl_state) do
+ do_verify(dsl_state)
+ else
+ :ok
+ end
+ end
+
+ defp atomic_opted_in?(dsl_state) do
+ Verifier.get_option(dsl_state, [:guardedstruct], :atomic, false) == true
+ end
+
+ defp do_verify(dsl_state) do
+ entities = Verifier.get_entities(dsl_state, [:guardedstruct])
+ module = Verifier.get_persisted(dsl_state, :module)
+ main_validator_opt = Verifier.get_option(dsl_state, [:guardedstruct], :main_validator)
+
+ blockers =
+ collect_entities(entities, []) ++
+ check_main_validator_opt(main_validator_opt) ++
+ check_main_validator_callback(module)
+
+ case blockers do
+ [] -> :ok
+ _ -> {:error, build_error(module, blockers)}
+ end
+ end
+
+ defp collect_entities(entities, path) do
+ Enum.flat_map(entities, &check_entity(&1, path))
+ end
+
+ defp check_entity(%Field{} = f, path) do
+ field_path = path ++ [f.name]
+
+ check_ops(f.__derive_ops__, field_path) ++
+ check_validator(f.validator, field_path) ++
+ check_auto(f.auto, field_path) ++
+ check_cross_field(f, field_path)
+ end
+
+ defp check_entity(%SubField{} = sf, path) do
+ field_path = path ++ [sf.name]
+
+ inner = collect_entities(sf.fields ++ sf.sub_fields ++ sf.conditional_fields, field_path)
+
+ check_ops(sf.__derive_ops__, field_path) ++
+ check_validator(sf.validator, field_path) ++
+ check_auto(sf.auto, field_path) ++
+ check_sub_main_validator(sf.main_validator, field_path) ++
+ check_cross_field(sf, field_path) ++
+ inner
+ end
+
+ defp check_entity(%ConditionalField{} = cf, path) do
+ field_path = path ++ [cf.name]
+
+ inner = collect_entities(cf.fields ++ cf.sub_fields ++ cf.conditional_fields, field_path)
+
+ check_ops(cf.__derive_ops__, field_path) ++
+ check_validator(cf.validator, field_path) ++
+ check_auto(cf.auto, field_path) ++
+ check_cross_field(cf, field_path) ++
+ inner
+ end
+
+ defp check_entity(%VirtualField{} = vf, path) do
+ field_path = path ++ [vf.name]
+
+ check_ops(vf.__derive_ops__, field_path) ++
+ check_validator(vf.validator, field_path) ++
+ check_auto(vf.auto, field_path) ++
+ check_cross_field(vf, field_path)
+ end
+
+ defp check_entity(other, path) do
+ [
+ {path, "unknown entity #{inspect(other)} cannot be classified for atomic mode"}
+ ]
+ end
+
+ defp check_ops(nil, _path), do: []
+
+ defp check_ops(ops, path) when is_map(ops) do
+ sanitize_ops = Map.get(ops, :sanitize, []) |> Enum.map(&{:sanitize, &1})
+ validate_ops = Map.get(ops, :validate, []) |> Enum.map(&{:validate, &1})
+
+ Enum.flat_map(sanitize_ops ++ validate_ops, fn op ->
+ case AtomicClassifier.classify_op(op) do
+ :safe -> []
+ {:unsafe, reason} -> [{path, reason}]
+ end
+ end)
+ end
+
+ defp check_ops(_other, _path), do: []
+
+ defp check_validator(nil, _path), do: []
+
+ defp check_validator({mod, fun}, path) do
+ [
+ {path,
+ "per-field `validator: {#{inspect(mod)}, :#{fun}}` runs arbitrary " <>
+ "Elixir — no SQL equivalent. Move the rule into a `derives:` " <>
+ "string with built-in atomic-safe ops, or set atomic: false"}
+ ]
+ end
+
+ defp check_auto(nil, _path), do: []
+
+ defp check_auto({mod, fun}, path) do
+ [
+ {path,
+ "`auto: {#{inspect(mod)}, :#{fun}}` runs arbitrary Elixir to " <>
+ "compute the value. The data layer can't invoke user-defined " <>
+ "Elixir mid-transaction"}
+ ]
+ end
+
+ defp check_sub_main_validator(nil, _path), do: []
+
+ defp check_sub_main_validator({mod, fun}, path) do
+ [
+ {path,
+ "sub_field-level `main_validator: {#{inspect(mod)}, :#{fun}}` " <>
+ "runs arbitrary Elixir across the sub_field's children"}
+ ]
+ end
+
+ defp check_cross_field(entity, path) do
+ cond do
+ Map.get(entity, :on) ->
+ [
+ {path,
+ "uses cross-field `on:` dependency, which requires reading " <>
+ "another field's value during validation — not expressible " <>
+ "as a single atomic SQL statement"}
+ ]
+
+ Map.get(entity, :from) ->
+ [
+ {path,
+ "uses `from:` cross-field reference, which copies a value " <>
+ "from another path at runtime — not atomic-safe"}
+ ]
+
+ Map.get(entity, :domain) ->
+ [
+ {path,
+ "uses `domain:` constraint, which depends on another field's " <>
+ "value — not atomic-safe"}
+ ]
+
+ true ->
+ []
+ end
+ end
+
+ defp check_main_validator_opt(nil), do: []
+
+ defp check_main_validator_opt({mod, fun}) do
+ [
+ {[:__section__],
+ "section option `main_validator: {#{inspect(mod)}, :#{fun}}` " <>
+ "runs arbitrary cross-field Elixir after all field validations"}
+ ]
+ end
+
+ defp check_main_validator_callback(module) when is_atom(module) do
+ if function_exported?(module, :main_validator, 1) do
+ [
+ {[:__module__],
+ "module #{inspect(module)} defines a `main_validator/1` callback. " <>
+ "Cross-field validation runs arbitrary Elixir after all field " <>
+ "validations and has no SQL equivalent"}
+ ]
+ else
+ []
+ end
+ end
+
+ defp check_main_validator_callback(_), do: []
+
+ defp build_error(module, blockers) do
+ formatted_blockers =
+ blockers
+ |> Enum.map(fn {path, reason} -> " * #{format_path(path)}: #{reason}" end)
+ |> Enum.join("\n")
+
+ Spark.Error.DslError.exception(
+ path: [:guardedstruct, :atomic],
+ message: """
+ `atomic: true` was set on #{inspect(module)}, but the resource has
+ ops that cannot run in atomic SQL mode. Either set `atomic: false`
+ (the default), drop the offending ops, or use a separate action
+ that doesn't require atomic.
+
+ Blockers:
+ #{formatted_blockers}
+
+ See `GuardedStruct.AtomicClassifier` for the full list of
+ atomic-safe ops.
+ """
+ )
+ end
+
+ defp format_path([:__section__]), do: "(section option)"
+ defp format_path([:__module__]), do: "(module callback)"
+ defp format_path(path), do: path |> Enum.map(&inspect/1) |> Enum.join(".")
+end
diff --git a/lib/guarded_struct/verifiers/verify_auto_mfa.ex b/lib/guarded_struct/verifiers/verify_auto_mfa.ex
new file mode 100644
index 0000000..ba04411
--- /dev/null
+++ b/lib/guarded_struct/verifiers/verify_auto_mfa.ex
@@ -0,0 +1,73 @@
+defmodule GuardedStruct.Verifiers.VerifyAutoMFA do
+ @moduledoc false
+
+ # Post-compile check: every `auto: {Mod, :fn}` (or `{Mod, :fn, default}`)
+ # MFA must exist. Same rationale as VerifyValidatorMFA — runs after compile
+ # to avoid forcing user modules into the compile graph.
+
+ use Spark.Dsl.Verifier
+
+ alias Spark.Dsl.Verifier
+ alias GuardedStruct.Dsl.{Field, SubField, ConditionalField}
+
+ @impl true
+ def verify(dsl_state) do
+ module = Verifier.get_persisted(dsl_state, :module)
+ entities = Verifier.get_entities(dsl_state, [:guardedstruct])
+
+ case walk(entities, []) do
+ [] ->
+ :ok
+
+ [{field_name, mod, fun, arity} | _] ->
+ {:error,
+ Spark.Error.DslError.exception(
+ message:
+ "auto #{inspect(mod)}.#{fun}/#{arity} not exported (declared on field #{inspect(field_name)})",
+ path: [:guardedstruct, :field, field_name, :auto],
+ module: module
+ )}
+ end
+ end
+
+ defp walk(entities, errors) do
+ Enum.reduce(entities, errors, fn entity, acc ->
+ acc =
+ case Map.get(entity, :auto) do
+ {mod, fun} when is_atom(mod) and is_atom(fun) ->
+ check(entity.name, mod, fun, 0, acc)
+
+ {mod, fun, arg} when is_atom(mod) and is_atom(fun) ->
+ arity = if is_list(arg), do: length(arg), else: 1
+ check(entity.name, mod, fun, arity, acc)
+
+ _ ->
+ acc
+ end
+
+ case entity do
+ %SubField{} = sf ->
+ walk(sf.fields ++ sf.sub_fields ++ sf.conditional_fields, acc)
+
+ %ConditionalField{} = cf ->
+ walk(cf.fields ++ cf.sub_fields ++ cf.conditional_fields, acc)
+
+ %Field{} ->
+ acc
+
+ _ ->
+ acc
+ end
+ end)
+ end
+
+ defp check(field, mod, fun, arity, acc) do
+ Code.ensure_loaded(mod)
+
+ if function_exported?(mod, fun, arity) do
+ acc
+ else
+ [{field, mod, fun, arity} | acc]
+ end
+ end
+end
diff --git a/lib/guarded_struct/verifiers/verify_no_struct_cycles.ex b/lib/guarded_struct/verifiers/verify_no_struct_cycles.ex
new file mode 100644
index 0000000..a0c5b9e
--- /dev/null
+++ b/lib/guarded_struct/verifiers/verify_no_struct_cycles.ex
@@ -0,0 +1,96 @@
+defmodule GuardedStruct.Verifiers.VerifyNoStructCycles do
+ @moduledoc false
+
+ use Spark.Dsl.Verifier
+
+ alias GuardedStruct.Dsl.{Field, SubField, ConditionalField}
+
+ @impl true
+ def verify(dsl_state) do
+ module = Spark.Dsl.Verifier.get_persisted(dsl_state, :module)
+ entities = Spark.Dsl.Verifier.get_entities(dsl_state, [:guardedstruct])
+
+ walk(module, entities, MapSet.new([module]))
+ :ok
+ end
+
+ defp walk(origin, entities, visited) do
+ Enum.each(entities, fn entity -> walk_entity(origin, entity, visited) end)
+ end
+
+ defp walk_entity(origin, %Field{struct: target}, visited)
+ when is_atom(target) and not is_nil(target) do
+ visit(origin, target, visited)
+ end
+
+ defp walk_entity(origin, %Field{structs: target}, visited)
+ when is_atom(target) and target not in [nil, true, false] do
+ visit(origin, target, visited)
+ end
+
+ defp walk_entity(origin, %SubField{} = sf, visited) do
+ children = sf.fields ++ sf.sub_fields ++ sf.conditional_fields
+ walk(origin, children, visited)
+ end
+
+ defp walk_entity(origin, %ConditionalField{} = cf, visited) do
+ children = cf.fields ++ cf.sub_fields ++ cf.conditional_fields
+ walk(origin, children, visited)
+ end
+
+ defp walk_entity(_origin, _other, _visited), do: :ok
+
+ defp visit(origin, target, visited) do
+ cond do
+ target == origin ->
+ raise_cycle(origin, [target])
+
+ MapSet.member?(visited, target) ->
+ :ok
+
+ not function_exported?(target, :__fields__, 0) ->
+ :ok
+
+ true ->
+ target_fields = target.__fields__()
+
+ Enum.each(target_fields, fn meta ->
+ case {Map.get(meta, :struct), Map.get(meta, :structs)} do
+ {nil, nil} ->
+ :ok
+
+ {next, _} when is_atom(next) and not is_nil(next) ->
+ if next == origin, do: raise_cycle(origin, [target, next])
+ visit_via(origin, next, MapSet.put(visited, target))
+
+ {_, next} when is_atom(next) and next not in [nil, true, false] ->
+ if next == origin, do: raise_cycle(origin, [target, next])
+ visit_via(origin, next, MapSet.put(visited, target))
+
+ _ ->
+ :ok
+ end
+ end)
+ end
+ end
+
+ defp visit_via(origin, next, visited) do
+ cond do
+ MapSet.member?(visited, next) -> :ok
+ not function_exported?(next, :__fields__, 0) -> :ok
+ true -> visit(origin, next, visited)
+ end
+ end
+
+ defp raise_cycle(origin, chain) do
+ rendered = [origin | chain] |> Enum.map(&inspect/1) |> Enum.join(" → ")
+
+ raise Spark.Error.DslError,
+ message:
+ "module reference cycle detected: #{rendered}.\n" <>
+ "Two GuardedStruct modules cannot reference each other via `struct:` " <>
+ "or `structs:` — building one would recursively build the other forever.\n" <>
+ "Break the cycle by replacing one direction with a non-struct field " <>
+ "(e.g. an id reference) and resolving the relation at runtime."
+ end
+end
diff --git a/lib/guarded_struct/verifiers/verify_validator_mfa.ex b/lib/guarded_struct/verifiers/verify_validator_mfa.ex
new file mode 100644
index 0000000..609336f
--- /dev/null
+++ b/lib/guarded_struct/verifiers/verify_validator_mfa.ex
@@ -0,0 +1,66 @@
+defmodule GuardedStruct.Verifiers.VerifyValidatorMFA do
+ @moduledoc false
+
+ # Post-compile check: every `validator: {Mod, :fn}` MFA on every field must
+ # exist. Verifiers run AFTER the user's module is fully compiled, so we can
+ # `Code.ensure_loaded?` user code without dragging it into the compile-time
+ # dependency graph.
+
+ use Spark.Dsl.Verifier
+
+ alias Spark.Dsl.Verifier
+ alias GuardedStruct.Dsl.{Field, SubField, ConditionalField}
+
+ @impl true
+ def verify(dsl_state) do
+ module = Verifier.get_persisted(dsl_state, :module)
+ entities = Verifier.get_entities(dsl_state, [:guardedstruct])
+
+ case walk(entities, []) do
+ [] ->
+ :ok
+
+ [{field_name, mod, fun} | _] ->
+ {:error,
+ Spark.Error.DslError.exception(
+ message:
+ "validator #{inspect(mod)}.#{fun}/2 not exported (declared on field #{inspect(field_name)})",
+ path: [:guardedstruct, :field, field_name, :validator],
+ module: module
+ )}
+ end
+ end
+
+ defp walk(entities, errors) do
+ Enum.reduce(entities, errors, fn entity, acc ->
+ acc =
+ case Map.get(entity, :validator) do
+ {mod, fun} when is_atom(mod) and is_atom(fun) ->
+ Code.ensure_loaded(mod)
+
+ if function_exported?(mod, fun, 2) do
+ acc
+ else
+ [{entity.name, mod, fun} | acc]
+ end
+
+ _ ->
+ acc
+ end
+
+ case entity do
+ %SubField{} = sf ->
+ walk(sf.fields ++ sf.sub_fields ++ sf.conditional_fields, acc)
+
+ %ConditionalField{} = cf ->
+ walk(cf.fields ++ cf.sub_fields ++ cf.conditional_fields, acc)
+
+ %Field{} ->
+ acc
+
+ _ ->
+ acc
+ end
+ end)
+ end
+end
diff --git a/lib/messages.ex b/lib/messages.ex
index caf7550..f442547 100644
--- a/lib/messages.ex
+++ b/lib/messages.ex
@@ -41,9 +41,6 @@ defmodule GuardedStruct.Messages do
@callback check_dependent_keys({any(), any()}) :: message()
@callback domain_field_status(any()) :: message()
@callback force_domain_field_status(any()) :: message()
- # Parser
- @callback parser_field_value() :: message()
- @callback unsupported_conditional_field() :: message()
# ValidationDerive
@callback not_empty_binary(any()) :: message()
@callback not_empty_list(any()) :: message()
@@ -92,6 +89,7 @@ defmodule GuardedStruct.Messages do
@callback is_type({any(), any()}) :: message()
@callback convert_enum_output(any()) :: message()
@callback equal(any()) :: message()
+ @callback record(any()) :: message()
@optional_callbacks required_fields: 0,
authorized_fields: 0,
@@ -107,8 +105,6 @@ defmodule GuardedStruct.Messages do
check_dependent_keys: 1,
domain_field_status: 1,
force_domain_field_status: 1,
- parser_field_value: 0,
- unsupported_conditional_field: 0,
not_empty_binary: 1,
not_empty_list: 1,
not_empty_map: 1,
@@ -155,7 +151,8 @@ defmodule GuardedStruct.Messages do
location_url: 1,
is_type: 1,
convert_enum_output: 1,
- equal: 1
+ equal: 1,
+ record: 1
@doc false
# Get idea from https://github.com/pow-auth/pow/blob/main/lib/pow/phoenix/messages.ex
@@ -177,10 +174,6 @@ defmodule GuardedStruct.Messages do
def domain_field_status(key), do: unquote(__MODULE__).domain_field_status(key)
def force_domain_field_status(key), do: unquote(__MODULE__).force_domain_field_status(key)
- # Parser
- def unsupported_conditional_field(), do: unquote(__MODULE__).unsupported_conditional_field()
- def parser_field_value(), do: unquote(__MODULE__).parser_field_value()
-
# ValidationDerive
def not_empty_binary(field), do: unquote(__MODULE__).not_empty_binary(field)
def not_empty_list(field), do: unquote(__MODULE__).not_empty_list(field)
@@ -229,6 +222,7 @@ defmodule GuardedStruct.Messages do
def is_type(field), do: unquote(__MODULE__).is_type(field)
def convert_enum_output(field), do: unquote(__MODULE__).convert_enum_output(field)
def equal(field), do: unquote(__MODULE__).equal(field)
+ def record(field), do: unquote(__MODULE__).record(field)
defoverridable unquote(__MODULE__)
end
@@ -280,22 +274,6 @@ defmodule GuardedStruct.Messages do
def force_domain_field_status(key),
do: "Based on field #{key} input you have to send authorized data and required key"
- # Parser
- def unsupported_conditional_field() do
- """
- \n ----------------------------------------------------------\n
- Unfortunately, this macro does not support the nested mode in the conditional_field macro.
- If you can add this feature I would be very happy to send a PR.
- More information: https://github.com/mishka-group/guarded_struct/issues/7
- Parent Issue: https://github.com/mishka-group/guarded_struct/issues/8
- \n ----------------------------------------------------------\n
- """
- end
-
- def parser_field_value(),
- do:
- "Oh no!, I think you have not made all the subfields of a conditional field to the same name"
-
# ValidationDerive
def not_empty_binary(field), do: "The #{field} field must not be empty"
def not_empty_list(field), do: "The #{field} field must not be empty"
@@ -413,6 +391,10 @@ defmodule GuardedStruct.Messages do
def equal(field), do: "Invalid value in the #{field} field"
+ def record(field) do
+ "The #{field} field is not a valid Erlang record (a tagged tuple)."
+ end
+
# Helpers
def translated_message(fn_atom), do: apply(@message_backend, fn_atom, [])
diff --git a/lib/mix/tasks/guarded_struct.install.ex b/lib/mix/tasks/guarded_struct.install.ex
new file mode 100644
index 0000000..3ae9de4
--- /dev/null
+++ b/lib/mix/tasks/guarded_struct.install.ex
@@ -0,0 +1,87 @@
+if Code.ensure_loaded?(Igniter) do
+ defmodule Mix.Tasks.GuardedStruct.Install do
+ @example "mix igniter.install guarded_struct"
+ @shortdoc "One-command project setup for guarded_struct"
+
+ @moduledoc """
+ #{@shortdoc}
+
+ ## Example
+
+ ```sh
+ #{@example}
+ ```
+
+ ## What it does
+
+ 1. Adds `{:guarded_struct, "~> 0.1.0"}` to `mix.exs` deps (if not already)
+ 2. Registers a `lint` alias chaining `mix spark.formatter` then `mix format`
+ 3. Seeds `config :guarded_struct, derive_extensions: []` in `config/config.exs`
+ so users have an obvious place to plug in custom validators
+ """
+
+ use Igniter.Mix.Task
+
+ @impl Igniter.Mix.Task
+ def info(_argv, _composing_task) do
+ %Igniter.Mix.Task.Info{
+ group: :guarded_struct,
+ example: @example,
+ positional: [],
+ schema: [],
+ defaults: []
+ }
+ end
+
+ @impl Igniter.Mix.Task
+ def igniter(igniter) do
+ igniter
+ |> Igniter.Project.TaskAliases.add_alias("lint", ["spark.formatter", "format"])
+ |> Igniter.Project.Config.configure_new(
+ "config.exs",
+ :guarded_struct,
+ [:derive_extensions],
+ []
+ )
+ |> Igniter.add_notice("""
+ guarded_struct installed.
+
+ Quick start — add to any module:
+
+ defmodule MyApp.User do
+ use GuardedStruct
+
+ guardedstruct do
+ field :name, String.t(), enforce: true,
+ derives: "sanitize(trim) validate(string, max_len=80)"
+ field :email, String.t(), enforce: true,
+ derives: "validate(email_r)"
+ end
+ end
+
+ Then call MyApp.User.builder(%{name: "Alice", email: "alice@example.com"}).
+ See https://hexdocs.pm/guarded_struct for the full guide.
+ """)
+ end
+ end
+else
+ defmodule Mix.Tasks.GuardedStruct.Install do
+ @shortdoc "One-command project setup for guarded_struct | Install `igniter` to use"
+ @moduledoc @shortdoc
+
+ use Mix.Task
+
+ @impl Mix.Task
+ def run(_argv) do
+ Mix.shell().error("""
+ The task 'guarded_struct.install' requires igniter. Add to your `mix.exs`:
+
+ {:igniter, "~> 0.7", only: [:dev, :test]}
+
+ and run `mix deps.get`.
+ """)
+
+ exit({:shutdown, 1})
+ end
+ end
+end
diff --git a/mix.exs b/mix.exs
index de08ae5..0472880 100644
--- a/mix.exs
+++ b/mix.exs
@@ -1,7 +1,7 @@
defmodule GuardedStruct.MixProject do
use Mix.Project
- @version "0.0.5"
+ @version "0.1.0-beta.2"
@source_url "https://github.com/mishka-group/guarded_struct"
def project do
@@ -11,17 +11,29 @@ defmodule GuardedStruct.MixProject do
elixir: "~> 1.17",
name: "GuardedStruct",
elixirc_paths: elixirc_paths(Mix.env()),
+ consolidate_protocols: Mix.env() != :test,
start_permanent: Mix.env() == :prod,
deps: deps(),
+ aliases: aliases(),
description: description(),
package: package(),
source_url: @source_url,
- docs: [
- main: "readme",
- source_ref: "v#{@version}",
- extras: ["README.md", "CHANGELOG.md"],
- source_url: @source_url
- ]
+ docs: docs()
+ ]
+ end
+
+ # Spark mix tasks require `--extensions` as a CLI flag (no config-file
+ # path for it). We pin the list ONCE here so both spark.formatter and
+ # spark.cheat_sheets pick it up automatically — and so a short alias
+ # like `mix lint` / `mix cheat` works.
+ @spark_extensions "GuardedStruct.Dsl,GuardedStruct.AshResource,GuardedStruct.Derive.Extension.Dsl"
+
+ defp aliases do
+ [
+ "spark.formatter": "spark.formatter --extensions #{@spark_extensions}",
+ "spark.cheat_sheets": "spark.cheat_sheets --extensions #{@spark_extensions}",
+ lint: ["spark.formatter", "format"],
+ cheat: ["spark.cheat_sheets"]
]
end
@@ -33,36 +45,104 @@ defmodule GuardedStruct.MixProject do
defp elixirc_paths(_), do: ["lib"]
defp description() do
- "GuardedStruct macro allows to build Structs that provide you with a number of important options Validation, Sanitizing, Constructor"
+ "Build Elixir structs with validation, sanitization, nested sub-structs, " <>
+ "conditional fields, pattern-keyed maps, and an Ash extension. " <>
+ "Built on Spark."
end
defp package() do
[
- files: ~w(lib .formatter.exs mix.exs LICENSE README*),
+ files: ~w(lib .formatter.exs mix.exs LICENSE README* CHANGELOG* SECURITY*),
licenses: ["Apache-2.0"],
maintainers: ["Shahryar Tavakkoli"],
links: %{
"Mishka" => "https://mishka.tools",
"GitHub" => @source_url,
"Changelog" => "#{@source_url}/blob/master/CHANGELOG.md",
+ "Security policy" => "#{@source_url}/blob/master/SECURITY.md",
"LiveBook document" => "#{@source_url}/blob/master/guidance/guarded-struct.livemd"
}
]
end
+ defp docs do
+ [
+ main: "readme",
+ source_ref: "v#{@version}",
+ source_url: @source_url,
+ extras: [
+ "README.md",
+ "CHANGELOG.md",
+ "documentation/dsls/DSL-GuardedStruct.md",
+ "documentation/dsls/DSL-GuardedStruct.AshResource.md",
+ "documentation/dsls/DSL-GuardedStruct.Derive.Extension.md"
+ ],
+ groups_for_extras: [
+ "DSL Reference": ~r"^documentation/dsls/.*"
+ ],
+ groups_for_modules: [
+ Core: [GuardedStruct, GuardedStruct.Info],
+ Validation: [GuardedStruct.Validate],
+ "Errors (Splode)": [
+ GuardedStruct.Errors,
+ GuardedStruct.Errors.Validation,
+ GuardedStruct.Errors.Invalid,
+ GuardedStruct.Errors.Unknown
+ ],
+ Extensions: [
+ GuardedStruct.Derive.Extension,
+ GuardedStruct.AshResource,
+ GuardedStruct.AshResource.Info
+ ],
+ i18n: [GuardedStruct.Messages]
+ ],
+ nest_modules_by_prefix: [
+ GuardedStruct.Errors,
+ GuardedStruct.Derive,
+ GuardedStruct.Dsl,
+ GuardedStruct.Transformers,
+ GuardedStruct.Verifiers
+ ]
+ ]
+ end
+
defp deps do
[
# necessary
+ {:spark, "~> 2.7"},
+ {:splode, "~> 0.3"},
+ {:telemetry, "~> 1.0"},
{:html_sanitize_ex, "~> 1.5"},
+ # required by Spark.Formatter for `mix format` and `mix spark.formatter`
+ {:sourceror, "~> 1.7", only: [:dev, :test]},
# document
{:ex_doc, "~> 0.40.1", only: :dev, runtime: false},
+ # property-based testing
+ {:stream_data, "~> 1.1", only: [:dev, :test]},
+
+ # tested when jason: true is opted into; the lib itself doesn't depend
+ # on Jason — Code.ensure_loaded?(Jason.Encoder) gates the @derive.
+ {:jason, "~> 1.4", only: [:dev, :test]},
+
# test env
{:email_checker, "~> 0.2.4", optional: true, only: :test},
{:ex_url, "~> 2.0.2", optional: true, only: :test},
{:ex_phone_number, "~> 0.4.11", optional: true, only: :test},
{:sweet_xml,
- github: "kbrw/sweet_xml", branch: "master", override: true, optional: true, only: :test}
+ github: "kbrw/sweet_xml", branch: "master", override: true, optional: true, only: :test},
+ {:igniter, "~> 0.8.0", only: [:dev, :test]},
+
+ # Real Ash for integration tests. We're a compile-time DSL extension
+ # so we don't dep on Ash at runtime; this is only for verifying that
+ # our extension actually works end-to-end with real Ash + the ETS
+ # data layer (no DB needed). Existing FakeFramework tests still cover
+ # the no-Ash path.
+ #
+ # `:dev, :test` (not just `:test`) so `mix format` in dev can pick up
+ # Ash's `.formatter.exs` via `import_deps: [..., :ash]` and keep Ash
+ # DSL calls paren-free.
+ {:ash, "~> 3.0", only: [:dev, :test]}
]
end
end
diff --git a/mix.lock b/mix.lock
index b5959f9..3a461ee 100644
--- a/mix.lock
+++ b/mix.lock
@@ -1,14 +1,48 @@
%{
+ "ash": {:hex, :ash, "3.24.7", "6e2f32011e7c8f0809dae36712ccfb2efaf3c669cbda7443685436e80acdebf7", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c9fb4d21c3c8bb85636338d448afdc283dd98a433d869e4b2210ac57ade00624"},
+ "benchee": {:hex, :benchee, "1.5.0", "4d812c31d54b0ec0167e91278e7de3f596324a78a096fd3d0bea68bb0c513b10", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.1", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "5b075393aea81b8ae74eadd1c28b1d87e8a63696c649d8293db7c4df3eb67535"},
+ "crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
+ "decimal": {:hex, :decimal, "3.1.0", "9ede268cff827e6f0c4fb1b34747c82630dce5d7b877dfb22ec8f0cb25855fce", [:mix], [], "hexpm", "e8b3efb3bb3a13cb5e4268ffe128569067b1972e9dee013537c71a5b073168f9"},
+ "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"},
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
+ "ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"},
"email_checker": {:hex, :email_checker, "0.2.4", "2bf246646678c8a366f2f6d2394845facb87c025ceddbd699019d387726548aa", [:mix], [{:socket, "~> 0.3.1", [hex: :socket, repo: "hexpm", optional: true]}], "hexpm", "e4ac0e5eb035dce9c8df08ebffdb525a5d82e61dde37390ac2469222f723e50a"},
+ "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
+ "ex_ast": {:hex, :ex_ast, "0.11.0", "840530d164ae9e937fbb04536eb3a25376b19145d037eca2f99cde5501b0d2f1", [:mix], [{:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "f4232f8d37f204ed27b086cb35edf3b681e588642b4bd838141835f654a69f37"},
"ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"},
"ex_phone_number": {:hex, :ex_phone_number, "0.4.11", "89f3f96f4b4c1404ae89b8a2f24397fd353a1d0d4b7dd39b2a633a23a4cf82b5", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "cefa61b4fd4f946a1813f19fcfce1370907d31261716fb7e7d04da775ad5d9c6"},
"ex_url": {:hex, :ex_url, "2.0.2", "63f96d7c878bfe1c1e1cecaa7f32dc3d00e648bf4100c4d8871f2646c00d0b3a", [:mix], [{:ex_cldr, "~> 2.18", [hex: :ex_cldr, repo: "hexpm", optional: true]}, {:ex_phone_number, "~> 0.1", [hex: :ex_phone_number, repo: "hexpm", optional: true]}, {:gettext, "~> 0.13 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, ">= 1.4.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "493ca49b9b5bdd7cc7986829687a2efed9463165dfe746ca2fa11012b81e5999"},
+ "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
+ "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
+ "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"html_sanitize_ex": {:hex, :html_sanitize_ex, "1.5.0", "ea13a4a92ba0fa17bc6199f1bb7b755a8595ec3b5f763330ea8570d8b5f648e4", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "4eaa2205ae56fab95d0f25065d709b05f0cba730f3fcec184dfde594acdd4578"},
+ "igniter": {:hex, :igniter, "0.8.0", "c7cab589440e5f20ff68e00f60eb094378114dab3105c0784ce8140f8dfdd2c0", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "fcd99096fde4797f7b48bebddcfc58785569acd696346a3eb385bf813f47a7cc"},
+ "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
+ "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
+ "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"},
+ "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
+ "mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"},
"mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"},
+ "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
+ "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
+ "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
+ "reactor": {:hex, :reactor, "1.0.1", "ca3b5cf3c04ec8441e67ea2625d0294939822060b1bfd00ffdaaf75b7682d991", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3497db2b204c9a3cabdaf1b26d2405df1dfbb138ce0ce50e616e9db19fec0043"},
+ "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
+ "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"},
+ "sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"},
+ "spark": {:hex, :spark, "2.7.0", "e685b33c038f12851993880bb7e3b326117612eb746fe15828678c152f8321c6", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "e2f675fbda32375b01d9ee7c652671531027fd043bf4a91bafdb2ab716aa1122"},
+ "spitfire": {:hex, :spitfire, "0.3.11", "79dfcb033762470de472c1c26ea2b4e3aca74700c685dbffd9a13466272c323d", [:mix], [], "hexpm", "eb6e2dadf63214e8bfe65ca9788cef2b03b01027365d78d3c0e3d9ebd3d5b7b4"},
+ "splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"},
+ "statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"},
+ "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
"sweet_xml": {:git, "https://github.com/kbrw/sweet_xml.git", "e2824e9051c50650cdb7cc6a9b4d31bfe215917c", [branch: "master"]},
+ "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
+ "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
+ "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
+ "yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"},
+ "ymlr": {:hex, :ymlr, "5.1.5", "0b9207c7940be3f2bc29b77cd55109d5aa2f4dcde6575942017335769e6f5628", [:mix], [], "hexpm", "7030cb240c46850caeb3b01be745307632be319b15f03083136f6251f49b516d"},
}
diff --git a/test/ash_integration_test.exs b/test/ash_integration_test.exs
new file mode 100644
index 0000000..61e7971
--- /dev/null
+++ b/test/ash_integration_test.exs
@@ -0,0 +1,797 @@
+defmodule GuardedStructTest.AshIntegrationTest do
+ use ExUnit.Case, async: false
+
+ @moduletag capture_log: true
+
+ alias GuardedStructTest.AshResources.{
+ UserManual,
+ UserAuto,
+ WithSubField,
+ WithListSubField,
+ WithAshChange,
+ AtomicEligibleUser
+ }
+
+ describe "sanitize end-to-end through Ash.create/1" do
+ test "trim + downcase normalize an email before insert (manual wiring)" do
+ {:ok, user} =
+ UserManual
+ |> Ash.Changeset.for_create(:create, %{email: " Alice@Example.COM "})
+ |> Ash.create()
+
+ assert user.email == "alice@example.com"
+ end
+
+ test "trim + downcase normalize via auto-wired resource" do
+ {:ok, user} =
+ UserAuto
+ |> Ash.Changeset.for_create(:create, %{email: " Bob@X.IO "})
+ |> Ash.create()
+
+ assert user.email == "bob@x.io"
+ end
+
+ test "trim runs on nickname too" do
+ {:ok, user} =
+ UserManual
+ |> Ash.Changeset.for_create(:create, %{email: "ok@x.com", nickname: " jay "})
+ |> Ash.create()
+
+ assert user.nickname == "jay"
+ end
+ end
+
+ describe "validation errors block Ash.create/1" do
+ test "invalid email format → Ash.Error.Invalid" do
+ assert {:error, %Ash.Error.Invalid{} = err} =
+ UserManual
+ |> Ash.Changeset.for_create(:create, %{email: "not-an-email"})
+ |> Ash.create()
+
+ assert inspect(err) =~ "email"
+ end
+
+ test "nickname too long → invalid" do
+ assert {:error, _} =
+ UserManual
+ |> Ash.Changeset.for_create(:create, %{
+ email: "ok@x.com",
+ nickname: String.duplicate("a", 50)
+ })
+ |> Ash.create()
+ end
+
+ test "missing required Ash attribute → Ash blocks before our change fires" do
+ assert {:error, %Ash.Error.Invalid{}} =
+ UserManual
+ |> Ash.Changeset.for_create(:create, %{})
+ |> Ash.create()
+ end
+
+ test "auto-wired resource rejects bad input identically" do
+ assert {:error, %Ash.Error.Invalid{}} =
+ UserAuto
+ |> Ash.Changeset.for_create(:create, %{email: "bad"})
+ |> Ash.create()
+ end
+ end
+
+ describe "manual vs auto wiring parity" do
+ test "same input produces same persisted result" do
+ input = %{email: " Carol@Z.IO ", nickname: " c "}
+
+ {:ok, manual} =
+ UserManual |> Ash.Changeset.for_create(:create, input) |> Ash.create()
+
+ {:ok, auto} =
+ UserAuto |> Ash.Changeset.for_create(:create, input) |> Ash.create()
+
+ assert manual.email == auto.email
+ assert manual.nickname == auto.nickname
+ end
+
+ test "both register the GuardedStruct change in Ash.Resource.Info.changes/1" do
+ manual_has =
+ UserManual
+ |> Ash.Resource.Info.changes()
+ |> Enum.any?(fn c -> c.change == {GuardedStruct.AshResource.Change, []} end)
+
+ auto_has =
+ UserAuto
+ |> Ash.Resource.Info.changes()
+ |> Enum.any?(fn c -> c.change == {GuardedStruct.AshResource.Change, []} end)
+
+ assert manual_has
+ assert auto_has
+ end
+ end
+
+ describe "sub_field cascade lands in :map attribute" do
+ test "single-level sub_field stored as plain map (not struct)" do
+ {:ok, user} =
+ WithSubField
+ |> Ash.Changeset.for_create(:create, %{
+ email: "x@y.com",
+ profile: %{name: "Alice", bio: "Hi"}
+ })
+ |> Ash.create()
+
+ assert is_map(user.profile)
+ refute is_struct(user.profile)
+ assert user.profile[:name] == "Alice" or user.profile["name"] == "Alice"
+ end
+
+ test "3-deep nested sub_field returns maps at every level" do
+ {:ok, user} =
+ WithSubField
+ |> Ash.Changeset.for_create(:create, %{
+ email: "x@y.com",
+ profile: %{
+ name: "Alice",
+ address: %{
+ city: "Berlin",
+ geo: %{lat: 52.5, lng: 13.4}
+ }
+ }
+ })
+ |> Ash.create()
+
+ profile = user.profile
+ address = profile[:address] || profile["address"]
+ geo = address[:geo] || address["geo"]
+
+ refute is_struct(profile)
+ refute is_struct(address)
+ refute is_struct(geo)
+ assert (geo[:lat] || geo["lat"]) == 52.5
+ end
+
+ test "list-of-sub_field stored as list of maps" do
+ {:ok, post} =
+ WithListSubField
+ |> Ash.Changeset.for_create(:create, %{
+ name: "Post",
+ tags: [
+ %{label: " Elixir ", score: 10},
+ %{label: " Phoenix ", score: 8}
+ ]
+ })
+ |> Ash.create()
+
+ assert is_list(post.tags)
+ assert length(post.tags) == 2
+ refute Enum.any?(post.tags, &is_struct/1)
+
+ labels = Enum.map(post.tags, fn t -> t[:label] || t["label"] end)
+ assert "elixir" in labels
+ assert "phoenix" in labels
+ end
+ end
+
+ describe "update actions" do
+ test "update sanitizes the new value just like create" do
+ {:ok, user} =
+ UserAuto
+ |> Ash.Changeset.for_create(:create, %{email: "first@x.com"})
+ |> Ash.create()
+
+ {:ok, updated} =
+ user
+ |> Ash.Changeset.for_update(:update, %{email: " Second@X.COM "})
+ |> Ash.update()
+
+ assert updated.email == "second@x.com"
+ end
+
+ test "update with invalid email fails" do
+ {:ok, user} =
+ UserAuto
+ |> Ash.Changeset.for_create(:create, %{email: "first@x.com"})
+ |> Ash.create()
+
+ assert {:error, _} =
+ user
+ |> Ash.Changeset.for_update(:update, %{email: "not-an-email"})
+ |> Ash.update()
+ end
+ end
+
+ describe "composition with Ash's native changes" do
+ test "both our change and Ash's own change run successfully" do
+ {:ok, user} =
+ WithAshChange
+ |> Ash.Changeset.for_create(:create, %{email: " John@Example.COM "})
+ |> Ash.create()
+
+ assert user.email == "john@example.com"
+ # Slug derived from email — order between our change and Ash's
+ # depends on Ash's internal scheduling; both forms are acceptable.
+ assert user.slug in ["john", "John"]
+ end
+ end
+
+ describe "direct __guarded_change__/1 API on real Ash resources" do
+ test "callable outside Ash changeset machinery" do
+ assert {:ok, %{email: "alice@x.io"}} =
+ UserManual.__guarded_change__(%{email: " ALICE@x.io "})
+ end
+
+ test "returns plain map (no struct wrapper) on real Ash resource" do
+ {:ok, result} =
+ WithSubField.__guarded_change__(%{
+ email: "a@b.com",
+ profile: %{name: "Z", address: %{city: "Paris"}}
+ })
+
+ refute is_struct(result)
+ refute is_struct(result.profile)
+ refute is_struct(result.profile.address)
+ end
+
+ test "errors surface as the same shape as standalone" do
+ assert {:error, _errs} = UserManual.__guarded_change__(%{email: "bad"})
+ end
+ end
+
+ describe "error shape and Ash error wrapping" do
+ test "changeset.errors after a failed create is non-empty" do
+ changeset =
+ Ash.Changeset.for_create(UserManual, :create, %{email: "definitely-not-an-email"})
+
+ refute changeset.valid?
+ assert length(changeset.errors) > 0
+ end
+
+ test "Ash.create on invalid changeset returns Ash.Error.Invalid" do
+ assert {:error, %Ash.Error.Invalid{errors: errs}} =
+ UserManual
+ |> Ash.Changeset.for_create(:create, %{email: "bad"})
+ |> Ash.create()
+
+ assert is_list(errs)
+ assert length(errs) > 0
+ end
+
+ test "successful Ash.create returns a struct of the resource type" do
+ {:ok, user} =
+ UserManual
+ |> Ash.Changeset.for_create(:create, %{email: "ok@x.com"})
+ |> Ash.create()
+
+ assert is_struct(user, UserManual)
+ end
+ end
+
+ describe "bulk_create end-to-end" do
+ test "bulk_create runs the GuardedStruct pipeline on every input" do
+ input = [
+ %{email: " Alice@Bulk.io "},
+ %{email: " Bob@Bulk.com "},
+ %{email: " Carol@Bulk.dev "}
+ ]
+
+ result =
+ Ash.bulk_create(input, UserAuto, :create,
+ return_records?: true,
+ return_errors?: true
+ )
+
+ assert result.status == :success
+ assert length(result.records) == 3
+
+ emails = Enum.map(result.records, & &1.email) |> Enum.sort()
+ assert emails == ["alice@bulk.io", "bob@bulk.com", "carol@bulk.dev"]
+ end
+
+ test "bulk_create with one invalid input — errors are partitioned per element" do
+ input = [
+ %{email: "ok@x.com"},
+ %{email: "not-an-email"},
+ %{email: " Also@OK.com "}
+ ]
+
+ result =
+ Ash.bulk_create(input, UserAuto, :create,
+ return_records?: true,
+ return_errors?: true,
+ stop_on_error?: false
+ )
+
+ # Two succeed, one fails.
+ assert length(result.records) == 2
+ assert length(result.errors) == 1
+
+ sanitized_emails = Enum.map(result.records, & &1.email) |> Enum.sort()
+ assert sanitized_emails == ["also@ok.com", "ok@x.com"]
+ end
+
+ test "bulk_create cascades into sub_field maps for every row" do
+ input = [
+ %{email: "a@x.com", profile: %{name: "Alice", bio: "Hi"}},
+ %{email: "b@x.com", profile: %{name: "Bob", bio: "Hey"}}
+ ]
+
+ result =
+ Ash.bulk_create(input, WithSubField, :create,
+ return_records?: true,
+ return_errors?: true
+ )
+
+ assert result.status == :success
+ assert length(result.records) == 2
+
+ profiles = Enum.map(result.records, & &1.profile)
+ assert Enum.all?(profiles, &is_map/1)
+ refute Enum.any?(profiles, &is_struct/1)
+ end
+ end
+
+ describe "bulk_update end-to-end" do
+ test "bulk_update via a stream sanitizes the new value on each row" do
+ # Create three users first.
+ %{status: :success} =
+ Ash.bulk_create(
+ [
+ %{email: "u1@bulk-up.com"},
+ %{email: "u2@bulk-up.com"},
+ %{email: "u3@bulk-up.com"}
+ ],
+ UserAuto,
+ :create,
+ return_records?: false,
+ return_errors?: true
+ )
+
+ result =
+ UserAuto
+ |> Ash.bulk_update(:update, %{email: " Updated@X.COM "},
+ return_records?: true,
+ return_errors?: true,
+ stop_on_error?: false,
+ strategy: :stream
+ )
+
+ assert result.status == :success
+ assert length(result.records) == 3
+
+ # Every email passed through our sanitize: trim + downcase
+ assert Enum.all?(result.records, fn r -> r.email == "updated@x.com" end)
+ end
+ end
+
+ describe "atomic mode — explicit opt-out behavior" do
+ test "atomic/3 returns {:not_atomic, reason}" do
+ reason = GuardedStruct.AshResource.Change.atomic(%{}, [], %{})
+ assert match?({:not_atomic, _}, reason)
+
+ {:not_atomic, msg} = reason
+ assert msg =~ "imperative"
+ end
+
+ test "actions that don't require_atomic: false fail at compile time" do
+ assert :ok = :ok
+ end
+ end
+
+ describe "persistence — read after write" do
+ test "reading back via Ash.get/2 returns the sanitized email" do
+ {:ok, created} =
+ UserAuto
+ |> Ash.Changeset.for_create(:create, %{email: " Dean@X.COM "})
+ |> Ash.create()
+
+ {:ok, fetched} = Ash.get(UserAuto, created.id)
+
+ assert fetched.email == "dean@x.com"
+ assert fetched.id == created.id
+ end
+
+ test "destroy works on a guarded-validated record" do
+ {:ok, user} =
+ UserAuto
+ |> Ash.Changeset.for_create(:create, %{email: "z@z.com"})
+ |> Ash.create()
+
+ assert :ok = Ash.destroy(user)
+ assert {:error, _} = Ash.get(UserAuto, user.id)
+ end
+ end
+
+ describe "atomic: true — real Ash resource end-to-end" do
+ test "resource compiles cleanly (VerifyAtomic accepts all-safe ops)" do
+ assert Code.ensure_loaded?(AtomicEligibleUser)
+ assert GuardedStruct.AshResource.Info.guardedstruct_atomic!(AtomicEligibleUser) == true
+ end
+
+ test "sanitize runs end-to-end through create" do
+ {:ok, user} =
+ AtomicEligibleUser
+ |> Ash.Changeset.for_create(:create, valid_atomic_input(%{email: " Alice@X.IO "}))
+ |> Ash.create()
+
+ assert user.email == "alice@x.io"
+ end
+
+ test "all atomic-safe validate ops accept good input" do
+ {:ok, user} =
+ AtomicEligibleUser
+ |> Ash.Changeset.for_create(:create,
+ email: "valid@x.com",
+ username: "alice",
+ age: 30,
+ role: "admin",
+ tenant_id: "11111111-2222-3333-4444-555555555555",
+ country_code: "DE",
+ status: "active"
+ )
+ |> Ash.create()
+
+ assert user.email == "valid@x.com"
+ assert user.username == "alice"
+ assert user.age == 30
+ assert user.role == "admin"
+ assert user.country_code == "DE"
+ assert user.status == "active"
+ end
+
+ test "validate(email_r) rejects malformed email" do
+ assert {:error, _} =
+ AtomicEligibleUser
+ |> Ash.Changeset.for_create(:create, valid_atomic_input(%{email: "not-an-email"}))
+ |> Ash.create()
+ end
+
+ test "validate(min_len) on integer rejects below-range value" do
+ assert {:error, _} =
+ AtomicEligibleUser
+ |> Ash.Changeset.for_create(:create, valid_atomic_input(%{age: -5}))
+ |> Ash.create()
+ end
+
+ test "validate(max_len) on integer rejects above-range value" do
+ assert {:error, _} =
+ AtomicEligibleUser
+ |> Ash.Changeset.for_create(:create, valid_atomic_input(%{age: 200}))
+ |> Ash.create()
+ end
+
+ test "validate(enum) rejects out-of-set role" do
+ assert {:error, _} =
+ AtomicEligibleUser
+ |> Ash.Changeset.for_create(:create, valid_atomic_input(%{role: "superuser"}))
+ |> Ash.create()
+ end
+
+ test "validate(uuid) rejects malformed tenant_id" do
+ assert {:error, _} =
+ AtomicEligibleUser
+ |> Ash.Changeset.for_create(
+ :create,
+ valid_atomic_input(%{tenant_id: "not-a-uuid"})
+ )
+ |> Ash.create()
+ end
+
+ test "validate(max_len) rejects wrong-length country code" do
+ assert {:error, _} =
+ AtomicEligibleUser
+ |> Ash.Changeset.for_create(:create, valid_atomic_input(%{country_code: "deu"}))
+ |> Ash.create()
+ end
+
+ test "sanitize(upcase) normalizes country code casing" do
+ {:ok, user} =
+ AtomicEligibleUser
+ |> Ash.Changeset.for_create(:create, valid_atomic_input(%{country_code: "de"}))
+ |> Ash.create()
+
+ assert user.country_code == "DE"
+ end
+
+ test "validate(min_len) on string rejects too-short username" do
+ assert {:error, _} =
+ AtomicEligibleUser
+ |> Ash.Changeset.for_create(:create, valid_atomic_input(%{username: "ab"}))
+ |> Ash.create()
+ end
+
+ test "validate(max_len) on string rejects too-long username" do
+ assert {:error, _} =
+ AtomicEligibleUser
+ |> Ash.Changeset.for_create(
+ :create,
+ valid_atomic_input(%{username: String.duplicate("a", 30)})
+ )
+ |> Ash.create()
+ end
+
+ test "field with default accepts being omitted" do
+ {:ok, user} =
+ AtomicEligibleUser
+ |> Ash.Changeset.for_create(:create, valid_atomic_input(%{status: nil}))
+ |> Ash.create()
+
+ refute is_nil(user.id)
+ end
+
+ test "multiple errors are aggregated, not short-circuited" do
+ assert {:error, %Ash.Error.Invalid{errors: errs}} =
+ AtomicEligibleUser
+ |> Ash.Changeset.for_create(:create,
+ email: "bad-email",
+ username: "x",
+ age: 500,
+ role: "nope",
+ tenant_id: "not-uuid",
+ country_code: "lower",
+ status: "active"
+ )
+ |> Ash.create()
+
+ assert length(errs) >= 2
+ end
+
+ test "direct __guarded_change__/1 still works" do
+ input = %{
+ email: " Bob@Y.com ",
+ username: "bob",
+ age: 25,
+ role: "user",
+ tenant_id: "11111111-2222-3333-4444-555555555555",
+ country_code: "FR",
+ status: "active"
+ }
+
+ assert {:ok, attrs} = AtomicEligibleUser.__guarded_change__(input)
+ assert attrs.email == "bob@y.com"
+ assert attrs.username == "bob"
+ end
+
+ test "Info.describe/1 reports atomic: true in section options" do
+ d = GuardedStruct.AshResource.Info.guardedstruct_atomic!(AtomicEligibleUser)
+ assert d == true
+ end
+
+ test "auto-wire still injected on top of atomic: true" do
+ assert Enum.any?(Ash.Resource.Info.changes(AtomicEligibleUser), fn c ->
+ c.change == {GuardedStruct.AshResource.Change, []}
+ end)
+ end
+
+ test "update action sanitizes the new value with all-safe ops" do
+ {:ok, user} =
+ AtomicEligibleUser
+ |> Ash.Changeset.for_create(:create, valid_atomic_input())
+ |> Ash.create()
+
+ {:ok, updated} =
+ user
+ |> Ash.Changeset.for_update(:update, %{email: " New@Email.COM "})
+ |> Ash.update()
+
+ assert updated.email == "new@email.com"
+ end
+ end
+
+ defp valid_atomic_input(overrides \\ %{}) do
+ Map.merge(
+ %{
+ email: "default@x.com",
+ username: "defaultuser",
+ age: 25,
+ role: "user",
+ tenant_id: "11111111-2222-3333-4444-555555555555",
+ country_code: "US",
+ status: "active"
+ },
+ Map.new(overrides)
+ )
+ end
+
+ describe "atomic: true — compile-time rejection on real Ash resources" do
+ import ExUnit.CaptureIO
+
+ defp compile_atomic_fixture(body) do
+ suffix = :erlang.unique_integer([:positive])
+
+ src = """
+ defmodule TestAtomicFixture#{suffix} do
+ use Ash.Resource,
+ domain: GuardedStructTest.Support.TestDomain,
+ data_layer: Ash.DataLayer.Ets,
+ extensions: [GuardedStruct.AshResource]
+
+ ets do
+ private? true
+ end
+
+ guardedstruct do
+ atomic true
+ #{body}
+ end
+
+ actions do
+ defaults [:read, :destroy]
+ create :create, accept: [:value]
+ end
+
+ attributes do
+ uuid_primary_key :id
+ attribute :value, :string, public?: true
+ end
+ end
+ """
+
+ capture_io(:stderr, fn ->
+ try do
+ Code.compile_string(src)
+ rescue
+ _ -> :ok
+ catch
+ _, _ -> :ok
+ end
+ end)
+ end
+
+ test "validate(email) DNS validator is rejected at compile time" do
+ output = compile_atomic_fixture(~s{field :value, :string, derives: "validate(email)"})
+
+ assert output =~ "Spark.Error.DslError"
+ assert output =~ "atomic: true"
+ assert output =~ ":value"
+ assert output =~ "DNS"
+ assert output =~ "validate(email_r)"
+ end
+
+ test "validate(url) is rejected" do
+ output = compile_atomic_fixture(~s{field :value, :string, derives: "validate(url)"})
+
+ assert output =~ "Spark.Error.DslError"
+ assert output =~ ":value"
+ assert output =~ "DNS"
+ assert output =~ "validate(url_r)"
+ end
+
+ test "per-field validator: MFA is rejected" do
+ output =
+ compile_atomic_fixture("""
+ field :value, :string,
+ validator: {ConditionalFieldValidatorTestValidators, :is_string_data}
+ """)
+
+ assert output =~ "Spark.Error.DslError"
+ assert output =~ ":value"
+ assert output =~ "validator:"
+ assert output =~ "arbitrary Elixir"
+ end
+
+ test "auto: MFA is rejected" do
+ output =
+ compile_atomic_fixture("""
+ field :value, :string,
+ auto: {GuardedStructTest.Support.TestDomain, :no_such_fn}
+ """)
+
+ assert output =~ "Spark.Error.DslError"
+ assert output =~ ":value"
+ assert output =~ "auto:"
+ assert output =~ "arbitrary Elixir"
+ end
+
+ test "cross-field on: dependency is rejected" do
+ output =
+ compile_atomic_fixture("""
+ field :value, :string,
+ derives: "validate(string)",
+ on: "root::other_field"
+ """)
+
+ assert output =~ "Spark.Error.DslError"
+ assert output =~ ":value"
+ assert output =~ "on:"
+ end
+
+ test "typo / unknown op is rejected with a typo-aware diagnostic" do
+ output =
+ compile_atomic_fixture(~s{field :value, :string, derives: "validate(emaill_r)"})
+
+ assert output =~ "Spark.Error.DslError"
+ assert output =~ "NOT a known built-in op"
+ assert output =~ "typo"
+ end
+
+ test "known built-in (but not atomic-safe) gets a different message than typos" do
+ # `validate(custom)` is in Derive.Registry but not in our atomic-safe
+ # list — message should say "built-in op but not in the atomic-safe
+ # registry", NOT "typo".
+ output =
+ compile_atomic_fixture(~s{field :value, :string, derives: "validate(custom)"})
+
+ assert output =~ "Spark.Error.DslError"
+ assert output =~ "is a built-in op but not in the atomic-safe registry"
+ refute output =~ "typo"
+ end
+
+ test "multiple blockers in one resource are aggregated in one error" do
+ output =
+ compile_atomic_fixture("""
+ field :a, :string, derives: "validate(email)"
+ field :b, :string, derives: "validate(url)"
+ field :c, :string, derives: "validate(totally_unknown)"
+ """)
+
+ assert output =~ "Spark.Error.DslError"
+ assert output =~ ":a"
+ assert output =~ ":b"
+ assert output =~ ":c"
+ end
+
+ test "unsafe op inside a sub_field is caught" do
+ output =
+ compile_atomic_fixture("""
+ sub_field :nested, :map do
+ field :value, :string, derives: "validate(email)"
+ end
+ """)
+
+ assert output =~ "Spark.Error.DslError"
+ assert output =~ ":nested"
+ assert output =~ ":value"
+ end
+
+ test "error message points users to AtomicClassifier" do
+ output = compile_atomic_fixture(~s{field :value, :string, derives: "validate(email)"})
+
+ assert output =~ "AtomicClassifier"
+ end
+
+ test "error message names the resource module" do
+ output = compile_atomic_fixture(~s{field :value, :string, derives: "validate(email)"})
+
+ assert output =~ "TestAtomicFixture"
+ end
+
+ test "atomic: false (default) compiles the SAME bad ops cleanly" do
+ suffix = :erlang.unique_integer([:positive])
+
+ src = """
+ defmodule TestAtomicOffFixture#{suffix} do
+ use Ash.Resource,
+ domain: GuardedStructTest.Support.TestDomain,
+ data_layer: Ash.DataLayer.Ets,
+ extensions: [GuardedStruct.AshResource]
+
+ ets do
+ private? true
+ end
+
+ guardedstruct do
+ field :email, :string, derives: "validate(email)"
+ end
+
+ actions do
+ defaults [:read, :destroy]
+ create :create, accept: [:email]
+ end
+
+ changes do
+ change GuardedStruct.AshResource.Change
+ end
+
+ attributes do
+ uuid_primary_key :id
+ attribute :email, :string, public?: true
+ end
+ end
+ """
+
+ output =
+ capture_io(:stderr, fn ->
+ Code.compile_string(src)
+ end)
+
+ refute output =~ "atomic: true"
+ refute output =~ "Spark.Error.DslError"
+ end
+ end
+end
diff --git a/test/ash_resource_change_test.exs b/test/ash_resource_change_test.exs
new file mode 100644
index 0000000..f10a28e
--- /dev/null
+++ b/test/ash_resource_change_test.exs
@@ -0,0 +1,119 @@
+defmodule GuardedStructTest.AshResourceChangeTest do
+ use ExUnit.Case, async: false
+
+ @moduletag capture_log: true
+
+ alias GuardedStructTest.AshResources.{Manual, AutoWired, AutoWireOff}
+
+ describe "Change.change/3 — happy path" do
+ test "valid input → changeset.attributes contain sanitized values" do
+ changeset = Ash.Changeset.for_create(Manual, :create, %{email: " Alice@X.io "})
+
+ result = GuardedStruct.AshResource.Change.change(changeset, [], %{})
+
+ assert result.attributes.email == "alice@x.io"
+ assert result.errors == []
+ assert result.valid?
+ end
+
+ test "preserves changeset identity (still an Ash.Changeset)" do
+ changeset = Ash.Changeset.for_create(Manual, :create, %{email: "ok@x.com"})
+
+ result = GuardedStruct.AshResource.Change.change(changeset, [], %{})
+
+ assert is_struct(result, Ash.Changeset)
+ assert result.resource == Manual
+ end
+ end
+
+ describe "Change.change/3 — error paths" do
+ test "nickname too long → adds an error" do
+ changeset =
+ Ash.Changeset.for_create(Manual, :create, %{
+ email: "ok@x.com",
+ nickname: "way-too-long-nickname-fails-max-len"
+ })
+
+ result = GuardedStruct.AshResource.Change.change(changeset, [], %{})
+
+ refute result.valid?
+ assert length(result.errors) >= 1
+ end
+
+ test "derive failure on nickname surfaces an error mentioning the field" do
+ changeset =
+ Ash.Changeset.for_create(Manual, :create, %{
+ email: "ok@x.com",
+ nickname: String.duplicate("a", 25)
+ })
+
+ result = GuardedStruct.AshResource.Change.change(changeset, [], %{})
+
+ refute result.valid?
+
+ assert Enum.any?(result.errors, fn err ->
+ inspected = inspect(err)
+
+ String.contains?(inspected, "nickname") or
+ String.contains?(inspected, "max_len")
+ end)
+ end
+ end
+
+ describe "AutoWireAshChange — auto_wire: true" do
+ test "Ash.Resource.Info.changes/1 lists our Change module" do
+ changes = Ash.Resource.Info.changes(AutoWired)
+
+ assert Enum.any?(changes, fn c ->
+ c.change == {GuardedStruct.AshResource.Change, []}
+ end),
+ "expected GuardedStruct.AshResource.Change in #{inspect(changes)}"
+ end
+
+ test "auto-wired resource applies sanitize end-to-end through Ash.create/1" do
+ {:ok, user} =
+ AutoWired
+ |> Ash.Changeset.for_create(:create, %{email: " Bob@Y.COM "})
+ |> Ash.create()
+
+ assert user.email == "bob@y.com"
+ end
+ end
+
+ describe "AutoWireAshChange — auto_wire: false (default)" do
+ test "Manual resource has exactly one explicitly-added change" do
+ gs_changes =
+ Manual
+ |> Ash.Resource.Info.changes()
+ |> Enum.filter(fn c -> c.change == {GuardedStruct.AshResource.Change, []} end)
+
+ assert length(gs_changes) == 1
+ end
+
+ test "AutoWireOff resource has ZERO GuardedStruct changes" do
+ refute Enum.any?(Ash.Resource.Info.changes(AutoWireOff), fn c ->
+ c.change == {GuardedStruct.AshResource.Change, []}
+ end)
+ end
+ end
+
+ describe "AutoWireAshChange — DSL option surface" do
+ test "Info.guardedstruct_auto_wire!/1 reflects the option" do
+ assert GuardedStruct.AshResource.Info.guardedstruct_auto_wire!(AutoWired) == true
+ assert GuardedStruct.AshResource.Info.guardedstruct_auto_wire!(Manual) == false
+ assert GuardedStruct.AshResource.Info.guardedstruct_auto_wire!(AutoWireOff) == false
+ end
+ end
+
+ describe "direct __guarded_change__/1 API" do
+ test "callable outside Ash actions for scripts/tests" do
+ assert {:ok, %{email: "alice@x.io"}} =
+ AutoWired.__guarded_change__(%{email: " ALICE@x.io "})
+ end
+
+ test "returns plain map (auto-map cascade) — no struct wrapping" do
+ {:ok, result} = Manual.__guarded_change__(%{email: "x@y.com"})
+ refute is_struct(result)
+ end
+ end
+end
diff --git a/test/ash_resource_test.exs b/test/ash_resource_test.exs
new file mode 100644
index 0000000..cc26bb0
--- /dev/null
+++ b/test/ash_resource_test.exs
@@ -0,0 +1,137 @@
+defmodule GuardedStructTest.AshResourceTest do
+ use ExUnit.Case, async: true
+
+ # We don't depend on :ash for the test suite — instead we define a tiny
+ # framework module (`FakeFramework`) that plays the role `Ash.Resource`
+ # does for real users. The framework declares which extension kinds it
+ # supports, then user modules opt into our extension via
+ # `use FakeFramework, extensions: [GuardedStruct.AshResource]`. This is
+ # the same wiring Ash uses; we're just replacing the framework.
+
+ defmodule FakeFramework do
+ use Spark.Dsl, default_extensions: [extensions: [GuardedStruct.AshResource]]
+ end
+
+ defmodule FakeAshResource do
+ # Real Ash users do: `use Ash.Resource, extensions: [GuardedStruct.AshResource]`
+ # — same wiring as `use FakeFramework, ...` here.
+ use FakeFramework
+
+ # Note: Ash users don't get our arity-2 `guardedstruct opts do … end`
+ # wrapper (that's auto-imported only by `use GuardedStruct`). Set options
+ # via the Spark-generated inline setters at the top of the block — this
+ # is idiomatic Spark.
+ guardedstruct do
+ field(:email, :string,
+ enforce: true,
+ derives: "sanitize(trim, downcase) validate(string, not_empty, email_r)"
+ )
+
+ field(:nickname, :string,
+ derives: "sanitize(strip_tags, trim) validate(string, max_len=20)"
+ )
+
+ sub_field(:preferences, :map) do
+ field(:theme, :string, derives: "validate(enum=String[light::dark])")
+ end
+ end
+ end
+
+ describe "Ash extension generates introspection functions" do
+ test "__guarded_information__/0 returns the metadata map" do
+ info = FakeAshResource.__guarded_information__()
+ assert info.module == FakeAshResource
+ assert :email in info.keys
+ assert :nickname in info.keys
+ assert :preferences in info.keys
+ end
+
+ test "__guarded_fields__/0 returns runtime field metadata" do
+ meta = FakeAshResource.__guarded_fields__()
+ assert is_list(meta)
+ assert Enum.any?(meta, &(&1.name == :email))
+ end
+
+ test "does NOT generate __struct__/builder/2 (Ash owns those)" do
+ refute function_exported?(FakeAshResource, :builder, 1)
+ refute function_exported?(FakeAshResource, :builder, 2)
+ refute function_exported?(FakeAshResource, :__struct__, 0)
+ end
+ end
+
+ describe "__guarded_change__/1" do
+ test "valid input → {:ok, sanitized_attrs}" do
+ assert {:ok, attrs} =
+ FakeAshResource.__guarded_change__(%{email: " Foo@Bar.COM "})
+
+ # Sanitize ran (trim + downcase).
+ assert attrs.email == "foo@bar.com"
+ end
+
+ test "missing required field → {:error, required_fields}" do
+ assert {:error, %{action: :required_fields, fields: [:email]}} =
+ FakeAshResource.__guarded_change__(%{})
+ end
+
+ test "derive failure → {:error, list of errors}" do
+ assert {:error, errs} =
+ FakeAshResource.__guarded_change__(%{
+ email: "valid@example.com",
+ nickname: 123
+ })
+
+ assert Enum.any?(errs, fn e -> e.field == :nickname end)
+ end
+
+ test "sub_field validation works through the Ash variant too" do
+ # `theme` has an enum derive — wrong value should fail.
+ assert {:error, errors} =
+ FakeAshResource.__guarded_change__(%{
+ email: "valid@example.com",
+ preferences: %{theme: "blue"}
+ })
+
+ # Should mention the preferences sub-tree.
+ assert Enum.any?(errors, fn e -> Map.get(e, :field) == :preferences end)
+
+ # And valid sub_field input passes through.
+ assert {:ok, attrs} =
+ FakeAshResource.__guarded_change__(%{
+ email: "valid@example.com",
+ preferences: %{theme: "dark"}
+ })
+
+ assert attrs.preferences.theme == "dark"
+ end
+ end
+
+ describe "GuardedStruct.AshResource.Info" do
+ test "fields/1 returns declared field names" do
+ assert GuardedStruct.AshResource.Info.fields(FakeAshResource) ==
+ [:email, :nickname, :preferences]
+ end
+
+ test "field/2 returns metadata for a name" do
+ assert %{kind: :field, name: :email} =
+ GuardedStruct.AshResource.Info.field(FakeAshResource, :email)
+ end
+
+ test "field?/2 boolean membership" do
+ assert GuardedStruct.AshResource.Info.field?(FakeAshResource, :email)
+ refute GuardedStruct.AshResource.Info.field?(FakeAshResource, :no_such)
+ end
+
+ test "validate/2 delegates to __guarded_change__/1" do
+ assert {:ok, _} =
+ GuardedStruct.AshResource.Info.validate(FakeAshResource, %{
+ email: "ok@x.com"
+ })
+ end
+
+ test "Spark-generated guardedstruct_enforce!/1 reads the section option" do
+ # No block-level enforce was set, so this is the default (false).
+ assert GuardedStruct.AshResource.Info.guardedstruct_enforce!(FakeAshResource) ==
+ false
+ end
+ end
+end
diff --git a/test/async_compile_sub_fields_test.exs b/test/async_compile_sub_fields_test.exs
new file mode 100644
index 0000000..ca37afd
--- /dev/null
+++ b/test/async_compile_sub_fields_test.exs
@@ -0,0 +1,217 @@
+defmodule GuardedStructTest.AsyncCompileSubFieldsTest do
+ @moduledoc """
+ Tests `GenerateSubFieldModules` use of `Spark.Dsl.Transformer.async_compile/2`
+ for sub_field submodule compilation.
+ """
+
+ use ExUnit.Case, async: true
+
+ alias GuardedStructTest.Fixtures.AsyncCompile.{
+ SimpleParent,
+ WideParent,
+ DeepParent,
+ WithConditional,
+ OrderedDeep
+ }
+
+ describe "simple sub_field — flat submodule generation" do
+ test "the Profile submodule exists and is fully compiled" do
+ assert Code.ensure_loaded?(SimpleParent.Profile)
+ assert function_exported?(SimpleParent.Profile, :builder, 1)
+ assert function_exported?(SimpleParent.Profile, :keys, 0)
+ assert function_exported?(SimpleParent.Profile, :__fields__, 0)
+ assert function_exported?(SimpleParent.Profile, :__information__, 0)
+ assert function_exported?(SimpleParent.Profile, :example, 0)
+ end
+
+ test "Profile.keys/0 reports the declared inner fields" do
+ assert SimpleParent.Profile.keys() == [:nickname, :bio]
+ end
+
+ test "end-to-end build through SimpleParent works" do
+ assert {:ok,
+ %SimpleParent{
+ id: "x",
+ profile: %SimpleParent.Profile{nickname: "n", bio: "b"}
+ }} =
+ SimpleParent.builder(%{id: "x", profile: %{nickname: "n", bio: "b"}})
+ end
+ end
+
+ describe "many sibling sub_fields — fan-out parallelism" do
+ test "every sibling submodule compiles independently" do
+ for letter <- [:A, :B, :C, :D] do
+ mod = Module.concat(WideParent, letter)
+ assert Code.ensure_loaded?(mod), "expected #{inspect(mod)} to exist"
+ assert function_exported?(mod, :builder, 1)
+ end
+ end
+
+ test "all four submodules are callable end-to-end" do
+ assert {:ok, built} =
+ WideParent.builder(%{
+ a: %{x: "1"},
+ b: %{x: "2"},
+ c: %{x: "3"},
+ d: %{x: "4"}
+ })
+
+ assert built.a.x == "1"
+ assert built.b.x == "2"
+ assert built.c.x == "3"
+ assert built.d.x == "4"
+ end
+ end
+
+ describe "deep nesting — parent → child → grandchild → great-grandchild" do
+ test "every depth-level submodule exists and is callable" do
+ mods = [
+ DeepParent.Level1,
+ DeepParent.Level1.Level2,
+ DeepParent.Level1.Level2.Level3,
+ DeepParent.Level1.Level2.Level3.Level4
+ ]
+
+ for mod <- mods do
+ assert Code.ensure_loaded?(mod), "missing #{inspect(mod)}"
+ assert function_exported?(mod, :builder, 1)
+ assert function_exported?(mod, :keys, 0)
+ end
+ end
+
+ test "end-to-end through all 4 levels of nesting" do
+ input = %{
+ level1: %{
+ tag: "1",
+ level2: %{
+ tag: "2",
+ level3: %{
+ tag: "3",
+ level4: %{value: "deep"}
+ }
+ }
+ }
+ }
+
+ assert {:ok, built} = DeepParent.builder(input)
+ assert built.level1.level2.level3.level4.value == "deep"
+ end
+
+ test "missing :value (enforced at the deepest level) propagates as an error" do
+ input = %{
+ level1: %{
+ level2: %{
+ level3: %{
+ level4: %{}
+ }
+ }
+ }
+ }
+
+ assert {:error, _} = DeepParent.builder(input)
+ end
+ end
+
+ describe "conditional sub_fields — auto-numbered submodule names" do
+ test "the auto-numbered Payload1 submodule exists" do
+ assert Code.ensure_loaded?(WithConditional.Payload1)
+ refute Code.ensure_loaded?(WithConditional.Payload)
+ end
+
+ test "the auto-numbered submodule has the inner field" do
+ assert WithConditional.Payload1.keys() == [:kind]
+ end
+
+ test "end-to-end conditional resolves to either branch" do
+ assert {:ok, %WithConditional{payload: "hello"}} =
+ WithConditional.builder(%{payload: "hello"})
+
+ assert {:ok, %WithConditional{payload: %WithConditional.Payload1{kind: "k"}}} =
+ WithConditional.builder(%{payload: %{kind: "k"}})
+ end
+ end
+
+ describe "async compile preserves ordering / dependency semantics" do
+ test "parent's example/0 successfully calls child's example/0 (runtime resolution)" do
+ ex = OrderedDeep.example()
+ assert ex.nested.label == "child-default"
+ end
+
+ test "all expected submodules exist at the moment user code first runs" do
+ assert Code.ensure_loaded?(OrderedDeep.Nested)
+ assert function_exported?(OrderedDeep.Nested, :builder, 1)
+ assert {:ok, _} = OrderedDeep.Nested.builder(%{})
+ end
+ end
+
+ describe "regression: the full existing fixture set still passes end-to-end" do
+ alias GuardedStructFixtures.{Conditionals, Decorated, Dynamic, Forms, Records, Showcase}
+
+ test "Forms.Signup builds" do
+ assert {:ok, _} =
+ Forms.Signup.builder(%{
+ email: "x@y.io",
+ password: "longenough",
+ password_confirmation: "longenough"
+ })
+ end
+
+ test "Decorated.BlogPost builds with the sub_field metadata" do
+ uuid = "22222222-2222-2222-2222-222222222222"
+
+ assert {:ok, _} =
+ Decorated.BlogPost.builder(%{
+ title: "ok",
+ body: "ok",
+ metadata: %{tags: ["a"], author_id: uuid}
+ })
+ end
+
+ test "Conditionals.Document — 7-level deep nesting still resolves" do
+ input = %{
+ title: "Hello",
+ content: %{
+ title: "Post",
+ body: %{
+ heading: "Section",
+ paragraphs: [
+ %{
+ text: "quote",
+ source: %{author: "Sh", url: "https://x.io"}
+ }
+ ]
+ }
+ }
+ }
+
+ assert {:ok, _} = Conditionals.Document.builder(input)
+ end
+
+ test "Dynamic.ClusterPlan composes pattern-keyed map" do
+ assert {:ok, _} =
+ Dynamic.ClusterPlan.builder(%{
+ status: "active",
+ shards: %{"shard_1" => %{node: "10.0.0.1"}}
+ })
+ end
+
+ test "Records.UserEvent accepts a record" do
+ require GuardedStructFixtures.Records
+ rec = GuardedStructFixtures.Records.user(name: "A", age: 1)
+ assert {:ok, _} = Records.UserEvent.builder(%{event_kind: :created, user: rec})
+ end
+
+ test "Showcase.EnterpriseAccount builds" do
+ input = %{
+ name: "Acme",
+ owner: %{id: "44444444-4444-4444-4444-444444444444", email: "o@a.io"},
+ members: [%{id: "55555555-5555-5555-5555-555555555555", email: "a@a.io"}],
+ plan: "enterprise",
+ settings: %{},
+ invitation_token: "abcdefghij1234567890"
+ }
+
+ assert {:ok, _} = Showcase.EnterpriseAccount.builder(input)
+ end
+ end
+end
diff --git a/test/atomic_verifier_test.exs b/test/atomic_verifier_test.exs
new file mode 100644
index 0000000..da87e51
--- /dev/null
+++ b/test/atomic_verifier_test.exs
@@ -0,0 +1,449 @@
+defmodule GuardedStructTest.AtomicVerifierTest do
+ use ExUnit.Case, async: true
+
+ alias GuardedStruct.AtomicClassifier
+ alias GuardedStruct.Verifiers.VerifyAtomic
+ alias GuardedStruct.Dsl.{Field, SubField, ConditionalField, VirtualField}
+
+ defp dsl_state(module, entities, opts \\ []) do
+ %{
+ [:guardedstruct] => %{entities: entities, opts: opts},
+ persist: %{module: module}
+ }
+ end
+
+ defp ops(validate, sanitize \\ []) do
+ %{validate: validate, sanitize: sanitize}
+ end
+
+ describe "atomic: false (default)" do
+ test "any combination of unsafe ops is allowed when atomic is off" do
+ state =
+ dsl_state(NotAtomicMod, [
+ %Field{name: :email, __derive_ops__: ops([:email])}
+ ])
+
+ assert :ok = VerifyAtomic.verify(state)
+ end
+
+ test "explicit atomic: false also skips verification" do
+ state =
+ dsl_state(
+ OffMod,
+ [%Field{name: :email, __derive_ops__: ops([:email])}],
+ atomic: false
+ )
+
+ assert :ok = VerifyAtomic.verify(state)
+ end
+ end
+
+ describe "atomic: true — happy paths" do
+ test "pure-validate fields pass" do
+ state =
+ dsl_state(
+ AllSafeMod,
+ [
+ %Field{name: :email, __derive_ops__: ops([:email_r, {:max_len, 320}])},
+ %Field{name: :age, __derive_ops__: ops([:integer, {:min_len, 0}])},
+ %Field{name: :name, __derive_ops__: ops([:string, :not_empty])}
+ ],
+ atomic: true
+ )
+
+ assert :ok = VerifyAtomic.verify(state)
+ end
+
+ test "sanitize + validate combos pass (sanitize runs before SQL)" do
+ state =
+ dsl_state(
+ SanOkMod,
+ [
+ %Field{
+ name: :email,
+ __derive_ops__: ops([:email_r], [:trim, :downcase])
+ },
+ %Field{
+ name: :role,
+ __derive_ops__: ops([{:enum, ["admin", "user"]}], [:trim])
+ }
+ ],
+ atomic: true
+ )
+
+ assert :ok = VerifyAtomic.verify(state)
+ end
+
+ test "all built-in sanitize ops are safe" do
+ state =
+ dsl_state(
+ SanitizersOkMod,
+ [
+ %Field{
+ name: :body,
+ __derive_ops__:
+ ops(
+ [:string],
+ [:trim, :downcase, :upcase, :capitalize, :strip_tags, :basic_html, :html5]
+ )
+ }
+ ],
+ atomic: true
+ )
+
+ assert :ok = VerifyAtomic.verify(state)
+ end
+ end
+
+ describe "atomic: true — DNS validators rejected" do
+ test "validate(email) blocked with DNS reason" do
+ state =
+ dsl_state(
+ DnsEmailMod,
+ [%Field{name: :email, __derive_ops__: ops([:email])}],
+ atomic: true
+ )
+
+ assert {:error, err} = VerifyAtomic.verify(state)
+ msg = Exception.message(err)
+
+ assert msg =~ "atomic: true"
+ assert msg =~ ":email"
+ assert msg =~ "DNS"
+ assert msg =~ "validate(email_r)"
+ end
+
+ test "validate(url) blocked with DNS/port reason" do
+ state =
+ dsl_state(
+ DnsUrlMod,
+ [%Field{name: :homepage, __derive_ops__: ops([:url])}],
+ atomic: true
+ )
+
+ assert {:error, err} = VerifyAtomic.verify(state)
+ msg = Exception.message(err)
+
+ assert msg =~ ":homepage"
+ assert msg =~ "DNS"
+ assert msg =~ "validate(url_r)"
+ end
+ end
+
+ describe "atomic: true — Elixir MFAs rejected" do
+ test "per-field validator: {Mod, :fn} blocked" do
+ state =
+ dsl_state(
+ PerFieldVMod,
+ [
+ %Field{
+ name: :code,
+ __derive_ops__: ops([:string]),
+ validator: {Some.Mod, :check}
+ }
+ ],
+ atomic: true
+ )
+
+ assert {:error, err} = VerifyAtomic.verify(state)
+ msg = Exception.message(err)
+
+ assert msg =~ ":code"
+ assert msg =~ "validator:"
+ assert msg =~ "arbitrary Elixir"
+ end
+
+ test "auto: {Mod, :fn} blocked" do
+ state =
+ dsl_state(
+ AutoMfaMod,
+ [
+ %Field{
+ name: :id,
+ __derive_ops__: ops([:string]),
+ auto: {Some.Gen, :gen}
+ }
+ ],
+ atomic: true
+ )
+
+ assert {:error, err} = VerifyAtomic.verify(state)
+ msg = Exception.message(err)
+
+ assert msg =~ ":id"
+ assert msg =~ "auto:"
+ assert msg =~ "arbitrary Elixir"
+ end
+
+ test "section main_validator: option blocked" do
+ state =
+ dsl_state(
+ MainValOptMod,
+ [%Field{name: :a, __derive_ops__: ops([:string])}],
+ atomic: true,
+ main_validator: {Some.Validator, :check}
+ )
+
+ assert {:error, err} = VerifyAtomic.verify(state)
+ msg = Exception.message(err)
+
+ assert msg =~ "main_validator"
+ assert msg =~ "cross-field"
+ end
+ end
+
+ describe "atomic: true — cross-field options rejected" do
+ test "field with `on:` cross-field dep blocked" do
+ state =
+ dsl_state(
+ OnDepMod,
+ [
+ %Field{
+ name: :parent_email,
+ __derive_ops__: ops([:email_r]),
+ on: "root::account_type"
+ }
+ ],
+ atomic: true
+ )
+
+ assert {:error, err} = VerifyAtomic.verify(state)
+ msg = Exception.message(err)
+
+ assert msg =~ ":parent_email"
+ assert msg =~ "on:"
+ end
+
+ test "field with `from:` reference blocked" do
+ state =
+ dsl_state(
+ FromRefMod,
+ [
+ %Field{
+ name: :copy,
+ __derive_ops__: ops([:string]),
+ from: "root::source"
+ }
+ ],
+ atomic: true
+ )
+
+ assert {:error, err} = VerifyAtomic.verify(state)
+ msg = Exception.message(err)
+
+ assert msg =~ ":copy"
+ assert msg =~ "from:"
+ end
+
+ test "field with `domain:` constraint blocked" do
+ state =
+ dsl_state(
+ DomainMod,
+ [
+ %Field{
+ name: :child_email,
+ __derive_ops__: ops([:email_r]),
+ domain: "!parent_email=Email[type=*]"
+ }
+ ],
+ atomic: true
+ )
+
+ assert {:error, err} = VerifyAtomic.verify(state)
+ msg = Exception.message(err)
+
+ assert msg =~ ":child_email"
+ assert msg =~ "domain:"
+ end
+ end
+
+ describe "atomic: true — multiple blockers aggregate" do
+ test "every offending field appears in one error message" do
+ state =
+ dsl_state(
+ MultiFailMod,
+ [
+ %Field{name: :email, __derive_ops__: ops([:email])},
+ %Field{name: :homepage, __derive_ops__: ops([:url])},
+ %Field{
+ name: :code,
+ __derive_ops__: ops([:string]),
+ validator: {Some.Mod, :check}
+ }
+ ],
+ atomic: true
+ )
+
+ assert {:error, err} = VerifyAtomic.verify(state)
+ msg = Exception.message(err)
+
+ assert msg =~ ":email"
+ assert msg =~ ":homepage"
+ assert msg =~ ":code"
+ end
+ end
+
+ describe "atomic: true — sub_field cascade" do
+ test "unsafe op inside a sub_field is caught with full path" do
+ state =
+ dsl_state(
+ SubFieldMod,
+ [
+ %SubField{
+ name: :profile,
+ fields: [
+ %Field{name: :email, __derive_ops__: ops([:email])}
+ ]
+ }
+ ],
+ atomic: true
+ )
+
+ assert {:error, err} = VerifyAtomic.verify(state)
+ msg = Exception.message(err)
+
+ assert msg =~ ":profile"
+ assert msg =~ ":email"
+ end
+
+ test "sub_field's own derive ops are also checked" do
+ state =
+ dsl_state(
+ SubFieldOwnMod,
+ [
+ %SubField{
+ name: :auth,
+ __derive_ops__: ops([:email]),
+ fields: []
+ }
+ ],
+ atomic: true
+ )
+
+ assert {:error, err} = VerifyAtomic.verify(state)
+ assert Exception.message(err) =~ ":auth"
+ end
+ end
+
+ describe "atomic: true — virtual_field / conditional_field cascade" do
+ test "unsafe op in a virtual_field is caught" do
+ state =
+ dsl_state(
+ VirtualMod,
+ [%VirtualField{name: :token, __derive_ops__: ops([:email])}],
+ atomic: true
+ )
+
+ assert {:error, err} = VerifyAtomic.verify(state)
+ assert Exception.message(err) =~ ":token"
+ end
+
+ test "unsafe op in a conditional_field child is caught" do
+ state =
+ dsl_state(
+ CondMod,
+ [
+ %ConditionalField{
+ name: :payload,
+ fields: [%Field{name: :payload, __derive_ops__: ops([:email])}]
+ }
+ ],
+ atomic: true
+ )
+
+ assert {:error, err} = VerifyAtomic.verify(state)
+ assert Exception.message(err) =~ ":payload"
+ end
+ end
+
+ describe "AtomicClassifier" do
+ test "safe sanitize ops" do
+ for op <- [:trim, :downcase, :upcase, :capitalize, :strip_tags, :basic_html, :html5] do
+ assert AtomicClassifier.classify_op({:sanitize, op}) == :safe,
+ "expected sanitize(#{op}) to be safe"
+ end
+ end
+
+ test "safe validate ops — type checks" do
+ for op <- [:string, :integer, :float, :boolean, :atom, :list, :map, :tuple, :record] do
+ assert AtomicClassifier.classify_op({:validate, op}) == :safe
+ end
+ end
+
+ test "safe validate ops — emptiness/length" do
+ assert :safe = AtomicClassifier.classify_op({:validate, :not_empty})
+ assert :safe = AtomicClassifier.classify_op({:validate, :not_empty_string})
+ assert :safe = AtomicClassifier.classify_op({:validate, {:max_len, 80}})
+ assert :safe = AtomicClassifier.classify_op({:validate, {:min_len, 0}})
+ end
+
+ test "safe validate ops — regex/pattern" do
+ assert :safe = AtomicClassifier.classify_op({:validate, :uuid})
+ assert :safe = AtomicClassifier.classify_op({:validate, :email_r})
+ assert :safe = AtomicClassifier.classify_op({:validate, :url_r})
+ assert :safe = AtomicClassifier.classify_op({:validate, :ipv4})
+ assert :safe = AtomicClassifier.classify_op({:validate, {:regex, ~r/x/}})
+ end
+
+ test "safe validate ops — date/time" do
+ for op <- [:datetime, :date, :time] do
+ assert AtomicClassifier.classify_op({:validate, op}) == :safe
+ end
+ end
+
+ test "safe validate ops — enum/equal/min/max" do
+ assert :safe = AtomicClassifier.classify_op({:validate, {:enum, ["a", "b"]}})
+ assert :safe = AtomicClassifier.classify_op({:validate, {:equal, "x"}})
+ assert :safe = AtomicClassifier.classify_op({:validate, {:min, 0}})
+ assert :safe = AtomicClassifier.classify_op({:validate, {:max, 100}})
+ end
+
+ test "unsafe DNS validators rejected with informative reasons" do
+ assert {:unsafe, msg} = AtomicClassifier.classify_op({:validate, :email})
+ assert msg =~ "DNS"
+ assert msg =~ "email_r"
+
+ assert {:unsafe, msg2} = AtomicClassifier.classify_op({:validate, :url})
+ assert msg2 =~ "DNS"
+ assert msg2 =~ "url_r"
+ end
+
+ test "unknown validate ops are rejected with a typo-aware catch-all" do
+ assert {:unsafe, msg} = AtomicClassifier.classify_op({:validate, :totally_unknown})
+ assert msg =~ "NOT a known built-in op"
+ assert msg =~ "typo"
+ assert msg =~ "Derive.Extension"
+ assert msg =~ "Registry.validate_ops/0"
+ end
+
+ test "known built-in validate ops outside the safe registry get a different message" do
+ # `:custom`, `:either`, `:struct`, `:queue`, etc. are in Registry but
+ # not in the atomic-safe list. The catch-all should recognize them as
+ # built-ins and suggest adding a classifier clause.
+ assert {:unsafe, msg} = AtomicClassifier.classify_op({:validate, :custom})
+ assert msg =~ "is a built-in op but not in the atomic-safe registry"
+ assert msg =~ "AtomicClassifier"
+ refute msg =~ "typo"
+ end
+
+ test "unknown sanitize ops get typo-aware message" do
+ assert {:unsafe, msg} = AtomicClassifier.classify_op({:sanitize, :slugify})
+ assert msg =~ "NOT a known built-in op"
+ assert msg =~ "typo"
+ assert msg =~ "Derive.Extension"
+ end
+
+ test "known built-in sanitize ops outside the safe registry" do
+ # `:markdown_html` and `:string_float` are in Registry but not in our
+ # atomic-safe sanitize list — same diagnostic path as :custom above.
+ assert {:unsafe, msg} = AtomicClassifier.classify_op({:sanitize, :markdown_html})
+ assert msg =~ "is a built-in op but not in the atomic-safe registry"
+ refute msg =~ "typo"
+ end
+
+ test "unrecognized shape returns a generic unsafe" do
+ assert {:unsafe, msg} = AtomicClassifier.classify_op({:something, :weird})
+ assert msg =~ "unrecognized"
+ end
+ end
+end
diff --git a/test/basic_types_test.exs b/test/basic_types_test.exs
index 1274efb..3445186 100644
--- a/test/basic_types_test.exs
+++ b/test/basic_types_test.exs
@@ -33,19 +33,7 @@ defmodule GuardedStructTest.BasicTypesTest do
# def enforce_keys, do: @enforce_keys
# end
- defmodule EnforcedGuardedStruct do
- use GuardedStruct
-
- guardedstruct enforce: true do
- field(:enforced_by_default, term())
- field(:not_enforced, term(), enforce: false)
- field(:with_default, integer(), default: 1)
- field(:with_false_default, boolean(), default: false)
- field(:with_nil_default, term(), default: nil)
- end
-
- def enforce_keys, do: @enforce_keys
- end
+ alias GuardedStructTest.Fixtures.BasicTypes.EnforcedGuardedStruct
@bytecode bytecode
@bytecode_opaque bytecode_opaque
diff --git a/test/conditional_field_test.exs b/test/conditional_field_test.exs
index 8a7b9a3..6f909e1 100644
--- a/test/conditional_field_test.exs
+++ b/test/conditional_field_test.exs
@@ -7,7 +7,7 @@ defmodule GuardedStructTest.ConditionalFieldTest do
use GuardedStruct
guardedstruct do
- field(:post_id, integer(), derive: "validate(integer)")
+ field(:post_id, integer(), derives: "validate(integer)")
field(:like, boolean(), enforce: true)
end
end
@@ -17,7 +17,7 @@ defmodule GuardedStructTest.ConditionalFieldTest do
alias ConditionalFieldValidatorTestValidators, as: VAL
guardedstruct do
- field(:nickname, String.t(), derive: "validate(string, not_empty)")
+ field(:nickname, String.t(), derives: "validate(string, not_empty)")
# For domain
sub_field(:identity, struct()) do
@@ -40,13 +40,13 @@ defmodule GuardedStructTest.ConditionalFieldTest do
field(:city, String.t(),
enforce: true,
- derive: "sanitize(trim) validate(string, not_empty)"
+ derives: "sanitize(trim) validate(string, not_empty)"
)
end
field(:location, String.t(),
validator: {VAL, :is_string_data},
- derive: "sanitize(trim) validate(string, location)",
+ derives: "sanitize(trim) validate(string, location)",
hint: "location2"
)
end
@@ -74,7 +74,7 @@ defmodule GuardedStructTest.ConditionalFieldTest do
hint: "auth1",
structs: true,
validator: {VAL, :is_list_data},
- derive: "validate(not_flatten_empty_item)"
+ derives: "validate(not_flatten_empty_item)"
) do
field(:username, String.t(), enforce: true)
field(:provider, String.t(), enforce: true)
@@ -131,8 +131,8 @@ defmodule GuardedStructTest.ConditionalFieldTest do
field(:profile, String.t(), hint: "profile1", validator: {VAL, :is_string_data})
sub_field(:profile, struct(), hint: "profile2", validator: {VAL, :is_map_data}) do
- field(:name, String.t(), enforce: true, derive: "validate(not_empty)")
- field(:family, String.t(), enforce: true, derive: "validate(not_empty)")
+ field(:name, String.t(), enforce: true, derives: "validate(not_empty)")
+ field(:family, String.t(), enforce: true, derives: "validate(not_empty)")
end
end
@@ -151,7 +151,7 @@ defmodule GuardedStructTest.ConditionalFieldTest do
conditional_field(:address, any(), structs: true) do
sub_field(:address, struct(),
- derive: "sanitize(trim, upcase)",
+ derives: "sanitize(trim, upcase)",
validator: {VAL, :is_map_data},
hint: "address1"
) do
@@ -160,7 +160,7 @@ defmodule GuardedStructTest.ConditionalFieldTest do
end
field(:address, String.t(),
- derive: "sanitize(trim) validate(not_empty)",
+ derives: "sanitize(trim) validate(not_empty)",
hint: "address2",
validator: {VAL, :is_string_data}
)
@@ -171,13 +171,13 @@ defmodule GuardedStructTest.ConditionalFieldTest do
field(:username, String.t(),
enforce: true,
validator: {VAL, :is_string_data},
- derive: "sanitize(trim) validate(string)"
+ derives: "sanitize(trim) validate(string)"
)
field(:provider, String.t(), enforce: true)
end
- field(:extera_auth, String.t(), derive: "sanitize(trim) validate(string, not_empty)")
+ field(:extera_auth, String.t(), derives: "sanitize(trim) validate(string, not_empty)")
end
conditional_field(:extera_auth2, any(), structs: true) do
@@ -188,14 +188,14 @@ defmodule GuardedStructTest.ConditionalFieldTest do
field(:username, String.t(),
enforce: true,
validator: {VAL, :is_string_data},
- derive: "sanitize(trim) validate(string)"
+ derives: "sanitize(trim) validate(string)"
)
field(:provider, String.t(), enforce: true)
end
field(:extera_auth2, String.t(),
- derive: "sanitize(trim) validate(string, not_empty)",
+ derives: "sanitize(trim) validate(string, not_empty)",
hint: "extera_auth2",
validator: {VAL, :is_string_data}
)
@@ -234,7 +234,7 @@ defmodule GuardedStructTest.ConditionalFieldTest do
) do
field(:role, String.t(),
enforce: true,
- derive: "sanitize(trim) validate(string, not_empty)"
+ derives: "sanitize(trim) validate(string, not_empty)"
)
field(:action, String.t(), enforce: true)
@@ -257,11 +257,11 @@ defmodule GuardedStructTest.ConditionalFieldTest do
structs: true,
validator: {VAL, :is_flat_list_data},
hint: "activities2",
- derive: "validate(not_flatten_empty_item)"
+ derives: "validate(not_flatten_empty_item)"
) do
field(:role, String.t(),
enforce: true,
- derive: "sanitize(trim) validate(string, not_empty)"
+ derives: "sanitize(trim) validate(string, not_empty)"
)
field(:action, String.t(), enforce: true)
@@ -292,7 +292,7 @@ defmodule GuardedStructTest.ConditionalFieldTest do
structs: true,
enforce: true,
validator: {VAL, :is_flat_list_data},
- derive: "validate(not_flatten_empty_item)",
+ derives: "validate(not_flatten_empty_item)",
hint: "author2"
) do
field(:name, String.t())
@@ -365,7 +365,7 @@ defmodule GuardedStructTest.ConditionalFieldTest do
conditional_field(:activity5, any(),
structs: true,
- derive: "validate(not_flatten_empty_item)"
+ derives: "validate(not_flatten_empty_item)"
) do
field(:activity5, String.t(),
validator: {VAL, :is_string_data},
@@ -373,7 +373,7 @@ defmodule GuardedStructTest.ConditionalFieldTest do
)
end
- conditional_field(:activity6, any(), derive: "validate(map)") do
+ conditional_field(:activity6, any(), derives: "validate(map)") do
field(:activity6, String.t(),
validator: {VAL, :is_string_data},
hint: "activity1"
@@ -381,7 +381,7 @@ defmodule GuardedStructTest.ConditionalFieldTest do
end
conditional_field(:activity7, any(),
- derive: "sanitize(tag=strip_tags) validate(not_empty_string)"
+ derives: "sanitize(tag=strip_tags) validate(not_empty_string)"
) do
field(:activity7, String.t(),
validator: {VAL, :is_string_data},
diff --git a/test/core_keys_test.exs b/test/core_keys_test.exs
index c9c7c22..7ff5025 100644
--- a/test/core_keys_test.exs
+++ b/test/core_keys_test.exs
@@ -104,29 +104,29 @@ defmodule GuardedStructTest.CoreKeysTest do
guardedstruct authorized_fields: true do
field(:username, String.t(),
domain: "!auth.action=String[admin, user]::?auth.social=Atom[banned]",
- derive: "validate(string)"
+ derives: "validate(string)"
)
field(:type_social, String.t(),
domain: "?auth.type=Map[%{name: \"mishka\"}, %{name: \"mishka2\"}]",
- derive: "validate(string)"
+ derives: "validate(string)"
)
field(:social_equal, atom(),
domain: "?auth.equal=Equal[Atom>>name]",
- derive: "validate(atom)"
+ derives: "validate(atom)"
)
field(:social_either, atom(),
domain: "?auth.either=Either[string, enum>>Integer[1>>2>>3]]",
- derive: "validate(atom)"
+ derives: "validate(atom)"
)
sub_field(:auth, struct(), authorized_fields: true) do
- field(:action, String.t(), derive: "validate(not_empty)")
- field(:social, atom(), derive: "validate(atom)")
- field(:type, map(), derive: "validate(map)")
- field(:equal, atom(), derive: "validate(atom)")
+ field(:action, String.t(), derives: "validate(not_empty)")
+ field(:social, atom(), derives: "validate(atom)")
+ field(:type, map(), derives: "validate(map)")
+ field(:equal, atom(), derives: "validate(atom)")
field(:either, atom())
end
end
@@ -139,21 +139,21 @@ defmodule GuardedStructTest.CoreKeysTest do
guardedstruct authorized_fields: true do
field(:username, String.t(),
domain: "!auth.action=Custom[#{@module_path}, is_stuff?]",
- derive: "validate(string)"
+ derives: "validate(string)"
)
sub_field(:auth, struct(), authorized_fields: true) do
- field(:action, String.t(), derive: "validate(not_empty)")
+ field(:action, String.t(), derives: "validate(not_empty)")
end
conditional_field(:id, String.t(), auto: {GuardedStructTest.Support.UUID, :generate}) do
field(:id, String.t(),
- derive: "sanitize(tag=strip_tags) validate(url, max_len=160)",
+ derives: "sanitize(tag=strip_tags) validate(url, max_len=160)",
hint: "url_id"
)
field(:id, any(),
- derive: "sanitize(tag=strip_tags) validate(not_empty_string, uuid)",
+ derives: "sanitize(tag=strip_tags) validate(not_empty_string, uuid)",
hint: "uuid_id"
)
end
diff --git a/test/derive_extension_shadow_warning_test.exs b/test/derive_extension_shadow_warning_test.exs
new file mode 100644
index 0000000..091230b
--- /dev/null
+++ b/test/derive_extension_shadow_warning_test.exs
@@ -0,0 +1,203 @@
+defmodule GuardedStructTest.DeriveExtensionShadowWarningTest do
+ @moduledoc """
+ Tests the compile-time shadow warning emitted by the
+ `GuardedStruct.Derive.Extension.Transformers.Codegen` transformer.
+
+ When a user declares a custom op (inside `derives do ... end`) whose
+ name collides with a built-in (registered in
+ `GuardedStruct.Derive.Registry`), the built-in's pattern-matched
+ function clause in `ValidationDerive` / `SanitizerDerive` matches
+ first — so the custom version would be dead code. We warn at compile
+ time via `IO.warn/2`.
+ """
+
+ # async: false — `capture_io(:stderr, ...)` is process-global.
+ use ExUnit.Case, async: false
+
+ import ExUnit.CaptureIO
+
+ defp compile_capture(code) do
+ capture_io(:stderr, fn -> Code.eval_string(code) end)
+ end
+
+ describe "validator shadow warning" do
+ test "warns when a validator shadows a built-in (e.g. :string)" do
+ output =
+ compile_capture("""
+ defmodule ShadowsString do
+ use GuardedStruct.Derive.Extension
+ derives do
+ validator :string, fn _ -> true end
+ end
+ end
+ """)
+
+ assert output =~ "validator"
+ assert output =~ ":string"
+ assert output =~ "shadows a built-in"
+ assert output =~ "NEVER be called"
+ assert output =~ "Rename it"
+ end
+
+ test "warns when validator shadows :integer, :email_r, :uuid, etc." do
+ for name <- [:integer, :email_r, :uuid, :url, :max_len, :not_empty] do
+ output =
+ compile_capture("""
+ defmodule Shadows#{Macro.camelize(to_string(name))} do
+ use GuardedStruct.Derive.Extension
+ derives do
+ validator #{inspect(name)}, fn _ -> true end
+ end
+ end
+ """)
+
+ assert output =~ "shadows a built-in",
+ "expected warning for validator #{inspect(name)}, got: #{inspect(output)}"
+
+ assert output =~ inspect(name)
+ end
+ end
+
+ test "warning includes the user module name for grep-ability" do
+ output =
+ compile_capture("""
+ defmodule MyAppShadowsString do
+ use GuardedStruct.Derive.Extension
+ derives do
+ validator :string, fn _ -> true end
+ end
+ end
+ """)
+
+ assert output =~ "MyAppShadowsString"
+ end
+
+ test "DOES NOT warn for non-shadowing names" do
+ output =
+ compile_capture("""
+ defmodule NoShadowingValidator do
+ use GuardedStruct.Derive.Extension
+ derives do
+ validator :my_custom_op, fn _ -> true end
+ end
+ end
+ """)
+
+ refute output =~ "shadows a built-in"
+ end
+ end
+
+ describe "sanitizer shadow warning" do
+ test "warns when a sanitizer shadows a built-in (e.g. :trim)" do
+ output =
+ compile_capture("""
+ defmodule ShadowsTrim do
+ use GuardedStruct.Derive.Extension
+ derives do
+ sanitizer :trim, fn input -> input end
+ end
+ end
+ """)
+
+ assert output =~ "sanitizer"
+ assert output =~ ":trim"
+ assert output =~ "shadows a built-in"
+ end
+
+ test "warns for each built-in sanitizer name shadowed" do
+ for name <- [:downcase, :upcase, :capitalize, :strip_tags, :basic_html] do
+ output =
+ compile_capture("""
+ defmodule ShadowsSanitize#{Macro.camelize(to_string(name))} do
+ use GuardedStruct.Derive.Extension
+ derives do
+ sanitizer #{inspect(name)}, fn input -> input end
+ end
+ end
+ """)
+
+ assert output =~ "shadows a built-in",
+ "expected warning for sanitizer #{inspect(name)}"
+ end
+ end
+
+ test "DOES NOT warn for non-shadowing names" do
+ output =
+ compile_capture("""
+ defmodule NoShadowingSanitizer do
+ use GuardedStruct.Derive.Extension
+ derives do
+ sanitizer :my_slugify, fn input -> input end
+ end
+ end
+ """)
+
+ refute output =~ "shadows a built-in"
+ end
+ end
+
+ describe "mixed shadowing in one extension module" do
+ test "emits ONE warning per shadow, none for clean names" do
+ output =
+ compile_capture("""
+ defmodule MixedShadowingExt do
+ use GuardedStruct.Derive.Extension
+ derives do
+ validator :string, fn _ -> true end # shadow
+ validator :my_custom, fn _ -> true end # clean
+ sanitizer :trim, fn input -> input end # shadow
+ sanitizer :my_clean_op, fn input -> input end # clean
+ end
+ end
+ """)
+
+ # Two warnings — one per shadow
+ shadow_count =
+ output
+ |> String.split("shadows a built-in")
+ |> length()
+ |> Kernel.-(1)
+
+ assert shadow_count == 2
+
+ # Both shadowed names mentioned, clean names not:
+ assert output =~ ":string"
+ assert output =~ ":trim"
+ refute output =~ ":my_custom"
+ refute output =~ ":my_clean_op"
+ end
+ end
+
+ describe "shadow validator is actually dead code at runtime" do
+ test "built-in :string wins; the custom one is never called" do
+ output =
+ capture_io(:stderr, fn ->
+ Code.eval_string("""
+ defmodule DeadCodeExt do
+ use GuardedStruct.Derive.Extension
+ derives do
+ validator :string, fn _input -> true end
+ end
+ end
+
+ defmodule UsesDeadCodeExt do
+ use GuardedStruct, derive_extensions: [DeadCodeExt]
+ guardedstruct do
+ field :name, String.t(), derives: "validate(string)"
+ end
+ end
+ """)
+ end)
+
+ # Confirm the warning fired (proof we're testing the right path):
+ assert output =~ "shadows a built-in"
+
+ # If the custom validator were live, `123` (an integer) would pass
+ # because the custom fn always returns true. It DOESN'T pass because
+ # the built-in :string clause matches first and rejects non-binaries.
+ mod = :"Elixir.UsesDeadCodeExt"
+ assert {:error, _} = apply(mod, :builder, [%{name: 123}])
+ assert {:ok, _} = apply(mod, :builder, [%{name: "real string"}])
+ end
+ end
+end
diff --git a/test/derive_extension_test.exs b/test/derive_extension_test.exs
new file mode 100644
index 0000000..56e2af4
--- /dev/null
+++ b/test/derive_extension_test.exs
@@ -0,0 +1,42 @@
+defmodule GuardedStructTest.DeriveExtensionTest do
+ use ExUnit.Case, async: false
+
+ alias GuardedStructTest.Fixtures.DeriveExtension.{SlugDerives, WithSlug, WithSlugify}
+
+ setup do
+ Application.put_env(:guarded_struct, :derive_extensions, [SlugDerives])
+ on_exit(fn -> Application.delete_env(:guarded_struct, :derive_extensions) end)
+ :ok
+ end
+
+ test "registered extension exposes its validator/sanitizer names" do
+ assert SlugDerives.__validators__() == [:slug]
+ assert SlugDerives.__sanitizers__() == [:slugify]
+ assert SlugDerives.__derive_extension__?()
+ end
+
+ test "extension validator runs against input" do
+ assert {:ok, %{slug: "valid-slug"}} = WithSlug.builder(%{slug: "valid-slug"})
+
+ assert {:error, [%{field: :slug, action: :slug}]} =
+ WithSlug.builder(%{slug: "Not Valid Slug!"})
+ end
+
+ test "extension sanitizer runs against input" do
+ assert {:ok, %{slug: "hello-world"}} = WithSlugify.builder(%{slug: "Hello World!"})
+ end
+
+ test "extension dispatch finds the registered op" do
+ assert "abc" = GuardedStruct.Derive.Extension.dispatch_validate(:slug, "abc", :test)
+
+ assert {:error, :test, :slug, _} =
+ GuardedStruct.Derive.Extension.dispatch_validate(:slug, "AB!", :test)
+
+ assert :__not_found__ =
+ GuardedStruct.Derive.Extension.dispatch_validate(:nonexistent, "x", :test)
+ end
+
+ test "all_extension_validators aggregates across registered modules" do
+ assert :slug in MapSet.to_list(GuardedStruct.Derive.Extension.all_extension_validators())
+ end
+end
diff --git a/test/derive_extensions_per_module_test.exs b/test/derive_extensions_per_module_test.exs
new file mode 100644
index 0000000..64e0ed8
--- /dev/null
+++ b/test/derive_extensions_per_module_test.exs
@@ -0,0 +1,432 @@
+defmodule GuardedStructTest.DeriveExtensionsPerModuleTest do
+ @moduledoc """
+ Tests the `use GuardedStruct, derive_extensions: [...]` per-module opt
+ and the `:config` sentinel that merges the global Application config
+ in-position.
+
+ Resolution rules covered:
+
+ * No opt → falls back to global Application config (legacy behavior)
+ * `[]` → opts OUT entirely (no extensions, global ignored)
+ * `[A]` → REPLACE global with [A]
+ * `[:config, A]` → global ++ [A] (global wins on :slug-style collisions)
+ * `[A, :config]` → [A] ++ global (A wins on collisions)
+ * `[A, :config, B]` → [A] ++ global ++ [B] (in-position merge)
+ * `[:config, :config]` → ArgumentError at compile time
+ * Non-atom entry → ArgumentError at compile time
+ * Non-list opt → ArgumentError at compile time
+ """
+
+ # async: false — we mutate Application env, which is process-global.
+ use ExUnit.Case, async: false
+
+ # Two extension modules with a deliberately overlapping op name `:slug`
+ # so we can prove who wins on collisions.
+
+ defmodule GlobalDerives do
+ use GuardedStruct.Derive.Extension
+
+ derives do
+ # Accepts only "global:..." prefixed slugs
+ validator :slug, fn input ->
+ is_binary(input) and String.starts_with?(input, "global:")
+ end
+
+ # Only here
+ validator :uuid7, fn input -> is_binary(input) end
+ end
+ end
+
+ defmodule LocalDerives do
+ use GuardedStruct.Derive.Extension
+
+ derives do
+ # Accepts only "local:..." prefixed slugs (collides with GlobalDerives.:slug)
+ validator :slug, fn input ->
+ is_binary(input) and String.starts_with?(input, "local:")
+ end
+
+ # Only here
+ validator :ksuid, fn input -> is_binary(input) end
+ end
+ end
+
+ defmodule ExtraDerives do
+ use GuardedStruct.Derive.Extension
+
+ derives do
+ validator :phone, fn input -> is_binary(input) end
+ end
+ end
+
+ setup do
+ # Each test starts with GlobalDerives wired up; we restore afterwards
+ # so the suite is hermetic.
+ previous = Application.get_env(:guarded_struct, :derive_extensions, [])
+ Application.put_env(:guarded_struct, :derive_extensions, [GlobalDerives])
+ on_exit(fn -> Application.put_env(:guarded_struct, :derive_extensions, previous) end)
+ :ok
+ end
+
+ # ---------------- 1. No per-module opt → global is used ----------------
+
+ describe "no derive_extensions: opt → falls back to global config" do
+ defmodule NoOpt do
+ use GuardedStruct
+
+ guardedstruct do
+ field(:slug, String.t(), derives: "validate(slug)")
+ end
+ end
+
+ test "GlobalDerives.:slug active (only 'global:...' accepted)" do
+ assert {:ok, _} = NoOpt.builder(%{slug: "global:hello"})
+
+ assert {:error, errs} = NoOpt.builder(%{slug: "local:hello"})
+ assert Enum.any?(errs, &(&1[:field] == :slug and &1[:action] == :slug))
+ end
+
+ test "__guarded_derive_extensions_opt__/0 returns nil (no opt set)" do
+ assert NoOpt.__guarded_derive_extensions_opt__() == nil
+ end
+ end
+
+ # ---------------- 2. Empty list → opt-OUT (no extensions at all) ----------------
+
+ describe "derive_extensions: [] → opts out entirely" do
+ defmodule EmptyOpt do
+ use GuardedStruct, derive_extensions: []
+
+ guardedstruct do
+ field(:name, String.t(), derives: "validate(string)")
+ end
+ end
+
+ test "global is ignored — :slug is no longer known if we tried it" do
+ # Use a built-in op so the field still validates; the point is to
+ # confirm that the module's extension list resolves to [].
+ assert {:ok, _} = EmptyOpt.builder(%{name: "x"})
+
+ assert EmptyOpt.__guarded_derive_extensions_opt__() == []
+
+ resolved =
+ GuardedStruct.Derive.Extension.resolve_opt(EmptyOpt.__guarded_derive_extensions_opt__())
+
+ assert resolved == []
+ end
+ end
+
+ # ---------------- 3. Per-module list (no :config) → REPLACE global ----------------
+
+ describe "derive_extensions: [LocalDerives] → REPLACES global" do
+ defmodule ReplaceOpt do
+ use GuardedStruct, derive_extensions: [LocalDerives]
+
+ guardedstruct do
+ field(:slug, String.t(), derives: "validate(slug)")
+ end
+ end
+
+ test "LocalDerives.:slug active — only 'local:...' accepted" do
+ assert {:ok, _} = ReplaceOpt.builder(%{slug: "local:hello"})
+
+ assert {:error, errs} = ReplaceOpt.builder(%{slug: "global:hello"})
+ assert Enum.any?(errs, &(&1[:field] == :slug))
+ end
+
+ test "global is COMPLETELY ignored — :uuid7 (only in global) becomes unknown" do
+ defmodule ReplaceOptUuid7 do
+ use GuardedStruct, derive_extensions: [LocalDerives]
+
+ guardedstruct do
+ # :uuid7 exists ONLY in GlobalDerives, which is bypassed entirely
+ # since we REPLACE (no :config). The op is unknown to the runtime
+ # → fallback_dispatch returns a :type error.
+ field(:id, String.t(), derives: "validate(uuid7)")
+ end
+ end
+
+ assert {:error, errs} = ReplaceOptUuid7.builder(%{id: "anything"})
+ assert Enum.any?(errs, &(&1[:field] == :id and &1[:action] == :type))
+ end
+ end
+
+ # ---------------- 4. [:config, Local] → global ++ [Local], GLOBAL wins ----------------
+
+ describe "derive_extensions: [:config, LocalDerives] → global first" do
+ defmodule ConfigFirst do
+ use GuardedStruct, derive_extensions: [:config, LocalDerives]
+
+ guardedstruct do
+ field(:slug, String.t(), derives: "validate(slug)")
+ field(:tag, String.t(), derives: "validate(ksuid)")
+ end
+ end
+
+ test "GLOBAL :slug wins on collision (first in list)" do
+ assert {:ok, _} = ConfigFirst.builder(%{slug: "global:x", tag: "k"})
+
+ # LocalDerives'.slug is shadowed → "local:..." rejected
+ assert {:error, errs} = ConfigFirst.builder(%{slug: "local:x", tag: "k"})
+ assert Enum.any?(errs, &(&1[:field] == :slug))
+ end
+
+ test "non-colliding local op (:ksuid) is still available" do
+ assert {:ok, _} = ConfigFirst.builder(%{slug: "global:x", tag: "any-ksuid"})
+ end
+ end
+
+ # ---------------- 5. [Local, :config] → [Local] ++ global, LOCAL wins ----------------
+
+ describe "derive_extensions: [LocalDerives, :config] → local first" do
+ defmodule ConfigLast do
+ use GuardedStruct, derive_extensions: [LocalDerives, :config]
+
+ guardedstruct do
+ field(:slug, String.t(), derives: "validate(slug)")
+ field(:other, String.t(), derives: "validate(uuid7)")
+ end
+ end
+
+ test "LOCAL :slug wins on collision (first in list)" do
+ assert {:ok, _} = ConfigLast.builder(%{slug: "local:x", other: "x"})
+
+ assert {:error, _} = ConfigLast.builder(%{slug: "global:x", other: "x"})
+ end
+
+ test "global-only op (:uuid7) still available via fall-through" do
+ # uuid7 doesn't exist in LocalDerives — falls through to GlobalDerives
+ assert {:ok, _} = ConfigLast.builder(%{slug: "local:x", other: "anything"})
+ end
+ end
+
+ # ---------------- 6. [A, :config, B] → A ++ global ++ B (in-position) ----------------
+
+ describe "derive_extensions: [LocalDerives, :config, ExtraDerives] → in-position merge" do
+ defmodule InPosition do
+ use GuardedStruct, derive_extensions: [LocalDerives, :config, ExtraDerives]
+
+ guardedstruct do
+ field(:slug, String.t(), derives: "validate(slug)")
+ field(:tag, String.t(), derives: "validate(ksuid)")
+ field(:tel, String.t(), derives: "validate(phone)")
+ field(:id, String.t(), derives: "validate(uuid7)")
+ end
+ end
+
+ test "LOCAL :slug wins (first in resolved list)" do
+ assert {:ok, _} =
+ InPosition.builder(%{slug: "local:x", tag: "k", tel: "p", id: "u"})
+
+ assert {:error, _} =
+ InPosition.builder(%{slug: "global:x", tag: "k", tel: "p", id: "u"})
+ end
+
+ test "ops from all three sources are available" do
+ # :ksuid from LocalDerives, :uuid7 from GlobalDerives, :phone from ExtraDerives
+ assert {:ok, _} =
+ InPosition.builder(%{slug: "local:x", tag: "k", tel: "p", id: "u"})
+ end
+ end
+
+ # ---------------- 7. Compile-time validation ----------------
+
+ describe "compile-time validation of the opt" do
+ test ":config more than once → ArgumentError" do
+ msg =
+ try do
+ Code.eval_string("""
+ defmodule DoubleConfig do
+ use GuardedStruct, derive_extensions: [:config, :config]
+ end
+ """)
+
+ :no_raise
+ rescue
+ e -> Exception.message(e)
+ end
+
+ assert is_binary(msg), "expected an ArgumentError, got no raise"
+ assert msg =~ ":config more than once"
+ end
+
+ test "non-atom entry → ArgumentError" do
+ msg =
+ try do
+ Code.eval_string("""
+ defmodule BadEntry do
+ use GuardedStruct, derive_extensions: [:config, "not a module"]
+ end
+ """)
+
+ :no_raise
+ rescue
+ e -> Exception.message(e)
+ end
+
+ assert is_binary(msg)
+ assert msg =~ "must be modules or :config"
+ end
+
+ test "non-list opt → ArgumentError" do
+ msg =
+ try do
+ Code.eval_string("""
+ defmodule NotAList do
+ use GuardedStruct, derive_extensions: GlobalDerives
+ end
+ """)
+
+ :no_raise
+ rescue
+ e -> Exception.message(e)
+ end
+
+ assert is_binary(msg)
+ assert msg =~ "expected a list"
+ end
+ end
+
+ # ---------------- 8. Resolver function — direct unit tests ----------------
+
+ describe "Extension.resolve_opt/1 unit tests" do
+ alias GuardedStruct.Derive.Extension
+
+ test "nil → falls back to global" do
+ # Global is set to [GlobalDerives] by setup
+ assert Extension.resolve_opt(nil) == [GlobalDerives]
+ end
+
+ test "empty list → no extensions" do
+ assert Extension.resolve_opt([]) == []
+ end
+
+ test "[Local] → [Local] (global ignored)" do
+ assert Extension.resolve_opt([LocalDerives]) == [LocalDerives]
+ end
+
+ test "[:config, Local] → [Global, Local] (in declaration order)" do
+ assert Extension.resolve_opt([:config, LocalDerives]) ==
+ [GlobalDerives, LocalDerives]
+ end
+
+ test "[Local, :config] → [Local, Global]" do
+ assert Extension.resolve_opt([LocalDerives, :config]) ==
+ [LocalDerives, GlobalDerives]
+ end
+
+ test "[A, :config, B] → [A, Global, B]" do
+ assert Extension.resolve_opt([LocalDerives, :config, ExtraDerives]) ==
+ [LocalDerives, GlobalDerives, ExtraDerives]
+ end
+ end
+
+ # ---------------- 9. Pdict isolation — nested external-struct builds ----------------
+
+ describe "process dict isolation across nested builders" do
+ defmodule UsesLocal do
+ use GuardedStruct, derive_extensions: [LocalDerives]
+
+ guardedstruct do
+ field(:slug, String.t(), derives: "validate(slug)")
+ end
+ end
+
+ defmodule UsesGlobal do
+ use GuardedStruct
+
+ guardedstruct do
+ field(:slug, String.t(), derives: "validate(slug)")
+ field(:inner, struct(), struct: UsesLocal)
+ end
+ end
+
+ test "outer module's extensions don't leak into inner builder" do
+ # The outer struct's :slug uses GLOBAL (no opt → global).
+ # The inner struct's :slug uses LOCAL (per-module opt).
+ # Both must enforce their own rule simultaneously.
+ assert {:ok, _} =
+ UsesGlobal.builder(%{
+ slug: "global:outer",
+ inner: %{slug: "local:inner"}
+ })
+
+ # Outer accepts "global:..." but inner rejects it (it expects "local:...")
+ assert {:error, _} =
+ UsesGlobal.builder(%{
+ slug: "global:outer",
+ inner: %{slug: "global:outer"}
+ })
+
+ # Outer rejects "local:..." while inner accepts it.
+ assert {:error, _} =
+ UsesGlobal.builder(%{
+ slug: "local:outer",
+ inner: %{slug: "local:inner"}
+ })
+ end
+
+ test "Process dict is cleaned up after build (no leak between calls)" do
+ refute Process.get(:guarded_struct_current_module)
+ {:ok, _} = UsesGlobal.builder(%{slug: "global:x", inner: %{slug: "local:y"}})
+ refute Process.get(:guarded_struct_current_module)
+ end
+ end
+
+ # ---------------- 10. Sub_field submodules inherit parent's extensions ----------------
+
+ describe "sub_field submodules use the parent's per-module extensions" do
+ defmodule WithSub do
+ use GuardedStruct, derive_extensions: [LocalDerives]
+
+ guardedstruct do
+ sub_field(:nested, struct()) do
+ field(:slug, String.t(), derives: "validate(slug)")
+ end
+ end
+ end
+
+ test "field inside a sub_field uses the parent's :slug extension" do
+ # WithSub's parent extension list is [LocalDerives]. The auto-generated
+ # WithSub.Nested submodule doesn't have its own use GuardedStruct, so
+ # its derive validation should use LocalDerives via the process dict.
+ assert {:ok, _} = WithSub.builder(%{nested: %{slug: "local:hello"}})
+
+ assert {:error, _} = WithSub.builder(%{nested: %{slug: "global:hello"}})
+ end
+ end
+
+ # ---------------- 11. Application.put_env after compile is honoured ----------------
+
+ describe ":config sentinel resolves at lookup time, not compile time" do
+ defmodule LazyConfig do
+ use GuardedStruct, derive_extensions: [:config, LocalDerives]
+
+ guardedstruct do
+ field(:slug, String.t(), derives: "validate(slug)")
+ end
+ end
+
+ test "swapping the global config affects already-compiled modules" do
+ # Setup put [GlobalDerives] globally — LazyConfig accepts "global:..."
+ assert {:ok, _} = LazyConfig.builder(%{slug: "global:x"})
+
+ # Now swap the global config to [ExtraDerives] which has no :slug op.
+ # LazyConfig should now ONLY have LocalDerives.slug active.
+ Application.put_env(:guarded_struct, :derive_extensions, [ExtraDerives])
+
+ try do
+ # "global:..." no longer accepted — GlobalDerives is gone
+ assert {:error, _} = LazyConfig.builder(%{slug: "global:x"})
+
+ # "local:..." still works — LocalDerives still in the per-module opt
+ assert {:ok, _} = LazyConfig.builder(%{slug: "local:x"})
+ after
+ # The outer setup's on_exit will restore the original; we just
+ # restore the test's expected value here so subsequent tests in
+ # this describe block aren't affected.
+ Application.put_env(:guarded_struct, :derive_extensions, [GlobalDerives])
+ end
+ end
+ end
+end
diff --git a/test/derive_rules_decorator_test.exs b/test/derive_rules_decorator_test.exs
new file mode 100644
index 0000000..f0c7f33
--- /dev/null
+++ b/test/derive_rules_decorator_test.exs
@@ -0,0 +1,47 @@
+defmodule GuardedStructTest.DeriveRulesDecoratorTest do
+ use ExUnit.Case, async: true
+
+ alias GuardedStructTest.Fixtures.DeriveRulesDecorator.{
+ Decorated,
+ Inline,
+ WithAlias,
+ WithBoth,
+ WithSub
+ }
+
+ test "decorated form parses + validates the same as inline" do
+ assert {:ok, %Decorated{name: "Alice", age: 30, plain: "x"}} =
+ Decorated.builder(%{name: "Alice", age: 30, plain: "x"})
+
+ assert {:ok, %Inline{name: "Alice", age: 30, plain: "x"}} =
+ Inline.builder(%{name: "Alice", age: 30, plain: "x"})
+ end
+
+ test "decorator catches the same validation errors" do
+ {:error, errs} = Decorated.builder(%{name: "this name is way too long", age: -5})
+
+ assert Enum.any?(errs, &(&1[:field] == :name and &1[:action] == :max_len))
+ assert Enum.any?(errs, &(&1[:field] == :age and &1[:action] == :min_len))
+ end
+
+ test "@derive_rules is one-shot — only consumed by the very next field" do
+ {:ok, %Decorated{plain: "anything works"}} =
+ Decorated.builder(%{name: "ok", age: 1, plain: "anything works"})
+ end
+
+ test "@derives is also accepted as an alias" do
+ assert {:ok, %WithAlias{name: "ok"}} = WithAlias.builder(%{name: "ok"})
+
+ {:error, errs} = WithAlias.builder(%{name: "this is too long"})
+ assert Enum.any?(errs, &(&1[:action] == :max_len))
+ end
+
+ test "explicit derives: opt wins if both are present" do
+ # Inline derives: takes precedence — long names allowed
+ assert {:ok, _} = WithBoth.builder(%{name: "much longer than five chars"})
+ end
+
+ test "decorator works on sub_field too" do
+ assert {:ok, _} = WithSub.builder(%{auth: %{role: "admin"}})
+ end
+end
diff --git a/test/derive_test.exs b/test/derive_test.exs
index 64f4c60..86d04cf 100644
--- a/test/derive_test.exs
+++ b/test/derive_test.exs
@@ -649,9 +649,9 @@ defmodule GuardedStructTest.DeriveTest do
use GuardedStruct
guardedstruct do
- field(:id, integer(), derive: "validate(not_exist)")
- field(:title, String.t(), derive: "validate(string)")
- field(:name, String.t(), derive: "sanitize(capitalize_v2)")
+ field(:id, integer(), derives: "validate(not_exist)")
+ field(:title, String.t(), derives: "validate(string)")
+ field(:name, String.t(), derives: "sanitize(capitalize_v2)")
end
end
@@ -660,10 +660,10 @@ defmodule GuardedStructTest.DeriveTest do
guardedstruct do
field(:id, integer())
- field(:title, String.t(), derive: "validate(not_empty, testv1)")
- field(:name, String.t(), derive: "validate(string, not_empty) sanitize(trim, capitalize)")
- field(:last_name, String.t(), derive: "sanitize(capitalize_v1")
- field(:nikname, String.t(), derive: "sanitize(not_exist")
+ field(:title, String.t(), derives: "validate(not_empty, testv1)")
+ field(:name, String.t(), derives: "validate(string, not_empty) sanitize(trim, capitalize)")
+ field(:last_name, String.t(), derives: "sanitize(capitalize_v1")
+ field(:nikname, String.t(), derives: "sanitize(not_exist")
end
end
@@ -709,14 +709,14 @@ defmodule GuardedStructTest.DeriveTest do
guardedstruct do
field(:id, integer())
- field(:title, String.t(), derive: "validate(not_empty, testv2)")
+ field(:title, String.t(), derives: "validate(not_empty, testv2)")
field(:name, String.t(),
- derive: "validate(string, not_empty) sanitize(trim, capitalize_v2)"
+ derives: "validate(string, not_empty) sanitize(trim, capitalize_v2)"
)
- field(:last_name, String.t(), derive: "sanitize(capitalize_v1")
- field(:nikname, String.t(), derive: "sanitize(not_exist")
+ field(:last_name, String.t(), derives: "sanitize(capitalize_v1")
+ field(:nikname, String.t(), derives: "sanitize(not_exist")
end
end
@@ -742,8 +742,8 @@ defmodule GuardedStructTest.DeriveTest do
use GuardedStruct
guardedstruct do
- field(:test, String.t(), derive: "validate(either=[integer, max_len=4])")
- field(:test1, String.t(), derive: "validate(either=[string, enum=Integer[1::2::3]])")
+ field(:test, String.t(), derives: "validate(either=[integer, max_len=4])")
+ field(:test1, String.t(), derives: "validate(either=[string, enum=Integer[1::2::3]])")
end
end
@@ -773,7 +773,7 @@ defmodule GuardedStructTest.DeriveTest do
use GuardedStruct
guardedstruct authorized_fields: true do
- field(:status, String.t(), derive: "validate(custom=[#{__MODULE__}, is_stuff?])")
+ field(:status, String.t(), derives: "validate(custom=[#{__MODULE__}, is_stuff?])")
end
def is_stuff?(data) when data == "ok", do: true
diff --git a/test/derives_deprecation_test.exs b/test/derives_deprecation_test.exs
new file mode 100644
index 0000000..13da979
--- /dev/null
+++ b/test/derives_deprecation_test.exs
@@ -0,0 +1,106 @@
+defmodule GuardedStructTest.DerivesDeprecationTest do
+ # async: false — we capture compile-time warnings via ExUnit.CaptureIO,
+ # which is process-global.
+ use ExUnit.Case, async: false
+
+ import ExUnit.CaptureIO
+
+ # Dynamic-eval helper. Returns {stderr_output, last_value_of_evaled_code}.
+ # Wrapping the assertion-targeted module references in `apply/3` keeps the
+ # compiler from emitting "module is undefined" warnings at static-analysis
+ # time, since `Code.eval_string` defines them only at runtime.
+ defp eval_with_stderr(code) do
+ {output, result} =
+ with_io_capture(fn -> Code.eval_string(code) end)
+
+ {output, result}
+ end
+
+ defp with_io_capture(fun) do
+ parent = self()
+ ref = make_ref()
+
+ output =
+ capture_io(:stderr, fn ->
+ send(parent, {ref, fun.()})
+ end)
+
+ receive do
+ {^ref, val} -> {output, val}
+ after
+ 0 -> {output, nil}
+ end
+ end
+
+ alias GuardedStructTest.Fixtures.DerivesDeprecation.CanonicalName
+
+ test "derives: works as the canonical name" do
+ assert {:ok, %{name: "ok"}} = CanonicalName.builder(%{name: "ok"})
+
+ {:error, errs} = CanonicalName.builder(%{name: "this is way too long"})
+ assert Enum.any?(errs, &(&1[:action] == :max_len))
+ end
+
+ test "legacy derive: still works but emits a deprecation warning at compile time" do
+ {output, _} =
+ eval_with_stderr("""
+ defmodule LegacyDeriveStillWorks do
+ use GuardedStruct
+
+ guardedstruct do
+ field(:name, String.t(), derive: "validate(string, max_len=10)")
+ end
+ end
+ """)
+
+ assert output =~ "deprecated"
+ assert output =~ "derive:"
+ assert output =~ "Use `derives:`"
+
+ # `apply/3` with an atom avoids the static-analysis "undefined module" warning.
+ mod = :"Elixir.LegacyDeriveStillWorks"
+ assert {:ok, %{name: "ok"}} = apply(mod, :builder, [%{name: "ok"}])
+
+ {:error, errs} = apply(mod, :builder, [%{name: "this is way too long"}])
+ assert Enum.any?(errs, &(&1[:action] == :max_len))
+ end
+
+ test "when both derives: and derive: are set, derives: wins (no warning emitted)" do
+ {output, _} =
+ eval_with_stderr("""
+ defmodule BothSet do
+ use GuardedStruct
+
+ guardedstruct do
+ field(:name, String.t(),
+ derives: "validate(string, max_len=100)",
+ derive: "validate(string, max_len=5)"
+ )
+ end
+ end
+ """)
+
+ # derives: wins, so the legacy derive: is never read — no warning.
+ refute output =~ "deprecated"
+
+ # derives: wins → 100-char limit applies, not the 5-char one.
+ mod = :"Elixir.BothSet"
+ assert {:ok, _} = apply(mod, :builder, [%{name: "longer than five chars"}])
+ end
+
+ test "deprecation warning mentions the field name and module" do
+ {output, _} =
+ eval_with_stderr("""
+ defmodule DeprecationLocation do
+ use GuardedStruct
+
+ guardedstruct do
+ field(:my_specific_field, String.t(), derive: "validate(string)")
+ end
+ end
+ """)
+
+ assert output =~ "my_specific_field"
+ assert output =~ "DeprecationLocation"
+ end
+end
diff --git a/test/diff_test.exs b/test/diff_test.exs
new file mode 100644
index 0000000..28c7dd8
--- /dev/null
+++ b/test/diff_test.exs
@@ -0,0 +1,112 @@
+defmodule GuardedStructTest.DiffTest do
+ use ExUnit.Case, async: true
+
+ alias GuardedStruct.Diff
+ alias GuardedStructTest.Fixtures.Diff.{User, Other, Other2}
+
+ describe "diff/2" do
+ test "two equal structs return %{}" do
+ {:ok, a} = User.builder(%{name: "Alice", age: 30, role: "admin"})
+ {:ok, b} = User.builder(%{name: "Alice", age: 30, role: "admin"})
+
+ assert Diff.diff(a, b) == %{}
+ end
+
+ test "primitive field change returns :changed tuple" do
+ {:ok, a} = User.builder(%{name: "Alice", age: 30})
+ {:ok, b} = User.builder(%{name: "Alice", age: 31})
+
+ assert Diff.diff(a, b) == %{age: {:changed, 30, 31}}
+ end
+
+ test "multiple field changes are aggregated" do
+ {:ok, a} = User.builder(%{name: "Alice", age: 30, role: "admin"})
+ {:ok, b} = User.builder(%{name: "Bob", age: 31, role: "admin"})
+
+ assert %{name: {:changed, "Alice", "Bob"}, age: {:changed, 30, 31}} = Diff.diff(a, b)
+ end
+
+ test "nested struct change recurses" do
+ {:ok, a} = User.builder(%{name: "Alice", address: %{city: "NYC", zip: "10001"}})
+ {:ok, b} = User.builder(%{name: "Alice", address: %{city: "Chicago", zip: "10001"}})
+
+ assert %{address: %{city: {:changed, "NYC", "Chicago"}}} = Diff.diff(a, b)
+ end
+
+ test "nested struct unchanged → not in diff" do
+ {:ok, a} = User.builder(%{name: "Alice", address: %{city: "NYC", zip: "10001"}})
+ {:ok, b} = User.builder(%{name: "Bob", address: %{city: "NYC", zip: "10001"}})
+
+ result = Diff.diff(a, b)
+ refute Map.has_key?(result, :address)
+ assert Map.has_key?(result, :name)
+ end
+
+ test "two structs of different types return :not_comparable" do
+ {:ok, a} = User.builder(%{name: "Alice"})
+
+ assert Diff.diff(a, %Other{x: 1}) == :not_comparable
+ end
+
+ test "plain maps work too" do
+ assert Diff.diff(%{a: 1, b: 2}, %{a: 1, b: 3}) == %{b: {:changed, 2, 3}}
+ end
+ end
+
+ describe "apply/2" do
+ test "applies a primitive change" do
+ {:ok, a} = User.builder(%{name: "Alice", age: 30})
+ patched = Diff.apply(a, %{age: {:changed, 30, 31}})
+
+ assert patched.age == 31
+ assert patched.name == "Alice"
+ end
+
+ test "applies a nested change" do
+ {:ok, a} = User.builder(%{name: "Alice", address: %{city: "NYC", zip: "10001"}})
+
+ patched = Diff.apply(a, %{address: %{city: {:changed, "NYC", "Chicago"}}})
+
+ assert patched.address.city == "Chicago"
+ assert patched.address.zip == "10001"
+ end
+
+ test "diff and apply round-trip" do
+ {:ok, a} = User.builder(%{name: "Alice", age: 30})
+ {:ok, b} = User.builder(%{name: "Bob", age: 35, role: "admin"})
+
+ d = Diff.diff(a, b)
+ reconstructed = Diff.apply(a, d)
+
+ assert reconstructed == b
+ end
+
+ test "unknown keys in diff are silently ignored" do
+ {:ok, a} = User.builder(%{name: "Alice"})
+ patched = Diff.apply(a, %{nonexistent_field: {:changed, nil, "x"}})
+
+ assert patched == a
+ end
+ end
+
+ describe "equal?/2" do
+ test "true for equal structs" do
+ {:ok, a} = User.builder(%{name: "Alice", age: 30})
+ {:ok, b} = User.builder(%{name: "Alice", age: 30})
+
+ assert Diff.equal?(a, b)
+ end
+
+ test "false for differing structs" do
+ {:ok, a} = User.builder(%{name: "Alice", age: 30})
+ {:ok, b} = User.builder(%{name: "Bob", age: 30})
+
+ refute Diff.equal?(a, b)
+ end
+
+ test "false for non-comparable" do
+ {:ok, a} = User.builder(%{name: "Alice"})
+ refute Diff.equal?(a, %Other2{x: 1})
+ end
+ end
+end
diff --git a/test/errors_test.exs b/test/errors_test.exs
new file mode 100644
index 0000000..8ea8425
--- /dev/null
+++ b/test/errors_test.exs
@@ -0,0 +1,46 @@
+defmodule GuardedStructTest.ErrorsTest do
+ use ExUnit.Case, async: true
+
+ alias GuardedStruct.Errors
+ alias GuardedStruct.Errors.{Invalid, Validation, Unknown}
+ alias GuardedStructTest.Fixtures.Errors.SampleStruct
+
+ test "wraps {:error, errors} into a Splode class" do
+ {:error, errors} = SampleStruct.builder(%{email: "not-an-email", age: 200})
+
+ class = Errors.from_tuple({:error, errors})
+
+ assert %Invalid{} = class
+ assert is_list(class.errors)
+ assert Enum.all?(class.errors, &match?(%Validation{}, &1))
+ end
+
+ test "Validation exception carries field/action/message" do
+ err = Validation.exception(field: :email, action: :email_r, message: "bad email")
+
+ assert err.field == :email
+ assert err.action == :email_r
+ assert Exception.message(err) == "bad email"
+ end
+
+ test "from_tuple preserves the message text on each child error" do
+ {:error, errors} = SampleStruct.builder(%{email: "x", age: 1})
+ class = Errors.from_tuple(errors)
+
+ messages = Enum.map(class.errors, & &1.message)
+ assert Enum.any?(messages, &(&1 =~ "Incorrect email" or &1 =~ "Invalid"))
+ end
+
+ test "traverse_errors yields per-field error lists" do
+ {:error, errors} = SampleStruct.builder(%{email: "x", age: -5})
+ class = Errors.from_tuple(errors)
+
+ grouped = Errors.traverse_errors(class, &Exception.message/1)
+ assert is_map(grouped)
+ end
+
+ test "Unknown error wraps free-form payloads" do
+ e = Unknown.exception(error: %{weird: 1}, message: "weird thing")
+ assert Exception.message(e) == "weird thing"
+ end
+end
diff --git a/test/example_helper_test.exs b/test/example_helper_test.exs
new file mode 100644
index 0000000..11a9d45
--- /dev/null
+++ b/test/example_helper_test.exs
@@ -0,0 +1,36 @@
+defmodule GuardedStructTest.ExampleHelperTest do
+ use ExUnit.Case, async: true
+
+ alias GuardedStructTest.Fixtures.ExampleHelper.{WithDefaults, TypeFallbacks, Nested}
+
+ test "example/0 uses declared defaults" do
+ sample = WithDefaults.example()
+ assert sample.name == "default name"
+ assert sample.age == 42
+ assert sample.active == true
+ end
+
+ test "example/0 falls back to type-based placeholders" do
+ sample = TypeFallbacks.example()
+ assert sample.name == ""
+ assert sample.count == 0
+ assert sample.rate == 0.0
+ assert sample.active == false
+ assert sample.tags == []
+ assert sample.metadata == %{}
+ end
+
+ test "nested sub_field example/0 recurses" do
+ sample = Nested.example()
+ assert sample.title == "the title"
+ assert is_struct(sample.meta)
+ assert sample.meta.author == "anon"
+ assert sample.meta.year == 2026
+ end
+
+ test "example/0 returns a struct of the declaring module" do
+ assert %WithDefaults{} = WithDefaults.example()
+ assert %Nested{} = Nested.example()
+ assert %Nested.Meta{} = Nested.example().meta
+ end
+end
diff --git a/test/fixtures/conditionals_test.exs b/test/fixtures/conditionals_test.exs
new file mode 100644
index 0000000..1fbafbd
--- /dev/null
+++ b/test/fixtures/conditionals_test.exs
@@ -0,0 +1,463 @@
+defmodule GuardedStructFixtures.ConditionalsTest do
+ @moduledoc """
+ Tests the `GuardedStructFixtures.Conditionals` fixture — covering
+ nested `conditional_field` resolution (the headline 0.1.0 unblocker).
+ """
+
+ use ExUnit.Case, async: true
+
+ alias GuardedStructFixtures.Conditionals
+
+ describe "Block (nested conditional_field)" do
+ test "resolves a plain paragraph (string) to the first branch" do
+ # `:block` is a conditional with 3 variants. A string input matches
+ # the first variant's `is_string` validator → resolved as the string.
+ assert {:ok, %Conditionals.Block{block: "hello world"}} =
+ Conditionals.Block.builder(%{block: "hello world"})
+ end
+
+ test "resolves a single image (map) to the sub_field branch" do
+ # A bare map input fails the string branch, passes `is_map` on the
+ # sub_field branch → resolved as the auto-numbered submodule struct.
+ assert {:ok, %Conditionals.Block{block: %{url: url}}} =
+ Conditionals.Block.builder(%{block: %{url: "https://x.io/a.png"}})
+
+ assert url == "https://x.io/a.png"
+ end
+
+ test "resolves a gallery (list) to the INNER conditional with list children" do
+ # A list input fails both leaf branches, passes `is_list` on the
+ # NESTED conditional → each item resolved via its own inner conditional.
+ gallery = [
+ "https://x.io/cap.png",
+ %{url: "https://x.io/img.png", alt: "a pic"}
+ ]
+
+ assert {:ok, %Conditionals.Block{block: items}} =
+ Conditionals.Block.builder(%{block: gallery})
+
+ assert length(items) == 2
+ end
+
+ test "rejects a value that matches no branch (number)" do
+ # ERROR REASON: 42 is not a string, not a map, not a list. All
+ # three branch validators reject it → :conditionals aggregate error.
+ assert {:error, _} = Conditionals.Block.builder(%{block: 42})
+ end
+
+ test "gallery item with an invalid URL inside a map fails the inner url validator" do
+ # ERROR REASON: the gallery branch's image variant routes through
+ # `Image.builder/1`, where `:url` has `derives: "validate(url, ...)"`.
+ # "not-a-url" is not a URL → :url action error.
+ gallery = [%{url: "not-a-url"}]
+ assert {:error, _} = Conditionals.Block.builder(%{block: gallery})
+ end
+
+ test "single image map with a non-url url fails the url validator" do
+ # ERROR REASON: same `validate(url)` rule on the sub_field branch's
+ # `:url`. "ftp://broken" fails the url shape check.
+ assert {:error, _} = Conditionals.Block.builder(%{block: %{url: "ftp://broken"}})
+ end
+ end
+
+ # ------------------------------------------------------------------
+ # Comprehensive shape — calling Block.builder/1 and showing the FULL
+ # data returned for each variant, plus introspection on the parent
+ # module and the auto-generated submodule.
+ # ------------------------------------------------------------------
+ describe "Block.builder/1 — full result shape and introspection" do
+ test "parent Block module surface: keys/0, enforce_keys/0, __information__/0" do
+ # The user-facing shape of the top-level module has exactly ONE field
+ # `:block`, which is itself a conditional_field with 3 variants.
+ assert Conditionals.Block.keys() == [:block]
+ assert Conditionals.Block.enforce_keys() == []
+
+ info = Conditionals.Block.__information__()
+ assert info.module == Conditionals.Block
+ assert info.keys == [:block]
+ assert info.enforce_keys == []
+ assert info.conditional_keys == [:block]
+ assert info.path == []
+ assert info.key == :root
+ assert info.options == %{json: false, authorized_fields: false}
+ end
+
+ test "__fields__/0 exposes the FULL conditional shape — all 3 variants" do
+ [%{name: :block, kind: :conditional_field, children: children}] =
+ Conditionals.Block.__fields__()
+
+ # Three variants, in declaration order:
+ [string_variant, image_variant, gallery_variant] = children
+
+ # 1. Paragraph — leaf string field with a derive
+ assert string_variant.name == :block
+ assert string_variant.kind == :field
+ assert string_variant.hint == "paragraph"
+ assert string_variant.derive == "validate(string, max_len=10_000)"
+ assert string_variant.__derive_ops__ == %{validate: [:string, {:max_len, 10_000}]}
+ assert string_variant.validator == {Conditionals.Validators, :is_string}
+
+ # 2. Single image — sub_field that generates its own submodule
+ assert image_variant.name == :block
+ assert image_variant.kind == :sub_field
+ assert image_variant.hint == "image"
+ assert image_variant.validator == {Conditionals.Validators, :is_map}
+ assert image_variant.sub_field_index == 1
+ refute image_variant.list?
+
+ # 3. Gallery — list-of-conditional, each item is its own conditional
+ assert gallery_variant.name == :block
+ assert gallery_variant.kind == :conditional_field
+ assert gallery_variant.hint == "gallery"
+ assert gallery_variant.validator == {Conditionals.Validators, :is_list}
+ assert gallery_variant.list? == true
+
+ [item_string, item_image] = gallery_variant.children
+ assert item_string.hint == "gallery_item_string"
+ assert item_string.derive == "validate(string, max_len=2048)"
+ assert item_image.hint == "gallery_item_image"
+ # External-struct reference (no submodule generated; delegates to Image)
+ assert item_image.struct == Conditionals.Image
+ end
+
+ test "paragraph variant: full %Block{} result with every key visible" do
+ assert {:ok, result} = Conditionals.Block.builder(%{block: "hello"})
+
+ # Exactly one key on the parent struct, populated with the string.
+ assert result == %Conditionals.Block{block: "hello"}
+ assert result |> Map.keys() |> Enum.sort() == [:__struct__, :block]
+ end
+
+ test "image variant: result.block is a fully-typed %Block.Block1{} submodule" do
+ assert {:ok, result} =
+ Conditionals.Block.builder(%{block: %{url: "https://x.io/a.png"}})
+
+ # The conditional resolves to the sub_field branch, which generated
+ # the submodule `Block.Block1` (the first sub_field child of the
+ # outer conditional).
+ assert %Conditionals.Block{block: image} = result
+ assert is_struct(image, Conditionals.Block.Block1)
+
+ # Submodule has its own keys/enforce/example surface
+ assert Conditionals.Block.Block1.keys() == [:url, :alt]
+ assert Conditionals.Block.Block1.enforce_keys() == [:url]
+ assert is_struct(Conditionals.Block.Block1.example(), Conditionals.Block.Block1)
+
+ # Provided URL surfaced; :alt populated with its default "".
+ assert image.url == "https://x.io/a.png"
+ assert image.alt == ""
+
+ # Submodule fully exposes its keys including __struct__
+ assert Map.keys(image) |> Enum.sort() == [:__struct__, :alt, :url]
+ end
+
+ test "image variant: an explicit :alt value flows through" do
+ assert {:ok, %Conditionals.Block{block: image}} =
+ Conditionals.Block.builder(%{
+ block: %{url: "https://x.io/cat.png", alt: "a cat"}
+ })
+
+ assert image.alt == "a cat"
+ end
+
+ test "gallery variant: result.block is a list where each item is fully resolved" do
+ assert {:ok, %Conditionals.Block{block: items}} =
+ Conditionals.Block.builder(%{
+ block: [
+ "https://x.io/header.png",
+ %{url: "https://x.io/img1.png"},
+ %{url: "https://x.io/img2.png", alt: "second"}
+ ]
+ })
+
+ assert length(items) == 3
+ [first, second, third] = items
+
+ # First is the string variant (resolved by the inner conditional)
+ assert first == "https://x.io/header.png"
+
+ # Second/third are %Image{} structs (external struct ref → delegates
+ # to GuardedStructFixtures.Conditionals.Image.builder/1)
+ assert is_struct(second, Conditionals.Image)
+ assert second.url == "https://x.io/img1.png"
+ assert second.alt == ""
+
+ assert is_struct(third, Conditionals.Image)
+ assert third.alt == "second"
+ end
+
+ test "Image (the external struct referenced by gallery items) has its own surface" do
+ assert Conditionals.Image.keys() == [:url, :alt]
+ assert Conditionals.Image.enforce_keys() == [:url]
+
+ info = Conditionals.Image.__information__()
+ assert info.module == Conditionals.Image
+ assert info.enforce_keys == [:url]
+ assert info.conditional_keys == []
+ end
+
+ test "Block.example/0 produces a complete starter struct" do
+ ex = Conditionals.Block.example()
+ assert is_struct(ex, Conditionals.Block)
+ # Top-level keys present:
+ assert Map.has_key?(ex, :block)
+ end
+ end
+
+ # ------------------------------------------------------------------
+ # Full deep-map equality — assert the ENTIRE returned struct in one
+ # `==` so any drift in any nested key fails the test loudly.
+ # ------------------------------------------------------------------
+ describe "Full struct equality (deep map comparison)" do
+ test "paragraph variant — Block.builder/1 returns the EXACT %Block{} in one assert" do
+ # Deep-equality lock for the string branch: only :block populated.
+ assert Conditionals.Block.builder(%{block: "hello world"}) ==
+ {:ok, %Conditionals.Block{block: "hello world"}}
+ end
+
+ test "image variant — Block.builder/1 returns Block with nested %Block1{} struct, every key set" do
+ # The sub_field branch inside a conditional gets auto-numbered →
+ # generated submodule is `Block.Block1`. Asserted by exact name.
+ assert Conditionals.Block.builder(%{
+ block: %{url: "https://x.io/a.png", alt: "alt text"}
+ }) ==
+ {:ok,
+ %Conditionals.Block{
+ block: %Conditionals.Block.Block1{
+ url: "https://x.io/a.png",
+ alt: "alt text"
+ }
+ }}
+ end
+
+ test "image variant — :alt defaults to \"\" when omitted (full equality with default applied)" do
+ # Locks the default behavior: omitting :alt yields "" not nil,
+ # because `:alt` has `default: ""` in the fixture.
+ assert Conditionals.Block.builder(%{block: %{url: "https://x.io/a.png"}}) ==
+ {:ok,
+ %Conditionals.Block{
+ block: %Conditionals.Block.Block1{
+ url: "https://x.io/a.png",
+ alt: ""
+ }
+ }}
+ end
+
+ test "gallery variant — Block.builder/1 returns full list of resolved Image structs + strings" do
+ # The gallery branch routes maps through the external `Image`
+ # module (not an auto-numbered submodule). Strings stay strings,
+ # maps become `%Image{}` structs with defaults applied.
+ assert Conditionals.Block.builder(%{
+ block: [
+ "https://x.io/header.png",
+ %{url: "https://x.io/img1.png"},
+ %{url: "https://x.io/img2.png", alt: "second"}
+ ]
+ }) ==
+ {:ok,
+ %Conditionals.Block{
+ block: [
+ "https://x.io/header.png",
+ %Conditionals.Image{url: "https://x.io/img1.png", alt: ""},
+ %Conditionals.Image{url: "https://x.io/img2.png", alt: "second"}
+ ]
+ }}
+ end
+ end
+
+ # ==================================================================
+ # Document — DEEPLY nested (7 levels, 3 stacked conditional layers)
+ # ==================================================================
+ describe "Document — deeply nested conditional_field (7 levels deep)" do
+ test "plain content variant — top conditional resolves to bare string" do
+ # Top conditional, first branch (string). No deeper traversal.
+ assert Conditionals.Document.builder(%{
+ title: "Hello",
+ content: "just plain text"
+ }) ==
+ {:ok,
+ %Conditionals.Document{
+ title: "Hello",
+ content: "just plain text"
+ }}
+ end
+
+ test "rich + simple-string body — 3 levels deep" do
+ # Top conditional resolves to sub_field (Content1). The Content1's
+ # `:body` is itself a conditional whose string branch wins.
+ # Depth: Document → Content1 → :body (string).
+ assert Conditionals.Document.builder(%{
+ title: "Hello",
+ content: %{title: "Post", body: "one paragraph"}
+ }) ==
+ {:ok,
+ %Conditionals.Document{
+ title: "Hello",
+ content: %Conditionals.Document.Content1{
+ title: "Post",
+ body: "one paragraph"
+ }
+ }}
+ end
+
+ test "rich + structured body + plain paragraphs only — 5 levels deep" do
+ # Depth: Document → Content1 → Body1 → :paragraphs (string branch).
+ # The inner conditional's string variant catches every list item.
+ assert Conditionals.Document.builder(%{
+ title: "Hello",
+ content: %{
+ title: "Post",
+ body: %{
+ heading: "Section 1",
+ paragraphs: ["alpha", "beta", "gamma"]
+ }
+ }
+ }) ==
+ {:ok,
+ %Conditionals.Document{
+ title: "Hello",
+ content: %Conditionals.Document.Content1{
+ title: "Post",
+ body: %Conditionals.Document.Content1.Body1{
+ heading: "Section 1",
+ paragraphs: ["alpha", "beta", "gamma"]
+ }
+ }
+ }}
+ end
+
+ test "rich + structured body + ONE quote paragraph with source — 7 levels deep" do
+ # Deepest path: Document → Content1 → Body1 → Paragraphs1 → Source.
+ # The inner conditional's sub_field branch resolves; the quote's
+ # `:source` is a regular nested sub_field (not numbered).
+ assert Conditionals.Document.builder(%{
+ title: "Hello",
+ content: %{
+ title: "Post",
+ body: %{
+ heading: "Section",
+ paragraphs: [
+ %{
+ text: "To be or not to be",
+ source: %{author: "Shakespeare", url: "https://shakespeare.io"}
+ }
+ ]
+ }
+ }
+ }) ==
+ {:ok,
+ %Conditionals.Document{
+ title: "Hello",
+ content: %Conditionals.Document.Content1{
+ title: "Post",
+ body: %Conditionals.Document.Content1.Body1{
+ heading: "Section",
+ paragraphs: [
+ %Conditionals.Document.Content1.Body1.Paragraphs1{
+ text: "To be or not to be",
+ source: %Conditionals.Document.Content1.Body1.Paragraphs1.Source{
+ author: "Shakespeare",
+ url: "https://shakespeare.io"
+ }
+ }
+ ]
+ }
+ }
+ }}
+ end
+
+ test "rich + structured body + MIXED paragraphs (strings + quotes) — full deep equality" do
+ # The inner conditional resolves each list item independently:
+ # strings go to the string branch, maps to the quote sub_field.
+ # Locks the per-item resolution behavior.
+ assert Conditionals.Document.builder(%{
+ title: "Mixed",
+ content: %{
+ title: "Post",
+ body: %{
+ heading: "Section",
+ paragraphs: [
+ "intro text",
+ %{
+ text: "quoted",
+ source: %{author: "X", url: "https://x.io"}
+ },
+ "outro text"
+ ]
+ }
+ }
+ }) ==
+ {:ok,
+ %Conditionals.Document{
+ title: "Mixed",
+ content: %Conditionals.Document.Content1{
+ title: "Post",
+ body: %Conditionals.Document.Content1.Body1{
+ heading: "Section",
+ paragraphs: [
+ "intro text",
+ %Conditionals.Document.Content1.Body1.Paragraphs1{
+ text: "quoted",
+ source: %Conditionals.Document.Content1.Body1.Paragraphs1.Source{
+ author: "X",
+ url: "https://x.io"
+ }
+ },
+ "outro text"
+ ]
+ }
+ }
+ }}
+ end
+
+ test "deepest path: quote.source.author missing → builder rejects (cascade enforce)" do
+ # ERROR REASON: `source.author` is `enforce: true` (also cascaded
+ # from the parent sub_field). Omitting it 7 levels deep still
+ # propagates the required-fields error all the way up.
+ assert {:error, _} =
+ Conditionals.Document.builder(%{
+ title: "Hello",
+ content: %{
+ title: "Post",
+ body: %{
+ heading: "Section",
+ paragraphs: [
+ # :source.author is enforce: true
+ %{text: "incomplete quote", source: %{url: "https://x.io"}}
+ ]
+ }
+ }
+ })
+ end
+
+ test "introspection — every auto-generated submodule in the chain exists" do
+ # Following the docstring's nesting diagram:
+ mods = [
+ Conditionals.Document,
+ Conditionals.Document.Content1,
+ Conditionals.Document.Content1.Body1,
+ Conditionals.Document.Content1.Body1.Paragraphs1,
+ Conditionals.Document.Content1.Body1.Paragraphs1.Source
+ ]
+
+ for mod <- mods do
+ assert Code.ensure_loaded?(mod),
+ "expected #{inspect(mod)} to be a generated submodule"
+
+ assert function_exported?(mod, :builder, 1),
+ "expected #{inspect(mod)}.builder/1 to be defined"
+
+ assert function_exported?(mod, :keys, 0),
+ "expected #{inspect(mod)}.keys/0 to be defined"
+ end
+ end
+
+ test "introspection — keys/0 at each depth reports the right fields" do
+ assert Conditionals.Document.keys() == [:title, :content]
+ assert Conditionals.Document.Content1.keys() == [:title, :body]
+ assert Conditionals.Document.Content1.Body1.keys() == [:heading, :paragraphs]
+ assert Conditionals.Document.Content1.Body1.Paragraphs1.keys() == [:text, :source]
+ assert Conditionals.Document.Content1.Body1.Paragraphs1.Source.keys() == [:author, :url]
+ end
+ end
+end
diff --git a/test/fixtures/cross_field_test.exs b/test/fixtures/cross_field_test.exs
new file mode 100644
index 0000000..ae8780f
--- /dev/null
+++ b/test/fixtures/cross_field_test.exs
@@ -0,0 +1,168 @@
+defmodule GuardedStructFixtures.CrossFieldTest do
+ @moduledoc """
+ Tests the `GuardedStructFixtures.CrossField` fixture — covering:
+
+ * `AuditedEvent` — `from:`, `on:`, `auto:`, `domain:`, `authorized_fields:`
+ * `StrictEvent` — the `sub_field(..., enforce: true)` enforce-cascade pattern
+ """
+
+ use ExUnit.Case, async: true
+
+ alias GuardedStructFixtures.CrossField
+
+ describe "AuditedEvent (from / on / auto / domain)" do
+ test "happy path: from: pulls actor_id; auto: mints event_id" do
+ # All required fields present. `event.actor_id` is auto-pulled
+ # from root via `from:`, `event.event_id` is generated by `auto:`.
+ assert {:ok, ev} =
+ CrossField.AuditedEvent.builder(%{
+ actor_id: "11111111-1111-1111-1111-111111111111",
+ account_type: "enterprise",
+ requested_by: "alice",
+ event: %{name: "did the thing", kind: "billing.refund"}
+ })
+
+ assert ev.event.actor_id == ev.actor_id
+ assert is_binary(ev.event.event_id)
+ assert String.length(ev.event.event_id) > 10
+ end
+
+ test "on: blocks build when the depended-on path is missing" do
+ # ERROR REASON: `event.name` has `on: "root::actor_id"`, but we
+ # omit `:actor_id` from the input → `on:` precondition fails.
+ assert {:error, _} =
+ CrossField.AuditedEvent.builder(%{
+ account_type: "enterprise",
+ requested_by: "alice",
+ event: %{name: "x", kind: "login"}
+ })
+ end
+
+ test "validate(enum=...) rejects an out-of-set kind" do
+ # ERROR REASON: `event.kind` derives say enum must be one of
+ # [login, logout, data.read, billing.refund]. "totally.invented"
+ # is not in the set → :enum validator fails.
+ assert {:error, _} =
+ CrossField.AuditedEvent.builder(%{
+ actor_id: "11111111-1111-1111-1111-111111111111",
+ account_type: "enterprise",
+ requested_by: "alice",
+ event: %{name: "x", kind: "totally.invented"}
+ })
+ end
+
+ test "domain: rejects values outside the allowed account_type set" do
+ # ERROR REASON: `:requested_by` has a domain rule pointing at
+ # `account_type`, which must be one of [free, pro, enterprise].
+ # "trial" is not in the set → :domain_parameters error.
+ assert {:error, _} =
+ CrossField.AuditedEvent.builder(%{
+ actor_id: "11111111-1111-1111-1111-111111111111",
+ account_type: "trial",
+ requested_by: "alice",
+ event: %{name: "x", kind: "login"}
+ })
+ end
+
+ test "authorized_fields: true rejects unknown top-level keys" do
+ # ERROR REASON: section has `authorized_fields: true`, so extra
+ # keys that aren't declared as fields (`:hacker_added`) trigger
+ # the :authorized_fields error.
+ assert {:error, _} =
+ CrossField.AuditedEvent.builder(%{
+ actor_id: "11111111-1111-1111-1111-111111111111",
+ account_type: "free",
+ requested_by: "alice",
+ event: %{name: "x", kind: "login"},
+ hacker_added: "value"
+ })
+ end
+ end
+
+ describe "StrictEvent (sub_field enforce-cascade pattern)" do
+ test "happy path: all enforced inner fields supplied → build succeeds" do
+ # `:kind` and `:body` are required by cascade (parent has
+ # `enforce: true`, they have no default). Both supplied → ok.
+ # `:trace_id` is `enforce: false`; `:retries` has `default: 0`.
+ assert {:ok, ev} =
+ CrossField.StrictEvent.builder(%{
+ source: "api",
+ payload: %{kind: "create", body: %{user_id: 1}}
+ })
+
+ assert ev.payload.kind == "create"
+ assert ev.payload.trace_id == nil
+ assert ev.payload.retries == 0
+ end
+
+ test "missing :kind (cascaded enforce) → :required_fields error" do
+ # ERROR REASON: parent sub_field has `enforce: true`, so every
+ # inner field without `default:` cascades to enforced. `:kind`
+ # is omitted → :required_fields error from the Payload submodule.
+ assert {:error, errs} =
+ CrossField.StrictEvent.builder(%{
+ source: "api",
+ payload: %{body: %{x: 1}}
+ })
+
+ errs = List.wrap(errs)
+
+ assert Enum.any?(errs, fn err ->
+ err[:action] == :required_fields or
+ (is_map(err[:errors]) and err[:errors][:action] == :required_fields)
+ end)
+ end
+
+ test "missing :body (cascaded enforce) → required error" do
+ # ERROR REASON: same cascade as the :kind test — `:body` has no
+ # default, so the parent's `enforce: true` makes it required.
+ assert {:error, _} =
+ CrossField.StrictEvent.builder(%{
+ source: "api",
+ payload: %{kind: "create"}
+ })
+ end
+
+ test "missing :retries is FINE — `default:` opts it out of the cascade" do
+ # `:retries` has `default: 0`, which short-circuits the cascade
+ # (the codegen treats "has default" as "opted-out of enforce").
+ # Build succeeds; the default is applied.
+ assert {:ok, ev} =
+ CrossField.StrictEvent.builder(%{
+ source: "api",
+ payload: %{kind: "create", body: %{}}
+ })
+
+ assert ev.payload.retries == 0
+ end
+
+ test "missing :trace_id is FINE — `enforce: false` opts it out explicitly" do
+ # `:trace_id` is marked `enforce: false`, the EXPLICIT way to opt
+ # out of the parent's enforce cascade. Build succeeds; value is nil.
+ assert {:ok, _} =
+ CrossField.StrictEvent.builder(%{
+ source: "api",
+ payload: %{kind: "create", body: %{}}
+ })
+ end
+
+ test "missing :payload (the parent sub_field itself) → required error" do
+ # ERROR REASON: the sub_field `:payload` is itself declared
+ # `enforce: true`, so omitting it entirely fails at the OUTER
+ # level (the StrictEvent's own enforce_keys list), independently
+ # of the cascade behavior inside it.
+ assert {:error, _} = CrossField.StrictEvent.builder(%{source: "api"})
+ end
+
+ test "inner submodule reports the cascade in its own enforce_keys/0" do
+ # Regression lock for the cascade: the auto-generated Payload
+ # submodule must list :kind and :body as enforced (cascade hit),
+ # but NOT :retries (default opts out) or :trace_id (explicit opt out).
+ keys = CrossField.StrictEvent.Payload.enforce_keys()
+ assert :kind in keys
+ assert :body in keys
+ refute :retries in keys
+ refute :trace_id in keys
+ end
+ end
+end
diff --git a/test/fixtures/custom_derives_test.exs b/test/fixtures/custom_derives_test.exs
new file mode 100644
index 0000000..f618f16
--- /dev/null
+++ b/test/fixtures/custom_derives_test.exs
@@ -0,0 +1,89 @@
+defmodule GuardedStructFixtures.CustomDerivesTest do
+ @moduledoc """
+ Tests the `GuardedStructFixtures.CustomDerives` fixture — custom
+ validators / sanitizers via `GuardedStruct.Derive.Extension`, wired
+ through the `:derive_extensions` Application env.
+ """
+
+ # async: false — we mutate `Application.put_env(:guarded_struct, :derive_extensions, ...)`
+ # which is process-global.
+ use ExUnit.Case, async: false
+
+ alias GuardedStructFixtures.CustomDerives
+
+ setup do
+ previous = Application.get_env(:guarded_struct, :derive_extensions, [])
+ Application.put_env(:guarded_struct, :derive_extensions, [CustomDerives.MyDerives])
+ on_exit(fn -> Application.put_env(:guarded_struct, :derive_extensions, previous) end)
+ :ok
+ end
+
+ describe "slugify sanitizer + slug validator (composed custom ops)" do
+ test "slugify transforms the input; slug validator passes" do
+ # `:slug` has `derives: "sanitize(slugify) validate(slug)"`.
+ # `slugify` (custom sanitizer) downcases + replaces non-alnum
+ # with hyphens, then `slug` (custom validator) accepts the result.
+ assert {:ok, art} =
+ CustomDerives.Article.builder(%{
+ title: "Hello, World!",
+ slug: "Hello, World!"
+ })
+
+ assert art.slug == "hello-world"
+ end
+
+ test "slugify collapses runs of non-alphanumerics into single hyphens" do
+ # Whitespace, punctuation, repeats — all become single hyphens.
+ # Surrounding hyphens trimmed off the result.
+ assert {:ok, art} =
+ CustomDerives.Article.builder(%{
+ title: "x",
+ slug: " Mishka --- Group !! 2026 "
+ })
+
+ assert art.slug == "mishka-group-2026"
+ end
+
+ test "slug validator rejects an empty/whitespace-only slug after slugify" do
+ # ERROR REASON: slugify turns "!!!" into "" (all chars stripped),
+ # then the `slug` validator (regex `^[a-z0-9][a-z0-9-]*$`) rejects
+ # the empty string → :slug action error.
+ assert {:error, errs} = CustomDerives.Article.builder(%{title: "x", slug: "!!!"})
+ errs = List.wrap(errs)
+ assert Enum.any?(errs, &(&1[:field] == :slug))
+ end
+ end
+
+ describe "positive_int validator" do
+ test "rejects 0 and negative values" do
+ # ERROR REASON: custom `positive_int` validator requires `> 0`.
+ # -1 fails → :positive_int action error on :views.
+ assert {:error, errs} =
+ CustomDerives.Article.builder(%{
+ title: "x",
+ slug: "x",
+ views: -1
+ })
+
+ errs = List.wrap(errs)
+ assert Enum.any?(errs, &(&1[:field] == :views))
+ end
+
+ test "accepts positive integers" do
+ # Sanity: 42 > 0 → validator passes.
+ assert {:ok, %{views: 42}} =
+ CustomDerives.Article.builder(%{
+ title: "x",
+ slug: "x",
+ views: 42
+ })
+ end
+
+ test "defaults to 1 when omitted" do
+ # `:views` has `default: 1`, which is > 0, so the validator
+ # passes on the default.
+ assert {:ok, %{views: 1}} =
+ CustomDerives.Article.builder(%{title: "x", slug: "x"})
+ end
+ end
+end
diff --git a/test/fixtures/decorated_all_entities_test.exs b/test/fixtures/decorated_all_entities_test.exs
new file mode 100644
index 0000000..87f16ed
--- /dev/null
+++ b/test/fixtures/decorated_all_entities_test.exs
@@ -0,0 +1,308 @@
+defmodule GuardedStructFixtures.DecoratedAllEntitiesTest do
+ @moduledoc """
+ End-to-end tests for the `@derives` / `@derive_rules` decorator across
+ EVERY entity type and at every nesting depth.
+
+ Each test does TWO things:
+ 1. Asserts the parsed `__derive_ops__` map on the relevant field's
+ `__fields__/0` metadata — proves the decorator's payload landed
+ on the right entity post-compile.
+ 2. Exercises `builder/1` end-to-end — proves the validation actually
+ fires at runtime, not just sits there as inert metadata.
+ """
+
+ use ExUnit.Case, async: true
+
+ alias GuardedStructFixtures.DecoratedAllEntities, as: D
+
+ defp find_field(fields, name), do: Enum.find(fields, &(&1.name == name))
+
+ # ============================================================
+ # 1. @derives on a top-level field — baseline
+ # ============================================================
+ describe "@derives on field (level 1)" do
+ test "__fields__/0 carries the parsed sanitize+validate op map" do
+ meta = find_field(D.OnField.__fields__(), :name)
+ assert meta.derive == "sanitize(trim) validate(string, max_len=10)"
+ assert meta.__derive_ops__ == %{sanitize: [:trim], validate: [:string, {:max_len, 10}]}
+ end
+
+ test "runtime: rule enforced — sanitize trims, max_len=10 rejects long" do
+ assert {:ok, %{name: "x"}} = D.OnField.builder(%{name: " x "})
+ assert {:error, _} = D.OnField.builder(%{name: "this is too long"})
+ end
+ end
+
+ # ============================================================
+ # 2. @derives on virtual_field (validated but not in struct)
+ # ============================================================
+ describe "@derives on virtual_field (level 1)" do
+ test "__fields__/0 carries the derive ops on the virtual field" do
+ meta = find_field(D.OnVirtualField.__fields__(), :password_confirmation)
+ assert meta.derive == "validate(string, min_len=8)"
+ assert meta.__derive_ops__ == %{validate: [:string, {:min_len, 8}]}
+ end
+
+ test "runtime: min_len=8 derive actually enforces on the virtual value" do
+ # virtual_field's `derives:` now fires at runtime (fixed via two-pass
+ # derive in Runtime — virtual fields are derived on the merged map
+ # BEFORE wrap drops them).
+ assert {:ok, struct} =
+ D.OnVirtualField.builder(%{keep: "x", password_confirmation: "longenough"})
+
+ # Virtual fields still don't appear on the final struct
+ refute Map.has_key?(struct, :password_confirmation)
+
+ # Short password (5 chars) rejected by the derive's min_len=8
+ assert {:error, errs} =
+ D.OnVirtualField.builder(%{keep: "x", password_confirmation: "short"})
+
+ assert Enum.any?(
+ errs,
+ &(&1[:field] == :password_confirmation and &1[:action] == :min_len)
+ )
+ end
+ end
+
+ # ============================================================
+ # 3. @derives on dynamic_field — overrides schema default
+ # ============================================================
+ describe "@derives on dynamic_field (level 1)" do
+ test "decorator wins over the `validate(map)` schema default" do
+ meta = find_field(D.OnDynamicField.__fields__(), :metadata)
+ assert meta.derive == "validate(map, not_empty)"
+ assert meta.__derive_ops__ == %{validate: [:map, :not_empty]}
+ end
+
+ test "runtime: not_empty enforced (default-empty %{} rejected)" do
+ # default for dynamic_field is `%{}` — but our @derives adds
+ # `not_empty`, so the default value FAILS validation.
+ assert {:error, _} = D.OnDynamicField.builder(%{})
+
+ assert {:ok, _} = D.OnDynamicField.builder(%{metadata: %{any: "value"}})
+ end
+ end
+
+ # ============================================================
+ # 4. @derives on sub_field itself
+ # ============================================================
+ describe "@derives on sub_field (level 1, the outer)" do
+ test "__fields__/0 carries derive on the sub_field meta" do
+ meta = find_field(D.OnSubField.__fields__(), :profile)
+ assert meta.derive == "validate(map)"
+ assert meta.__derive_ops__ == %{validate: [:map]}
+ end
+
+ test "runtime: rejects non-map inputs for the sub_field key" do
+ # The :profile sub_field requires `validate(map)` BEFORE descending
+ # into the inner builder. A non-map value fails immediately.
+ assert {:error, _} =
+ D.OnSubField.builder(%{profile: "definitely not a map"})
+
+ assert {:ok, _} = D.OnSubField.builder(%{profile: %{bio: "hello"}})
+ end
+ end
+
+ # ============================================================
+ # 5. @derives on conditional_field itself
+ # ============================================================
+ describe "@derives on conditional_field (level 1, the outer)" do
+ test "__fields__/0 carries derive on the conditional_field meta" do
+ meta = find_field(D.OnConditionalField.__fields__(), :detail)
+ assert meta.derive == "validate(map)"
+ assert meta.__derive_ops__ == %{validate: [:map]}
+ end
+
+ test "runtime: decorator's validate(map) enforces BEFORE branch resolution" do
+ # The string "not a map" is rejected by the conditional's own derive
+ # before any branch is even tried — proves the decorator's payload
+ # is actually applied at runtime, not just stored as metadata.
+ assert {:error, _} = D.OnConditionalField.builder(%{detail: "not a map"})
+
+ # Map inputs pass the validate(map), then the conditional resolves
+ # to whichever branch's inner validator matches.
+ assert {:ok, _} = D.OnConditionalField.builder(%{detail: %{tag: "x"}})
+ assert {:ok, _} = D.OnConditionalField.builder(%{detail: %{tag: "x", extra: "y"}})
+ end
+ end
+
+ # ============================================================
+ # 6. @derives on a field INSIDE a sub_field body (level 2)
+ # ============================================================
+ describe "@derives on field inside sub_field (level 2)" do
+ test "the AST walker recursed — inner field carries the derive" do
+ meta = find_field(D.InsideSubField.Wrapper.__fields__(), :tag)
+ assert meta.derive == "sanitize(trim) validate(string, max_len=5)"
+
+ assert meta.__derive_ops__ == %{
+ sanitize: [:trim],
+ validate: [:string, {:max_len, 5}]
+ }
+ end
+
+ test "runtime: inner max_len=5 enforced" do
+ assert {:ok, _} = D.InsideSubField.builder(%{wrapper: %{tag: "x"}})
+
+ assert {:error, _} =
+ D.InsideSubField.builder(%{wrapper: %{tag: "way too long"}})
+ end
+ end
+
+ # ============================================================
+ # 7. @derives on field INSIDE a conditional_field branch
+ # ============================================================
+ describe "@derives on field inside conditional_field branch (level 2)" do
+ test "branch-level field carries the derive payload" do
+ # The conditional has 2 children; the FIELD branch has the @derives.
+ [conditional] = D.InsideConditional.__fields__()
+ assert conditional.kind == :conditional_field
+
+ [string_branch | _] = conditional.children
+ assert string_branch.derive == "validate(string, max_len=10)"
+ assert string_branch.__derive_ops__ == %{validate: [:string, {:max_len, 10}]}
+ end
+
+ test "@derives also recurses into the sub_field branch's body" do
+ meta = find_field(D.InsideConditional.Body1.__fields__(), :kind)
+ assert meta.derive == "validate(string)"
+ assert meta.__derive_ops__ == %{validate: [:string]}
+ end
+
+ test "runtime: each branch enforces its own decorator" do
+ assert {:ok, _} = D.InsideConditional.builder(%{body: "ok"})
+
+ assert {:error, _} =
+ D.InsideConditional.builder(%{body: "this is too long for max_len=10"})
+
+ assert {:ok, _} = D.InsideConditional.builder(%{body: %{kind: "anything"}})
+ end
+ end
+
+ # ============================================================
+ # 8. DEEP nesting — @derives at every depth (1 → 2 → 3 → 4)
+ # ============================================================
+ describe "deep nesting — @derives at levels 1, 2, 3, 4" do
+ test "every level carries its own derive payload" do
+ # Level 1 — top + sub_field meta
+ top = find_field(D.DeepNested.__fields__(), :top)
+ assert top.__derive_ops__ == %{validate: [:string, {:max_len, 10}]}
+
+ l1_meta = find_field(D.DeepNested.__fields__(), :l1)
+ assert l1_meta.__derive_ops__ == %{validate: [:map]}
+
+ # Level 2 — inside l1's sub_field body
+ l2_tag = find_field(D.DeepNested.L1.__fields__(), :tag)
+ assert l2_tag.__derive_ops__ == %{validate: [:string, {:max_len, 20}]}
+
+ # Level 3 — inside l2
+ l3_tag = find_field(D.DeepNested.L1.L2.__fields__(), :tag)
+ assert l3_tag.__derive_ops__ == %{validate: [:string, {:max_len, 30}]}
+
+ # Level 4 — inside l3
+ l4_tag = find_field(D.DeepNested.L1.L2.L3.__fields__(), :tag)
+ assert l4_tag.__derive_ops__ == %{validate: [:string, {:max_len, 40}]}
+ end
+
+ test "runtime: every level's max_len rule is enforced independently" do
+ # All-good build
+ assert {:ok, _} =
+ D.DeepNested.builder(%{
+ top: "topshort",
+ l1: %{
+ tag: "lvl2",
+ l2: %{
+ tag: "lvl3",
+ l3: %{tag: "lvl4"}
+ }
+ }
+ })
+
+ # Level 4 max_len=40 → 41-char tag fails
+ assert {:error, _} =
+ D.DeepNested.builder(%{
+ top: "ok",
+ l1: %{
+ tag: "ok",
+ l2: %{
+ tag: "ok",
+ l3: %{tag: String.duplicate("x", 41)}
+ }
+ }
+ })
+
+ # Level 1 max_len=10 fails at the top
+ assert {:error, _} =
+ D.DeepNested.builder(%{top: String.duplicate("x", 50)})
+ end
+ end
+
+ # ============================================================
+ # 9. Mixed-everything module — every entity type in one place
+ # ============================================================
+ describe "mixed all-entities module (every type, every level)" do
+ test "__fields__/0 shows the right derive on each entity type" do
+ fields = D.MixedAll.__fields__()
+
+ assert find_field(fields, :plain).__derive_ops__ == %{validate: [:string]}
+ assert find_field(fields, :extras).__derive_ops__ == %{validate: [:map]}
+ assert find_field(fields, :totp).__derive_ops__ == %{validate: [:string, {:min_len, 3}]}
+ assert find_field(fields, :nested).__derive_ops__ == %{validate: [:map]}
+ # :variant intentionally has NO decorator on the conditional — see
+ # the OnConditionalField fixture's docstring on why @derives there
+ # blocks the string branch.
+ assert find_field(fields, :variant).__derive_ops__ == nil
+ end
+
+ test "nested sub_field's submodule got its inner-field derive too" do
+ meta = find_field(D.MixedAll.Nested.__fields__(), :label)
+ assert meta.__derive_ops__ == %{validate: [:string, {:max_len, 10}]}
+ end
+
+ test "conditional_field's sub_field branch got its inner-field derive" do
+ meta = find_field(D.MixedAll.Variant1.__fields__(), :value)
+ assert meta.__derive_ops__ == %{validate: [:string]}
+ end
+
+ test "runtime: every layer's validation fires" do
+ assert {:ok, _} =
+ D.MixedAll.builder(%{
+ plain: "p",
+ extras: %{a: 1},
+ totp: "1234",
+ nested: %{label: "ok"},
+ variant: "string-variant"
+ })
+
+ # label max_len=10 fails
+ assert {:error, _} =
+ D.MixedAll.builder(%{
+ plain: "p",
+ totp: "1234",
+ nested: %{label: "way too long for the limit"}
+ })
+
+ # totp min_len=3 fails (this triggers main_validator)
+ assert {:error, errs} =
+ D.MixedAll.builder(%{plain: "p", totp: "xx"})
+
+ errs = List.wrap(errs)
+ assert Enum.any?(errs, &(&1[:field] == :totp))
+ end
+ end
+
+ # ============================================================
+ # 10. Coverage check — summary of decorator across all fixture modules
+ # ============================================================
+ describe "decorator coverage summary (one assertion proves every entity type was hit)" do
+ test "every entity kind in the support file has a non-nil derive somewhere" do
+ # If any future change breaks `@derives` for one entity type, the
+ # matching coverage row will go nil and this test fails loudly.
+ assert find_field(D.OnField.__fields__(), :name).derive != nil
+ assert find_field(D.OnVirtualField.__fields__(), :password_confirmation).derive != nil
+ assert find_field(D.OnDynamicField.__fields__(), :metadata).derive != nil
+ assert find_field(D.OnSubField.__fields__(), :profile).derive != nil
+ assert find_field(D.OnConditionalField.__fields__(), :detail).derive != nil
+ assert find_field(D.InsideSubField.Wrapper.__fields__(), :tag).derive != nil
+ end
+ end
+end
diff --git a/test/fixtures/decorated_test.exs b/test/fixtures/decorated_test.exs
new file mode 100644
index 0000000..14b4d37
--- /dev/null
+++ b/test/fixtures/decorated_test.exs
@@ -0,0 +1,234 @@
+defmodule GuardedStructFixtures.DecoratedTest do
+ @moduledoc """
+ Tests the `GuardedStructFixtures.Decorated` fixture — `@derives` and
+ `@derive_rules` decorators applied at top-level AND inside sub_field
+ bodies (verifies the AST walker recurses).
+ """
+
+ use ExUnit.Case, async: true
+
+ alias GuardedStructFixtures.Decorated
+
+ describe "@derives / @derive_rules decorator on top-level fields" do
+ test "decorated fields enforce the same rules as inline derives:" do
+ # `@derives` above `:title` injects the same sanitize/validate ops
+ # as if written inline. Sanitize trims the leading/trailing spaces.
+ assert {:ok, post} =
+ Decorated.BlogPost.builder(%{
+ title: " Hello ",
+ body: "**markdown**",
+ slug: "hello-world"
+ })
+
+ assert post.title == "Hello"
+ end
+
+ test "rejects long titles via the @derives max_len rule" do
+ # ERROR REASON: the @derives line above `:title` includes
+ # `max_len=200`. 250 x's exceed it → :max_len action error.
+ assert {:error, errs} =
+ Decorated.BlogPost.builder(%{
+ title: String.duplicate("x", 250),
+ body: "y"
+ })
+
+ assert Enum.any?(errs, &(&1[:field] == :title and &1[:action] == :max_len))
+ end
+
+ test "field without a decorator and without a derives: opt has no rule" do
+ # `:draft` is declared with neither a decorator nor inline `derives:`
+ # → any value passes (here: true / default false). Confirms decorator
+ # is one-shot — it doesn't leak to the next field.
+ assert {:ok, %{draft: true}} =
+ Decorated.BlogPost.builder(%{title: "ok", body: "ok", draft: true})
+
+ assert {:ok, %{draft: false}} =
+ Decorated.BlogPost.builder(%{title: "ok", body: "ok"})
+ end
+
+ test "@derive_rules (verbose alias) and @derives produce identical ops" do
+ # `@derive_rules` decorates `:body` with `sanitize(markdown_html)`
+ # which strips dangerous HTML. `Hello",
+ body: "**bold**"
+ })
+
+ refute post.title =~ "