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 +
- - Buy Me A Coffee - +# 🛡️ 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.** +[![Hex.pm](https://img.shields.io/hexpm/v/guarded_struct.svg?style=flat-square)](https://hex.pm/packages/guarded_struct) +[![Hex Downloads](https://img.shields.io/hexpm/dt/guarded_struct.svg?style=flat-square)](https://hex.pm/packages/guarded_struct) +[![License](https://img.shields.io/hexpm/l/guarded_struct.svg?style=flat-square)](https://github.com/mishka-group/guarded_struct/blob/master/LICENSE) +[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-mishka--group-ea4aaa?style=flat-square&logo=github)](https://github.com/sponsors/mishka-group) +[![Buy Me a Coffee](https://img.shields.io/badge/Buy_Me_a_Coffee-mishkagroup-ffdd00?style=flat-square&logo=buy-me-a-coffee&logoColor=black)](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 -[![Run in Livebook](https://livebook.dev/badge/v1/pink.svg)](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: + +
+ +[![GitHub Sponsors](https://img.shields.io/badge/GitHub_Sponsors-mishka--group-ea4aaa?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/mishka-group) +    +[![Buy Me a Coffee](https://img.shields.io/badge/Buy_Me_a_Coffee-mishkagroup-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](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 =~ " + assert f.__derive_ops__ == nil, + "field #{inspect(f.name)} has nil derive but non-nil ops" + + "" -> + assert f.__derive_ops__ == nil + + str when is_binary(str) -> + assert is_map(f.__derive_ops__), + "field #{inspect(f.name)} has derive string #{inspect(str)} but no parsed ops" + + assert map_size(f.__derive_ops__) > 0, + "field #{inspect(f.name)} parsed to an EMPTY op map" + end + end + end + + test "summary helper — name → derive op-string for every decorated field" do + # This shape is what a user would build in iex/livebook to quickly + # audit "what rule does each field actually enforce?". + summary = + Decorated.BlogPost.__fields__() + |> Enum.map(fn f -> {f.name, f.derive} end) + |> Enum.into(%{}) + + assert summary == %{ + title: "sanitize(strip_tags, trim) validate(string, not_empty, max_len=200)", + body: "sanitize(markdown_html, trim) validate(string, not_empty)", + slug: "validate(string, max_len=50)", + draft: nil, + metadata: "validate(map)" + } + end + end + + describe "@derives inside a sub_field body (AST walker recurses)" do + test "decorated inner field's derives: validate(uuid) accepts a valid uuid" do + # The walker recurses into the `:metadata` sub_field body, so + # `@derives "validate(uuid)"` above `:author_id` applies even + # though it's two levels deep, not at the outermost block. + uuid = "22222222-2222-2222-2222-222222222222" + + assert {:ok, post} = + Decorated.BlogPost.builder(%{ + title: "ok", + body: "ok", + metadata: %{tags: ["a", "b"], author_id: uuid} + }) + + assert post.metadata.author_id == uuid + end + + test "rejects an invalid uuid on the decorated inner field" do + # ERROR REASON: same nested `@derives "validate(uuid)"` rule + # applies. "not-a-uuid" doesn't match the uuid shape → :uuid + # action error on `metadata.author_id`. + assert {:error, _} = + Decorated.BlogPost.builder(%{ + title: "ok", + body: "ok", + metadata: %{author_id: "not-a-uuid"} + }) + end + end +end diff --git a/test/fixtures/dynamic_field_full_opts_test.exs b/test/fixtures/dynamic_field_full_opts_test.exs new file mode 100644 index 0000000..19b722c --- /dev/null +++ b/test/fixtures/dynamic_field_full_opts_test.exs @@ -0,0 +1,271 @@ +defmodule GuardedStructFixtures.DynamicFieldFullOptsTest do + @moduledoc """ + Tests for the 5 newly-added opts on `dynamic_field`: `enforce:`, `auto:`, + `from:`, `on:`, `domain:`. Previously the schema rejected these — now + `dynamic_field` has full parity with `field` for cross-field semantics + (while keeping its free-form-map value shape). + """ + + use ExUnit.Case, async: true + + # ---------------------------------------------------------------- + # enforce: true on a dynamic_field + # ---------------------------------------------------------------- + describe "enforce: true" do + defmodule WithEnforce do + use GuardedStruct + + guardedstruct do + field(:id, String.t(), enforce: true) + dynamic_field(:metadata, enforce: true) + end + end + + test "rejects build when :metadata is missing" do + # ERROR REASON: `dynamic_field :metadata, enforce: true` makes + # :metadata a required key. Input only has :id → :required_fields error. + assert {:error, _} = WithEnforce.builder(%{id: "x"}) + end + + test "accepts when :metadata is provided" do + # All enforced keys present; dynamic_field accepts any map value. + assert {:ok, %{id: "x", metadata: %{a: 1}}} = + WithEnforce.builder(%{id: "x", metadata: %{a: 1}}) + end + end + + # ---------------------------------------------------------------- + # auto: — compute the map at build time + # ---------------------------------------------------------------- + defmodule Computed do + def default_metadata(_default) do + %{computed_at: "build_time", source: :auto} + end + end + + describe "auto: computed metadata" do + defmodule WithAuto do + use GuardedStruct + + guardedstruct do + field(:id, String.t(), enforce: true) + + dynamic_field(:metadata, + auto: {Computed, :default_metadata, "unused"} + ) + end + end + + test "auto: populates :metadata regardless of user input" do + # `auto: {Mod, :fn, arg}` calls `Mod.fn(arg)` at build time and uses + # the return value, IGNORING whatever the user passed. Same semantics + # as auto: on a regular field. + assert {:ok, %{metadata: %{computed_at: "build_time", source: :auto}}} = + WithAuto.builder(%{id: "x"}) + + # User-supplied value is silently discarded — auto wins. + assert {:ok, %{metadata: %{computed_at: "build_time", source: :auto}}} = + WithAuto.builder(%{id: "x", metadata: %{user: "value"}}) + end + end + + # ---------------------------------------------------------------- + # from: — pull the value from elsewhere in the input + # ---------------------------------------------------------------- + describe "from: pulls from another path" do + defmodule WithFrom do + use GuardedStruct + + guardedstruct do + field(:source_data, map(), enforce: true, derives: "validate(map)") + + # Pull metadata from source_data at build time + dynamic_field(:metadata, from: "root::source_data") + end + end + + test "from: copies the value from the named root path" do + # User only supplied :source_data; `:metadata` is auto-filled from + # `root::source_data` via from:. Both fields end up with the same map. + assert {:ok, %{metadata: %{foo: "bar"}, source_data: %{foo: "bar"}}} = + WithFrom.builder(%{source_data: %{foo: "bar"}}) + end + end + + # ---------------------------------------------------------------- + # on: — require another field to be present before accepting this one + # ---------------------------------------------------------------- + describe "on: presence requirement" do + defmodule WithOn do + use GuardedStruct + + guardedstruct do + field(:account_id, String.t()) + dynamic_field(:metadata, on: "root::account_id") + end + end + + test "rejects metadata when account_id is missing" do + # ERROR REASON: `dynamic_field :metadata, on: "root::account_id"` + # requires `:account_id` to be present in the input before accepting + # :metadata. Input has :metadata but no :account_id → :dependent_keys. + assert {:error, _} = + WithOn.builder(%{metadata: %{any: "value"}}) + end + + test "accepts metadata when account_id is present" do + # on: dependency satisfied → :metadata accepted. + assert {:ok, _} = + WithOn.builder(%{account_id: "acc_x", metadata: %{any: "value"}}) + end + end + + # ---------------------------------------------------------------- + # domain: — constrain based on a sibling field + # ---------------------------------------------------------------- + describe "domain: sibling-field constraint" do + defmodule WithDomain do + use GuardedStruct + + guardedstruct do + field(:account_type, String.t(), + enforce: true, + derives: "validate(enum=String[free::pro::enterprise])" + ) + + # metadata is only allowed when account_type is in [pro, enterprise] + dynamic_field(:metadata, + domain: "!account_type=String[pro, enterprise]" + ) + end + end + + test "rejects when account_type doesn't match the domain set" do + # ERROR REASON: `domain: "!account_type=String[pro, enterprise]"` + # constrains :metadata's acceptance based on the sibling field. "free" + # is not in [pro, enterprise] → :domain_parameters error. + assert {:error, _} = + WithDomain.builder(%{ + account_type: "free", + metadata: %{a: 1} + }) + end + + test "accepts when account_type is in the allowed set" do + # account_type is in the allowed set → domain check passes. + assert {:ok, _} = + WithDomain.builder(%{ + account_type: "pro", + metadata: %{a: 1} + }) + + assert {:ok, _} = + WithDomain.builder(%{ + account_type: "enterprise", + metadata: %{a: 1} + }) + end + end + + # ---------------------------------------------------------------- + # ALL 5 in one module + # ---------------------------------------------------------------- + describe "all five opts together" do + defmodule AllAtOnce do + use GuardedStruct + + guardedstruct do + field(:id, String.t(), enforce: true) + + field(:account_type, String.t(), + enforce: true, + derives: "validate(enum=String[free::pro::enterprise])" + ) + + field(:trace_data, map(), derives: "validate(map)") + + dynamic_field(:metadata, + enforce: true, + on: "root::id", + domain: "!account_type=String[pro, enterprise]" + ) + + # Computed/pulled metadata — separate dynamic_fields demonstrating + # auto: and from: in the same module. + dynamic_field(:computed_meta, auto: {Computed, :default_metadata, "x"}) + + # from: pulls a MAP (dynamic_field requires its value to be a map) + dynamic_field(:trace_meta, from: "root::trace_data") + end + end + + test "all opts apply together — happy path" do + # enforce, on, and domain checks for :metadata all pass. + # :computed_meta auto-generated; :trace_meta pulled from :trace_data. + assert {:ok, built} = + AllAtOnce.builder(%{ + id: "x", + account_type: "pro", + trace_data: %{trace_id: "trace_xyz"}, + metadata: %{user: "value"} + }) + + assert built.metadata == %{user: "value"} + assert built.computed_meta == %{computed_at: "build_time", source: :auto} + assert built.trace_meta == %{trace_id: "trace_xyz"} + end + + test "enforce: missing :metadata → error" do + # ERROR REASON: :metadata has `enforce: true` on the dynamic_field. + # Input lacks it → :required_fields error. + assert {:error, _} = + AllAtOnce.builder(%{id: "x", account_type: "pro"}) + end + + test "domain: account_type=free → error" do + # ERROR REASON: :metadata has `domain:` constraint that requires + # account_type ∈ [pro, enterprise]. "free" violates → :domain_parameters. + assert {:error, _} = + AllAtOnce.builder(%{ + id: "x", + account_type: "free", + metadata: %{a: 1} + }) + end + end + + # ---------------------------------------------------------------- + # Schema introspection — confirm the new opts appear in __fields__/0 + # (modules defined inside describe blocks live under the test + # module's namespace, so we use fully-qualified names here) + # ---------------------------------------------------------------- + alias GuardedStructFixtures.DynamicFieldFullOptsTest, as: T + + describe "__fields__/0 reflects the new opts" do + test "WithEnforce: :metadata appears in module enforce_keys" do + # Compile-time check: dynamic_field's enforce: true makes it land in + # the module's enforce_keys list, same as any other field. + assert :metadata in T.WithEnforce.enforce_keys() + end + + test "WithFrom: :metadata's meta carries __from_path__ post-compile" do + # The from: string gets parsed into a path list by ParseCoreKeys + # transformer and stored on the field meta for runtime resolution. + meta = Enum.find(T.WithFrom.__fields__(), &(&1.name == :metadata)) + assert meta.__from_path__ == [:root, :source_data] + end + + test "WithOn: :metadata's meta carries __on_path__ post-compile" do + # Same as __from_path__ but for the on: opt. + meta = Enum.find(T.WithOn.__fields__(), &(&1.name == :metadata)) + assert meta.__on_path__ == [:root, :account_id] + end + + test "WithDomain: :metadata's meta carries __domain_ops__" do + # ParseDomain transformer compiles the domain string into an op list. + meta = Enum.find(T.WithDomain.__fields__(), &(&1.name == :metadata)) + assert is_list(meta.__domain_ops__) + assert length(meta.__domain_ops__) > 0 + end + end +end diff --git a/test/fixtures/dynamic_test.exs b/test/fixtures/dynamic_test.exs new file mode 100644 index 0000000..4a47122 --- /dev/null +++ b/test/fixtures/dynamic_test.exs @@ -0,0 +1,197 @@ +defmodule GuardedStructFixtures.DynamicTest do + @moduledoc """ + Tests the `GuardedStructFixtures.Dynamic` fixture — `dynamic_field` + (free-form map) and pattern-keyed map (regex `field` names), plus + composing the two. + """ + + use ExUnit.Case, async: true + + alias GuardedStructFixtures.Dynamic + + describe "atom-attack safety (dynamic_field passthrough)" do + # SECURITY: see SECURITY.md. + # dynamic_field values are PASS-THROUGH — left entirely untouched + # during input normalisation. No key conversion at any depth. + # Whatever you submit, you get back — predictable, identity-preserving, + # immune to atom-table-exhaustion DoS. + + @unique_prefix "z9_atomattack_neverdeclared_anywhere_" + @uuid "11111111-1111-1111-1111-111111111111" + + test "dynamic_field value is identity-preserved — whatever you submit, you get back" do + input = %{ + "foo" => 1, + :bar => 2, + "baz" => %{"nested" => 3}, + "list_of_maps" => [%{"inner" => 1}, %{"inner" => 2}] + } + + {:ok, doc} = + Dynamic.Document.builder(%{id: @uuid, body: "hi", metadata: input}) + + # Byte-identical: NO key conversion at any depth. + assert doc.metadata == input + end + + test "attacker-controlled keys do NOT create new atoms" do + key1 = @unique_prefix <> "aaa_#{:rand.uniform(99_999_999)}" + key2 = @unique_prefix <> "bbb_#{:rand.uniform(99_999_999)}" + + {:ok, doc} = + Dynamic.Document.builder(%{ + id: @uuid, + body: "hi", + metadata: %{key1 => 1, key2 => 2} + }) + + # Keys are still STRINGS — atomized versions DON'T exist: + assert Map.has_key?(doc.metadata, key1) + assert Map.has_key?(doc.metadata, key2) + refute_raise(fn -> String.to_existing_atom(key1) end) + refute_raise(fn -> String.to_existing_atom(key2) end) + end + + test "declared FIELD-NAME keys (as strings) ARE still converted to atoms" do + # The top-level field names are schema-declared atoms — submitting + # them as strings still maps to the right atom (no atom growth, since + # the atom already exists). + assert {:ok, %Dynamic.Document{id: @uuid, body: "hi"}} = + Dynamic.Document.builder(%{"id" => @uuid, "body" => "hi"}) + end + + test "even if a key inside metadata HAPPENS to match an existing atom, it stays a string" do + # Predictability: previously `to_existing_atom` would opportunistically + # convert "theme" if :theme atom existed elsewhere. With dynamic_field + # passthrough, that no longer happens. dynamic_field values are + # untouched, period. + {:ok, doc} = + Dynamic.Document.builder(%{ + id: @uuid, + body: "hi", + metadata: %{"id" => "user-supplied-string", "name" => "x"} + }) + + # :id atom exists (it's a declared field name), but inside the + # dynamic_field VALUE, "id" stays as a string. No magic. + assert doc.metadata == %{"id" => "user-supplied-string", "name" => "x"} + refute Map.has_key?(doc.metadata, :id) + end + + defp refute_raise(fun) do + try do + fun.() + flunk("expected #{inspect(fun)} to raise but it didn't") + rescue + ArgumentError -> :ok + end + end + end + + describe "dynamic_field — free-form map" do + test "defaults to %{}" do + # `dynamic_field :metadata` declares `default: %{}` under the hood + # → omitting it yields the empty map. + assert {:ok, doc} = + Dynamic.Document.builder(%{ + id: "33333333-3333-3333-3333-333333333333", + body: "hi" + }) + + assert doc.metadata == %{} + end + + test "accepts any map shape at runtime" do + # Free-form keys/values — no compile-time schema for inner shape. + # The implicit `validate(map)` is the only constraint. + assert {:ok, doc} = + Dynamic.Document.builder(%{ + id: "33333333-3333-3333-3333-333333333333", + body: "hi", + metadata: %{author: "x", tags: ["a"]} + }) + + assert doc.metadata.author == "x" + end + + test "rejects non-map metadata via the implicit validate(map) derive" do + # ERROR REASON: `dynamic_field` carries `derives: "validate(map)"` + # by default. A plain string fails the :map check. + assert {:error, _} = + Dynamic.Document.builder(%{ + id: "33333333-3333-3333-3333-333333333333", + body: "hi", + metadata: "not a map" + }) + end + + test "rejects non-uuid id on the parent" do + # ERROR REASON: `:id` has `derives: "validate(uuid)"`. The string + # "not-uuid" doesn't match the uuid pattern → :uuid action error. + assert {:error, _} = + Dynamic.Document.builder(%{id: "not-uuid", body: "hi"}) + end + end + + describe "Pattern-keyed map (regex field name)" do + test "builds a plain map of validated structs" do + # Regex `field` name → the module's builder/1 returns a PLAIN MAP + # (no defstruct), keyed by the input string keys, with each value + # built through the referenced `Shard` module. + assert {:ok, %{"shard_1" => %Dynamic.Shard{node: "10.0.0.1"}}} = + Dynamic.ShardsMap.builder(%{"shard_1" => %{node: "10.0.0.1"}}) + end + + test "rejects keys that don't match the regex" do + # ERROR REASON: declared regex is `~r/^shard_\d+$/`. "banana" + # doesn't match → key rejected by the pattern-map runtime. + assert {:error, _} = + Dynamic.ShardsMap.builder(%{"banana" => %{node: "10.0.0.1"}}) + end + + test "rejects an empty input via validate(map, not_empty)" do + # ERROR REASON: the regex field's `derives:` includes `not_empty`. + # An empty input map fails the :not_empty check. + assert {:error, _} = Dynamic.ShardsMap.builder(%{}) + end + + test "rejects non-IPv4 node strings inside Shard" do + # ERROR REASON: `Shard.node` has `derives: "validate(ipv4)"`. + # "not-an-ip" is not a valid IPv4 address. + assert {:error, _} = + Dynamic.ShardsMap.builder(%{"shard_1" => %{node: "not-an-ip"}}) + end + + test "Shard.replicas defaults to 1" do + # `Shard.replicas` has `default: 1`. Omitting it yields 1, not nil. + assert {:ok, %{"shard_1" => %Dynamic.Shard{replicas: 1}}} = + Dynamic.ShardsMap.builder(%{"shard_1" => %{node: "10.0.0.1"}}) + end + end + + describe "Composing a pattern-keyed map module via struct:" do + test "ClusterPlan validates the status enum AND the inner ShardsMap" do + # `:status` enum-validates, `:shards` delegates to ShardsMap + # which pattern-key-validates each entry — both pipelines run. + assert {:ok, plan} = + Dynamic.ClusterPlan.builder(%{ + status: "active", + shards: %{"shard_1" => %{node: "10.0.0.1"}} + }) + + assert plan.status == "active" + assert match?(%{"shard_1" => %Dynamic.Shard{}}, plan.shards) + end + + test "ClusterPlan rejects an invalid status" do + # ERROR REASON: `:status` derives include + # `enum=String[draft::active::archived]`. "unknown" is not in the + # allowed set → :enum action error. + assert {:error, _} = + Dynamic.ClusterPlan.builder(%{ + status: "unknown", + shards: %{"shard_1" => %{node: "10.0.0.1"}} + }) + end + end +end diff --git a/test/fixtures/forms_test.exs b/test/fixtures/forms_test.exs new file mode 100644 index 0000000..c0c8428 --- /dev/null +++ b/test/fixtures/forms_test.exs @@ -0,0 +1,596 @@ +defmodule GuardedStructFixtures.FormsTest do + @moduledoc """ + Comprehensive tests for `GuardedStructFixtures.Forms` — the canonical + real-world signup/login fixture. + + Coverage strategy: **full output equality everywhere**. + + * Happy paths assert the ENTIRE returned struct in one `==` so any + drift in any field (sanitization, hashing, defaults) fails loudly. + * Failure paths assert the EXACT error list/map (field, action, + message) so any change to error format is caught at PR time. + + Sections: + 1. Signup happy paths — 7 tests + 2. Signup boundary values — 4 tests + 3. Signup failure paths — 9 tests + 4. Signup multi-error aggregation — 2 tests + 5. Jason encoding — 3 tests + 6. Login happy paths — 2 tests + 7. Login failure paths — 4 tests + 8. Introspection / module surface — 4 tests + """ + + use ExUnit.Case, async: true + + alias GuardedStructFixtures.Forms + + # SHA256 of the strings used across tests — precomputed so happy-path + # assertions can use `==` against the exact hex digest. + defp sha256(s), do: :crypto.hash(:sha256, s) |> Base.encode16(case: :lower) + + defp hash_of_longenough, do: sha256("longenough") + defp hash_of_passworda, do: sha256("passwordA") + defp hash_of_passwordb, do: sha256("passwordB") + defp hash_of_min_password, do: sha256("min8char") + defp hash_of_unicode, do: sha256("pässwörd") + + # ============================================================ + # 1. Signup — happy paths (full struct equality) + # ============================================================ + describe "Signup happy paths (full == on the returned struct)" do + test "standard input → email sanitised, password hashed, virtual dropped" do + # Input has mixed-case email with whitespace and an 8+ char password. + # Output: lowercase trimmed email, sha256 hash, NO :password_confirmation. + assert Forms.Signup.builder(%{ + email: " ALICE@example.IO ", + password: "longenough", + password_confirmation: "longenough" + }) == + {:ok, + %Forms.Signup{ + email: "alice@example.io", + password: hash_of_longenough() + }} + end + + test "already-lowercase email passes through unchanged after trim" do + # No casing to flip — only trim has effect on this email. + assert Forms.Signup.builder(%{ + email: " alice@example.io ", + password: "longenough", + password_confirmation: "longenough" + }) == + {:ok, + %Forms.Signup{ + email: "alice@example.io", + password: hash_of_longenough() + }} + end + + test "fully-uppercase email becomes fully-lowercase" do + assert Forms.Signup.builder(%{ + email: "ALICE@EXAMPLE.IO", + password: "longenough", + password_confirmation: "longenough" + }) == + {:ok, + %Forms.Signup{ + email: "alice@example.io", + password: hash_of_longenough() + }} + end + + test "hashing is deterministic — same plaintext → same hash" do + # Two builds with the same plaintext yield byte-identical structs. + {:ok, a} = + Forms.Signup.builder(%{ + email: "a@b.io", + password: "longenough", + password_confirmation: "longenough" + }) + + {:ok, b} = + Forms.Signup.builder(%{ + email: "a@b.io", + password: "longenough", + password_confirmation: "longenough" + }) + + assert a == b + assert a.password == hash_of_longenough() + end + + test "different plaintexts → different hashes" do + input_a = %{email: "a@b.io", password: "passwordA", password_confirmation: "passwordA"} + input_b = %{email: "a@b.io", password: "passwordB", password_confirmation: "passwordB"} + + assert Forms.Signup.builder(input_a) == + {:ok, %Forms.Signup{email: "a@b.io", password: hash_of_passworda()}} + + assert Forms.Signup.builder(input_b) == + {:ok, %Forms.Signup{email: "a@b.io", password: hash_of_passwordb()}} + end + + test "unicode password is hashed correctly" do + # SHA256 operates on raw bytes, so non-ASCII chars hash fine. + assert Forms.Signup.builder(%{ + email: "x@y.io", + password: "pässwörd", + password_confirmation: "pässwörd" + }) == + {:ok, + %Forms.Signup{ + email: "x@y.io", + password: hash_of_unicode() + }} + end + + test "password_confirmation does NOT appear on the struct or in Map.keys/1" do + # Locks the virtual_field semantics — even after a successful build, + # the confirmation field is not on the struct, not in Map.from_struct, etc. + {:ok, signup} = + Forms.Signup.builder(%{ + email: "a@b.io", + password: "longenough", + password_confirmation: "longenough" + }) + + refute Map.has_key?(signup, :password_confirmation) + assert Map.keys(signup) |> Enum.sort() == [:__struct__, :email, :password] + end + end + + # ============================================================ + # 2. Signup — boundary values (full equality at the limits) + # ============================================================ + describe "Signup boundary values" do + test "password at the minimum allowed length (8 chars) is accepted" do + assert Forms.Signup.builder(%{ + email: "a@b.io", + password: "min8char", + password_confirmation: "min8char" + }) == + {:ok, + %Forms.Signup{ + email: "a@b.io", + password: hash_of_min_password() + }} + end + + test "password at the maximum allowed length (128 chars) is accepted" do + pw = String.duplicate("x", 128) + hashed = sha256(pw) + + assert Forms.Signup.builder(%{ + email: "a@b.io", + password: pw, + password_confirmation: pw + }) == + {:ok, %Forms.Signup{email: "a@b.io", password: hashed}} + end + + test "long but valid email (≤ 320 chars) is accepted" do + # Need a syntactically-valid email under max_len=320. Build one with + # a long local part and a normal domain — that satisfies both + # email_r's regex AND the length cap. + local = String.duplicate("a", 100) + email = local <> "@example.io" + assert String.length(email) <= 320 + + assert Forms.Signup.builder(%{ + email: email, + password: "longenough", + password_confirmation: "longenough" + }) == + {:ok, %Forms.Signup{email: email, password: hash_of_longenough()}} + end + + test "password 7 chars (one below min) is rejected by the Hasher" do + # ERROR REASON: Hasher.hash/2 requires byte_size in 8..128. 7 chars fails. + # Note: when Hasher errors, the post-validator value is the original input + # (treated as if validation didn't transform), so main_validator's hash + # comparison ALSO fails — producing TWO errors. + assert Forms.Signup.builder(%{ + email: "a@b.io", + password: "7chars!", + password_confirmation: "7chars!" + }) == + {:error, + [ + %{ + message: "passwords don't match", + field: :password_confirmation, + action: :match + }, + %{ + message: "password must be 8-128 characters", + field: :password, + action: :validator + } + ]} + end + end + + # ============================================================ + # 3. Signup — failure paths (exact error shape) + # ============================================================ + describe "Signup failure paths (exact error structure)" do + test "missing :email → single :required_fields map (not a list)" do + # ERROR REASON: orchestration-layer required_fields returns a MAP, + # not a list — locked in here to prevent accidental wrapping change. + assert Forms.Signup.builder(%{ + password: "longenough", + password_confirmation: "longenough" + }) == + {:error, + %{ + message: "Please submit required fields.", + fields: [:email], + action: :required_fields + }} + end + + test "missing :password → single :required_fields map" do + assert Forms.Signup.builder(%{ + email: "a@b.io", + password_confirmation: "longenough" + }) == + {:error, + %{ + message: "Please submit required fields.", + fields: [:password], + action: :required_fields + }} + end + + test "missing :password_confirmation → main_validator's :match error" do + # ERROR REASON: :password_confirmation is a virtual_field. When + # missing, main_validator/1 falls through to the catch-all clause + # (binary guard fails on nil) → returns :match error. + # Note the wrapper differs — :required_fields is a single map but + # main_validator returns a LIST of errors. + assert Forms.Signup.builder(%{ + email: "a@b.io", + password: "longenough" + }) == + {:error, + [ + %{ + message: "passwords don't match", + field: :password_confirmation, + action: :match + } + ]} + end + + test "mismatched confirmation → main_validator :match error" do + # ERROR REASON: both passwords are valid 8+ char strings, so Hasher + # accepts both. But after hashing, the stored hash and the re-hashed + # confirmation differ → main_validator returns :match. + assert Forms.Signup.builder(%{ + email: "a@b.io", + password: "abcdefgh", + password_confirmation: "different" + }) == + {:error, + [ + %{ + message: "passwords don't match", + field: :password_confirmation, + action: :match + } + ]} + end + + test "invalid email format → :email_r action" do + # ERROR REASON: derive's validate(email_r) regex requires "@" + domain. + assert Forms.Signup.builder(%{ + email: "not-an-email", + password: "longenough", + password_confirmation: "longenough" + }) == + {:error, + [ + %{ + message: "Incorrect email in the email field.", + field: :email, + action: :email_r + } + ]} + end + + test "email too long → rejected (either :max_len or :email_r depending on shape)" do + # ERROR REASON: derive's max_len=320 cap. Building a syntactically + # valid email longer than 320 chars and asserting :max_len fires. + # Use a 350-char local part so the shape is still email-like. + local = String.duplicate("a", 350) + long_email = local <> "@example.io" + assert String.length(long_email) > 320 + + assert {:error, errs} = + Forms.Signup.builder(%{ + email: long_email, + password: "longenough", + password_confirmation: "longenough" + }) + + errs = List.wrap(errs) + # The error mentions :email and is a length-violation (:max_len). + assert Enum.any?(errs, &(&1[:field] == :email and &1[:action] == :max_len)) + end + + test "password too long (130 chars) → 2 errors: Hasher rejects + match fails" do + # ERROR REASON: Hasher.hash/2 has the upper bound 128. 130 chars + # fails, password stays unhashed, then main_validator's comparison + # fails too → two errors aggregated. + pw = String.duplicate("x", 130) + + assert Forms.Signup.builder(%{ + email: "a@b.io", + password: pw, + password_confirmation: pw + }) == + {:error, + [ + %{ + message: "passwords don't match", + field: :password_confirmation, + action: :match + }, + %{ + message: "password must be 8-128 characters", + field: :password, + action: :validator + } + ]} + end + + test "non-binary password (integer) → Hasher's catch-all error" do + # ERROR REASON: Hasher's third clause matches non-binary, returns a + # descriptive error embedding the inspected value. + assert Forms.Signup.builder(%{ + email: "a@b.io", + password: 12345, + password_confirmation: "xxxxxxxx" + }) == + {:error, + [ + %{ + message: "passwords don't match", + field: :password_confirmation, + action: :match + }, + %{ + message: "expected a string, got 12345", + field: :password, + action: :validator + } + ]} + end + + test "empty-string password (len=0) → 2 errors" do + # ERROR REASON: same as 7-char path — Hasher rejects on length AND + # main_validator can't compare hashes because Hasher errored. + assert Forms.Signup.builder(%{ + email: "a@b.io", + password: "", + password_confirmation: "" + }) == + {:error, + [ + %{ + message: "passwords don't match", + field: :password_confirmation, + action: :match + }, + %{ + message: "password must be 8-128 characters", + field: :password, + action: :validator + } + ]} + end + end + + # ============================================================ + # 4. Multi-error aggregation (multiple things wrong at once) + # ============================================================ + describe "Signup — multi-error aggregation" do + test "two distinct stage-8 failures (validator + main_validator) BOTH appear" do + # Stage 8 (per-field validator) and stage 9 (main_validator) errors + # are aggregated together. Bad password → Hasher returns :validator, + # AND main_validator fails the hash comparison → :match. + # Note: derive (stage 10) is SHORT-CIRCUITED when main_validator + # fails, so email's :email_r derive does NOT run in this case. + assert {:error, errs} = + Forms.Signup.builder(%{ + email: "not-an-email", + password: "tiny", + password_confirmation: "tiny" + }) + + assert is_list(errs) + actions = Enum.map(errs, & &1.action) |> Enum.sort() + assert :match in actions + assert :validator in actions + end + + test "all-fields invalid → multiple errors collected in one response" do + # Locks the "no short-circuit between stages" invariant for stages + # that both run (per-field validator + main_validator). + assert {:error, errs} = + Forms.Signup.builder(%{ + email: "bad", + password: 999, + password_confirmation: 999 + }) + + errs = List.wrap(errs) + assert length(errs) >= 2 + end + end + + # ============================================================ + # 5. Jason encoding — full decoded-map equality + # ============================================================ + describe "Signup JSON encoding (json: true)" do + test "decoded JSON contains EXACTLY the public fields (no virtuals)" do + {:ok, signup} = + Forms.Signup.builder(%{ + email: "alice@example.io", + password: "longenough", + password_confirmation: "longenough" + }) + + decoded = signup |> Jason.encode!() |> Jason.decode!() + + # Full equality — every key spelled out, virtual fields NOT present. + assert decoded == + %{ + "email" => "alice@example.io", + "password" => hash_of_longenough() + } + end + + test "encoding is round-trip stable (decode-encode-decode → same map)" do + # Map key order in JSON output is NOT deterministic, so we can't + # compare bytes directly. But decoded maps MUST be identical. + {:ok, signup} = + Forms.Signup.builder(%{ + email: "a@b.io", + password: "longenough", + password_confirmation: "longenough" + }) + + once = signup |> Jason.encode!() |> Jason.decode!() + twice = signup |> Jason.encode!() |> Jason.decode!() |> Jason.encode!() |> Jason.decode!() + assert once == twice + end + + test "decoded JSON has exactly two keys" do + {:ok, signup} = + Forms.Signup.builder(%{ + email: "a@b.io", + password: "longenough", + password_confirmation: "longenough" + }) + + decoded = signup |> Jason.encode!() |> Jason.decode!() + assert Map.keys(decoded) |> Enum.sort() == ["email", "password"] + end + end + + # ============================================================ + # 6. Login — happy paths (full equality) + # ============================================================ + describe "Login happy paths" do + test "standard input — email sanitised, password raw (no validator)" do + assert Forms.Login.builder(%{ + email: " USER@example.IO ", + password: "anything" + }) == + {:ok, + %Forms.Login{ + email: "user@example.io", + password: "anything" + }} + end + + test "password is passed through unchanged (Login has no Hasher)" do + # Locks the contract: Login is for AUTHENTICATION, not signup. + # Plaintext password is stored as-is so a downstream service can + # compare it against a stored hash. + assert Forms.Login.builder(%{ + email: "x@y.io", + password: "🔥 plaintext with unicode 🔑" + }) == + {:ok, + %Forms.Login{ + email: "x@y.io", + password: "🔥 plaintext with unicode 🔑" + }} + end + end + + # ============================================================ + # 7. Login — failure paths (exact error shape) + # ============================================================ + describe "Login failure paths" do + test "missing :email → :required_fields error" do + assert Forms.Login.builder(%{password: "anything"}) == + {:error, + %{ + message: "Please submit required fields.", + fields: [:email], + action: :required_fields + }} + end + + test "missing :password → :required_fields error" do + assert Forms.Login.builder(%{email: "x@y.io"}) == + {:error, + %{ + message: "Please submit required fields.", + fields: [:password], + action: :required_fields + }} + end + + test "invalid email → :email_r action" do + # ERROR REASON: derive(validate(email_r)) rejects malformed emails. + assert Forms.Login.builder(%{email: "not-an-email", password: "x"}) == + {:error, + [ + %{ + message: "Incorrect email in the email field.", + field: :email, + action: :email_r + } + ]} + end + + test "empty password → :min_len action (Login uses min_len=1)" do + # ERROR REASON: Login's :password derive is `validate(string, min_len=1)`. + # An empty string is 0 chars < 1 → :min_len. + assert {:error, + [ + %{ + field: :password, + action: :min_len, + message: "The minimum number of characters in the password field is 1" <> _ + } + ]} = Forms.Login.builder(%{email: "x@y.io", password: ""}) + end + end + + # ============================================================ + # 8. Module surface / introspection + # ============================================================ + describe "Signup module introspection" do + test "keys/0 lists email + password (NO :password_confirmation)" do + # Virtual fields don't appear in keys/0 — they're not in defstruct. + assert Forms.Signup.keys() == [:email, :password] + end + + test "enforce_keys/0 includes both visible fields" do + assert Forms.Signup.enforce_keys() |> Enum.sort() == [:email, :password] + end + + test "__information__/0 carries the expected module metadata" do + info = Forms.Signup.__information__() + assert info.module == Forms.Signup + assert info.keys == [:email, :password] + assert Enum.sort(info.enforce_keys) == [:email, :password] + assert info.options.json == true + assert info.conditional_keys == [] + end + + test "__fields__/0 includes the virtual field's metadata (still introspectable)" do + # The virtual field IS still in __fields__/0 — what differs is keys/0 + # / defstruct visibility, not introspection visibility. + names = Forms.Signup.__fields__() |> Enum.map(& &1.name) |> Enum.sort() + assert names == [:email, :password, :password_confirmation] + end + end +end diff --git a/test/fixtures/inline_all_entities_test.exs b/test/fixtures/inline_all_entities_test.exs new file mode 100644 index 0000000..4cc91bf --- /dev/null +++ b/test/fixtures/inline_all_entities_test.exs @@ -0,0 +1,293 @@ +defmodule GuardedStructFixtures.InlineAllEntitiesTest do + @moduledoc """ + End-to-end tests for inline `derives:` opt on every entity type. + + Mirrors `DecoratedAllEntitiesTest` — both syntactic forms should + produce identical `__derive_ops__` metadata AND identical runtime + enforcement. + + Critical regression lock: the `OnVirtualField` runtime test confirms + `derives:` on `virtual_field` ACTUALLY fires (previously broken before + the two-pass derive fix in Runtime). + """ + + use ExUnit.Case, async: true + + alias GuardedStructFixtures.InlineAllEntities, as: I + + defp find_field(fields, name), do: Enum.find(fields, &(&1.name == name)) + + describe "inline derives: on field (level 1)" do + test "__fields__/0 carries the parsed sanitize+validate op map" do + # Inline `derives:` lands in `__fields__/0` exactly the same as the + # decorator form would — same parsed op map. + meta = find_field(I.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: sanitize trims input, max_len=10 rejects long input" do + # " x " → sanitize(trim) → "x" → validate(string, max_len=10) passes. + assert {:ok, %{name: "x"}} = I.OnField.builder(%{name: " x "}) + + # ERROR REASON: 16-char "this is too long" exceeds max_len=10 → :max_len. + assert {:error, _} = I.OnField.builder(%{name: "this is too long"}) + end + end + + describe "inline derives: on virtual_field (level 1)" do + test "__fields__/0 includes the virtual_field with its derive ops" do + # Even though virtual_field is dropped from the struct, its derive + # ops live in __fields__/0 for introspection (and runtime, see below). + meta = find_field(I.OnVirtualField.__fields__(), :password_confirmation) + assert meta.derive == "validate(string, min_len=8)" + assert meta.__derive_ops__ == %{validate: [:string, {:min_len, 8}]} + end + + test "runtime (FIXED): min_len=8 actually fires on the virtual value" do + # Pre-fix: this would have succeeded because virtual_field derives + # were dropped before run_derives/2. Now the two-pass derive in + # Runtime catches them on the merged map before wrap drops them. + + # Happy path: 10-char password passes min_len=8. + assert {:ok, struct} = + I.OnVirtualField.builder(%{keep: "x", password_confirmation: "longenough"}) + + refute Map.has_key?(struct, :password_confirmation) + + # ERROR REASON: "short" (5 chars) fails min_len=8 → :min_len error. + assert {:error, errs} = + I.OnVirtualField.builder(%{keep: "x", password_confirmation: "short"}) + + assert Enum.any?( + errs, + &(&1[:field] == :password_confirmation and &1[:action] == :min_len) + ) + end + end + + describe "inline derives: on dynamic_field (level 1)" do + test "inline derives wins over the schema default `validate(map)`" do + # dynamic_field's schema-default `derives:` is `"validate(map)"`. The + # user's explicit `derives:` opt replaces it — `__derive_ops__` reflects + # the user's choice, not the default. + meta = find_field(I.OnDynamicField.__fields__(), :metadata) + assert meta.derive == "validate(map, not_empty)" + assert meta.__derive_ops__ == %{validate: [:map, :not_empty]} + end + + test "runtime: not_empty rejects the default empty map" do + # ERROR REASON: dynamic_field's `default: %{}` is applied when input + # omits :metadata. The user's `validate(map, not_empty)` then runs + # against `%{}` and the :not_empty check fails. + assert {:error, _} = I.OnDynamicField.builder(%{}) + + # Non-empty map passes both :map and :not_empty. + assert {:ok, _} = I.OnDynamicField.builder(%{metadata: %{any: "value"}}) + end + end + + describe "inline derives: on sub_field (level 1)" do + test "__fields__/0 carries derive on the sub_field meta" do + # The derive is on the OUTER meta (the sub_field declaration itself), + # not on the inner submodule. So __fields__/0 of the parent shows it. + meta = find_field(I.OnSubField.__fields__(), :profile) + assert meta.derive == "validate(map)" + assert meta.__derive_ops__ == %{validate: [:map]} + end + + test "runtime: non-map rejected before descending into sub_field body" do + # ERROR REASON: sub_field's `derives: "validate(map)"` runs on the + # sub_field's input value BEFORE descending into the body — a string + # fails the :map check and the body never runs. + assert {:error, _} = I.OnSubField.builder(%{profile: "not a map"}) + + # Map passes :map → body descends → inner field accepts any string. + assert {:ok, _} = I.OnSubField.builder(%{profile: %{bio: "hello"}}) + end + end + + describe "inline derives: on conditional_field (level 1)" do + test "__fields__/0 carries derive on the conditional_field meta" do + meta = find_field(I.OnConditionalField.__fields__(), :detail) + assert meta.derive == "validate(map)" + assert meta.__derive_ops__ == %{validate: [:map]} + end + + test "runtime: pre-branch validate(map) blocks non-map inputs" do + # ERROR REASON: conditional_field's derive runs BEFORE branch + # resolution. validate(map) on the conditional itself means the + # value MUST be a map — string is rejected here, not at branch. + assert {:error, _} = I.OnConditionalField.builder(%{detail: "not a map"}) + + # Maps pass the pre-branch :map check, then branch resolution picks + # whichever sub_field branch's `validator:` matches (both branches + # are :is_map here so the first matches; second works the same way). + assert {:ok, _} = I.OnConditionalField.builder(%{detail: %{tag: "x"}}) + assert {:ok, _} = I.OnConditionalField.builder(%{detail: %{tag: "x", extra: "y"}}) + end + end + + describe "inline derives: on field INSIDE a sub_field body (level 2)" do + test "inner field carries the derive in submodule __fields__/0" do + # The derive is on the INNER field of a sub_field. The auto-generated + # submodule (Wrapper) has its own __fields__/0 that exposes this. + meta = find_field(I.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 + # "x" passes (after trim) the max_len=5 check. + assert {:ok, _} = I.InsideSubField.builder(%{wrapper: %{tag: "x"}}) + + # ERROR REASON: "way too long" is 12 chars > max_len=5 → :max_len. + assert {:error, _} = + I.InsideSubField.builder(%{wrapper: %{tag: "way too long"}}) + end + end + + describe "inline derives: on conditional branch fields" do + test "branch field's __derive_ops__ is populated" do + # Each branch of a conditional has its own derive ops in `children`. + [conditional] = I.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 "inner sub_field branch field also carries its derive" do + # The conditional's SECOND branch is a sub_field — `Body1` is the + # auto-numbered submodule. Its inner :kind field's derive lives in + # the submodule's __fields__/0. + meta = find_field(I.InsideConditional.Body1.__fields__(), :kind) + assert meta.derive == "validate(string)" + assert meta.__derive_ops__ == %{validate: [:string]} + end + + test "runtime: each branch enforces independently" do + # String branch's max_len=10 passes for "ok" (2 chars). + assert {:ok, _} = I.InsideConditional.builder(%{body: "ok"}) + + # ERROR REASON: string branch's max_len=10 rejects this 32-char input. + assert {:error, _} = + I.InsideConditional.builder(%{body: "this is too long for max_len=10"}) + + # Map input goes to the sub_field branch where :kind's derive(string) + # accepts any binary. + assert {:ok, _} = I.InsideConditional.builder(%{body: %{kind: "anything"}}) + end + end + + describe "deep nesting — inline derives: at levels 1, 2, 3, 4" do + test "every level carries its own derive payload" do + # Inline derives at four different depths land in four different + # submodules' __fields__/0 — each with the right max_len limit. + top = find_field(I.DeepNested.__fields__(), :top) + assert top.__derive_ops__ == %{validate: [:string, {:max_len, 10}]} + + l1 = find_field(I.DeepNested.__fields__(), :l1) + assert l1.__derive_ops__ == %{validate: [:map]} + + l2_tag = find_field(I.DeepNested.L1.__fields__(), :tag) + assert l2_tag.__derive_ops__ == %{validate: [:string, {:max_len, 20}]} + + l3_tag = find_field(I.DeepNested.L1.L2.__fields__(), :tag) + assert l3_tag.__derive_ops__ == %{validate: [:string, {:max_len, 30}]} + + l4_tag = find_field(I.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" do + # Every level's tag fits its level's max_len → all pass. + assert {:ok, _} = + I.DeepNested.builder(%{ + top: "topshort", + l1: %{ + tag: "lvl2", + l2: %{tag: "lvl3", l3: %{tag: "lvl4"}} + } + }) + + # ERROR REASON: level-4's tag is 41 chars > max_len=40 → error + # bubbles up through level 3 → 2 → 1 → root. + assert {:error, _} = + I.DeepNested.builder(%{ + top: "ok", + l1: %{ + tag: "ok", + l2: %{tag: "ok", l3: %{tag: String.duplicate("x", 41)}} + } + }) + + # ERROR REASON: 50-char :top exceeds level-1 max_len=10. Top-level + # fails immediately without needing the nested children. + assert {:error, _} = + I.DeepNested.builder(%{top: String.duplicate("x", 50)}) + end + end + + describe "mixed all-entities module (inline form)" do + test "__fields__/0 shows the right derive on each entity type" do + # Lock that every entity-type's inline derive lands correctly. + # If any entity-type's inline support breaks, exactly one of these + # assertions will fail. + fields = I.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]} + end + + test "nested sub_field's submodule has its inner-field derive" do + # Inner-field derives live on the auto-generated submodule, not + # on the parent. Confirms the codegen plumbs them into the right + # __fields__/0. + meta = find_field(I.MixedAll.Nested.__fields__(), :label) + assert meta.__derive_ops__ == %{validate: [:string, {:max_len, 10}]} + end + + test "runtime: every layer's validation fires" do + # Happy path — every layer's derive passes. + assert {:ok, _} = + I.MixedAll.builder(%{ + plain: "p", + extras: %{a: 1}, + totp: "1234", + nested: %{label: "ok"}, + variant: "string-variant" + }) + + # ERROR REASON: :label's max_len=10 (inside the sub_field's submodule) + # rejects the long label. Error bubbles up from the submodule. + assert {:error, _} = + I.MixedAll.builder(%{ + plain: "p", + totp: "1234", + nested: %{label: "way too long for the limit"}, + variant: "x" + }) + + # ERROR REASON: :totp is a VIRTUAL field with min_len=3. "xx" (2 + # chars) fails. This test specifically locks in the virtual_field + # runtime fix — pre-fix it would NOT have rejected this input. + assert {:error, errs} = + I.MixedAll.builder(%{plain: "p", totp: "xx", variant: "x"}) + + errs = List.wrap(errs) + + assert Enum.any?( + errs, + &(&1[:field] == :totp and &1[:action] == :min_len) + ) + end + end +end diff --git a/test/fixtures/mixed_decorator_inline_test.exs b/test/fixtures/mixed_decorator_inline_test.exs new file mode 100644 index 0000000..3d175e4 --- /dev/null +++ b/test/fixtures/mixed_decorator_inline_test.exs @@ -0,0 +1,171 @@ +defmodule GuardedStructFixtures.MixedDecoratorInlineTest do + @moduledoc """ + Tests that the `@derives` decorator and inline `derives:` opt can be + freely mixed in the same module / nested module, AND that both forms + produce identical `__derive_ops__` metadata. + """ + + use ExUnit.Case, async: true + + alias GuardedStructFixtures.MixedDecoratorInline, as: M + + defp find_field(fields, name), do: Enum.find(fields, &(&1.name == name)) + + describe "siblings — decorator on field A, inline on field B" do + test "both forms produce parsed op maps in __fields__/0" do + fields = M.SiblingMix.__fields__() + + assert find_field(fields, :short_name).__derive_ops__ == + %{validate: [:string, {:max_len, 5}]} + + assert find_field(fields, :long_name).__derive_ops__ == + %{validate: [:string, {:max_len, 50}]} + end + + test "runtime: each field enforces its own rule independently" do + assert {:ok, _} = M.SiblingMix.builder(%{short_name: "ok", long_name: "still fine"}) + + # short_name max_len=5 fails + assert {:error, _} = + M.SiblingMix.builder(%{short_name: "way too long", long_name: "ok"}) + + # long_name max_len=50 fails + assert {:error, _} = + M.SiblingMix.builder(%{ + short_name: "ok", + long_name: String.duplicate("x", 60) + }) + end + end + + describe "outer decorator + inner inline" do + test "decorator on the sub_field meta, inline on the inner field" do + outer = find_field(M.OuterDecoratorInnerInline.__fields__(), :profile) + assert outer.__derive_ops__ == %{validate: [:map]} + + inner = find_field(M.OuterDecoratorInnerInline.Profile.__fields__(), :nickname) + assert inner.__derive_ops__ == %{validate: [:string, {:max_len, 20}]} + end + + test "runtime: both rules enforce" do + assert {:ok, _} = + M.OuterDecoratorInnerInline.builder(%{profile: %{nickname: "ok"}}) + + # outer validate(map) fails + assert {:error, _} = + M.OuterDecoratorInnerInline.builder(%{profile: "not a map"}) + + # inner max_len=20 fails + assert {:error, _} = + M.OuterDecoratorInnerInline.builder(%{ + profile: %{nickname: String.duplicate("x", 30)} + }) + end + end + + describe "outer inline + inner decorator" do + test "inline on the sub_field meta, decorator on the inner field" do + outer = find_field(M.OuterInlineInnerDecorator.__fields__(), :profile) + assert outer.__derive_ops__ == %{validate: [:map]} + + inner = find_field(M.OuterInlineInnerDecorator.Profile.__fields__(), :nickname) + assert inner.__derive_ops__ == %{validate: [:string, {:max_len, 20}]} + end + + test "runtime: both rules enforce (mirror of the previous case)" do + assert {:ok, _} = + M.OuterInlineInnerDecorator.builder(%{profile: %{nickname: "ok"}}) + + assert {:error, _} = + M.OuterInlineInnerDecorator.builder(%{profile: "not a map"}) + + assert {:error, _} = + M.OuterInlineInnerDecorator.builder(%{ + profile: %{nickname: String.duplicate("x", 30)} + }) + end + end + + describe "both forms on the SAME field — inline wins (precedence rule)" do + test "the inline derives: max_len=100 wins, not the decorator's max_len=5" do + meta = find_field(M.BothOnSameField.__fields__(), :name) + # max_len=100 (inline) is in the parsed ops, NOT max_len=5 (decorator) + assert meta.__derive_ops__ == %{validate: [:string, {:max_len, 100}]} + end + + test "runtime: a 50-char name passes (would fail the decorator's max_len=5)" do + # The decorator-only rule would reject this; inline (max_len=100) accepts. + assert {:ok, _} = + M.BothOnSameField.builder(%{name: String.duplicate("x", 50)}) + + # But 150 chars still fails the inline max_len=100 + assert {:error, _} = + M.BothOnSameField.builder(%{name: String.duplicate("x", 150)}) + end + end + + describe "adjacent virtual_field — one decorator, one inline" do + test "each virtual carries its own derive ops" do + fields = M.VirtualMix.__fields__() + + assert find_field(fields, :totp_a).__derive_ops__ == + %{validate: [:string, {:min_len, 4}]} + + assert find_field(fields, :totp_b).__derive_ops__ == + %{validate: [:string, {:min_len, 6}]} + end + + test "runtime: both virtual_field derives enforce independently" do + assert {:ok, _} = + M.VirtualMix.builder(%{keep: "x", totp_a: "abcd", totp_b: "abcdef"}) + + # totp_a min_len=4 fails (3 chars) + assert {:error, errs} = + M.VirtualMix.builder(%{keep: "x", totp_a: "abc", totp_b: "abcdef"}) + + errs = List.wrap(errs) + assert Enum.any?(errs, &(&1[:field] == :totp_a and &1[:action] == :min_len)) + + # totp_b min_len=6 fails (5 chars) + assert {:error, errs} = + M.VirtualMix.builder(%{keep: "x", totp_a: "abcd", totp_b: "short"}) + + errs = List.wrap(errs) + assert Enum.any?(errs, &(&1[:field] == :totp_b and &1[:action] == :min_len)) + end + + test "decorator is one-shot — it does NOT leak past totp_a to totp_b" do + # If the decorator leaked, totp_b would have min_len=4 (totp_a's rule). + # We verify by sending a 5-char value to totp_b: should FAIL with + # totp_b's own min_len=6 rule (and message), not totp_a's. + assert {:error, errs} = + M.VirtualMix.builder(%{keep: "x", totp_a: "abcd", totp_b: "abcde"}) + + errs = List.wrap(errs) + # The failure is on totp_b — proving its OWN derive is enforced, not + # leaked-from-decorator. + assert Enum.any?(errs, &(&1[:field] == :totp_b)) + end + end + + describe "decorator on conditional + inline on branch field" do + test "both forms coexist in a conditional structure" do + cond_meta = find_field(M.ConditionalMix.__fields__(), :detail) + assert cond_meta.__derive_ops__ == %{validate: [:map]} + + tag_meta = find_field(M.ConditionalMix.Detail1.__fields__(), :tag) + assert tag_meta.__derive_ops__ == %{validate: [:string, {:max_len, 8}]} + end + + test "runtime: conditional's validate(map) blocks non-maps; tag's max_len=8 enforces" do + assert {:ok, _} = + M.ConditionalMix.builder(%{detail: %{tag: "okok"}}) + + assert {:error, _} = + M.ConditionalMix.builder(%{detail: "not a map"}) + + assert {:error, _} = + M.ConditionalMix.builder(%{detail: %{tag: "way too long"}}) + end + end +end diff --git a/test/fixtures/records_test.exs b/test/fixtures/records_test.exs new file mode 100644 index 0000000..8976957 --- /dev/null +++ b/test/fixtures/records_test.exs @@ -0,0 +1,509 @@ +defmodule GuardedStructFixtures.RecordsTest do + @moduledoc """ + Comprehensive tests for `GuardedStructFixtures.Records` — + Erlang Record support via `validate(record)` and `validate(record=Tag)`. + + Strategy: full equality assertions everywhere. Happy paths use `==` on + the entire returned struct (records are just tagged tuples, so equality + is straightforward). Failure paths use `==` on the exact error list to + lock the error shape. + + Sections: + 1. Record fundamentals — confirm what `Record.defrecord` produces + 2. validate(record=user) happy paths + 3. validate(record=user) failure paths (wrong tag, non-tuple, etc.) + 4. validate(record) (no tag) — any tagged tuple + 5. event_kind enum tests + 6. Missing required fields + 7. Multi-error aggregation + 8. Edge cases (default fields, nested records, large records) + 9. Introspection / module surface + """ + + use ExUnit.Case, async: true + + require Record + require GuardedStructFixtures.Records + alias GuardedStructFixtures.Records + + # ============================================================ + # 1. Record fundamentals — what defrecord actually produces + # ============================================================ + describe "Record fundamentals (what the Record module gives us)" do + test "Records.user(...) is just a tagged tuple — Record.is_record/1 confirms" do + # The macro `Records.user/1` expands to a plain tuple literal whose + # first element is the atom `:user`. This is what Erlang/OTP code + # passes around when it talks about "records". + rec = Records.user(name: "Alice", age: 30) + + assert rec == {:user, "Alice", 30} + assert Record.is_record(rec) + assert Record.is_record(rec, :user) + refute Record.is_record(rec, :address) + end + + test "Records.user/0 returns a record with all defaults (nil)" do + # Record.defrecord(:user, ..., name: nil, age: nil) makes nil the + # default for omitted keys. + assert Records.user() == {:user, nil, nil} + assert Records.user(name: "X") == {:user, "X", nil} + assert Records.user(age: 99) == {:user, nil, 99} + end + + test "Records.address has 3 fields — defrecord arity matches" do + assert Records.address() == {:address, nil, nil, nil} + + assert Records.address(street: "Main", city: "NYC", zip: "10001") == + {:address, "Main", "NYC", "10001"} + end + + test "records of different tags carry different tags at position 0" do + # The fundamental difference: tag = first element. That's what + # `validate(record=Tag)` checks at runtime. + u = Records.user(name: "x", age: nil) + a = Records.address(street: "x", city: nil, zip: nil) + + assert elem(u, 0) == :user + assert elem(a, 0) == :address + assert Record.is_record(u, :user) + assert Record.is_record(a, :address) + refute Record.is_record(u, :address) + refute Record.is_record(a, :user) + end + end + + # ============================================================ + # 2. validate(record=user) — happy paths (full struct ==) + # ============================================================ + describe "validate(record=user) happy paths (full == on the struct)" do + test "minimal valid input — full struct equality" do + rec = Records.user(name: "Alice", age: 30) + + assert Records.UserEvent.builder(%{event_kind: :created, user: rec}) == + {:ok, + %Records.UserEvent{ + event_kind: :created, + user: {:user, "Alice", 30}, + trace: nil + }} + end + + test "all-defaults user record (all fields nil) still passes" do + # The :record validator checks tagged-tuple shape, NOT the inner + # field values. So a fully-nil-filled user record is still a valid + # :user record. + rec = Records.user() + + assert Records.UserEvent.builder(%{event_kind: :updated, user: rec}) == + {:ok, + %Records.UserEvent{ + event_kind: :updated, + user: {:user, nil, nil}, + trace: nil + }} + end + + test "raw tagged tuple (not built via macro) also accepted — same shape" do + # Macros are syntactic sugar — what matters is the resulting tuple + # shape. A hand-built `{:user, ..., ...}` tuple is byte-identical. + raw = {:user, "Bob", 25} + built = Records.user(name: "Bob", age: 25) + + assert raw == built + + assert Records.UserEvent.builder(%{event_kind: :created, user: raw}) == + {:ok, + %Records.UserEvent{ + event_kind: :created, + user: raw, + trace: nil + }} + end + + test "all three event_kind atoms work end-to-end with full equality" do + rec = Records.user(name: "X", age: 1) + + for kind <- [:created, :updated, :deleted] do + assert Records.UserEvent.builder(%{event_kind: kind, user: rec}) == + {:ok, + %Records.UserEvent{ + event_kind: kind, + user: {:user, "X", 1}, + trace: nil + }} + end + end + end + + # ============================================================ + # 3. validate(record=user) — failure paths (exact error shape) + # ============================================================ + describe "validate(record=user) failure paths (exact error structure)" do + test "wrong tag (:address record) → :record action error" do + # ERROR REASON: `:user` field's derive is `validate(record=user)`. + # An `:address` record has the wrong tag at position 0. + bad = Records.address(street: "Main", city: "NYC", zip: "10001") + + assert Records.UserEvent.builder(%{event_kind: :created, user: bad}) == + {:error, + [ + %{ + message: "The user field is not a valid Erlang record (a tagged tuple).", + field: :user, + action: :record + } + ]} + end + + test "non-tuple (string) → :record error" do + # The validator's error message is the same for "not a record" and + # "wrong tag" — both fall under :record action. + assert Records.UserEvent.builder(%{event_kind: :created, user: "not a tuple"}) == + {:error, + [ + %{ + message: "The user field is not a valid Erlang record (a tagged tuple).", + field: :user, + action: :record + } + ]} + end + + test "non-tuple (map) → :record error" do + assert Records.UserEvent.builder(%{event_kind: :created, user: %{name: "x"}}) == + {:error, + [ + %{ + message: "The user field is not a valid Erlang record (a tagged tuple).", + field: :user, + action: :record + } + ]} + end + + test "non-tuple (atom) → :record error" do + # ERROR REASON: an atom (e.g. `:user`) is NOT a tuple even though the + # tag name matches — record validation requires tuple shape. + assert Records.UserEvent.builder(%{event_kind: :created, user: :user}) == + {:error, + [ + %{ + message: "The user field is not a valid Erlang record (a tagged tuple).", + field: :user, + action: :record + } + ]} + end + + test "empty tuple `{}` → :record error (no tag)" do + assert Records.UserEvent.builder(%{event_kind: :created, user: {}}) == + {:error, + [ + %{ + message: "The user field is not a valid Erlang record (a tagged tuple).", + field: :user, + action: :record + } + ]} + end + + test "tuple with string-first-element → :record error (tag must be atom)" do + # ERROR REASON: a record's tag MUST be an atom. A tuple whose first + # element is a string is rejected even if shape-similar. + assert Records.UserEvent.builder(%{event_kind: :created, user: {"user", "Alice", 30}}) == + {:error, + [ + %{ + message: "The user field is not a valid Erlang record (a tagged tuple).", + field: :user, + action: :record + } + ]} + end + end + + # ============================================================ + # 4. validate(record) — any tag accepted on :trace + # ============================================================ + describe "validate(record) — no tag constraint (the :trace field)" do + test "accepts a :user record on :trace (no specific tag required)" do + rec = Records.user(name: "Alice", age: 30) + trace = Records.user(name: "Bob", age: 25) + + assert Records.UserEvent.builder(%{event_kind: :created, user: rec, trace: trace}) == + {:ok, + %Records.UserEvent{ + event_kind: :created, + user: {:user, "Alice", 30}, + trace: {:user, "Bob", 25} + }} + end + + test "accepts ANY tagged tuple on :trace — custom tag works" do + rec = Records.user(name: "X", age: 1) + trace = {:custom_tag, "any", "payload", :here} + + assert Records.UserEvent.builder(%{event_kind: :created, user: rec, trace: trace}) == + {:ok, + %Records.UserEvent{ + event_kind: :created, + user: {:user, "X", 1}, + trace: {:custom_tag, "any", "payload", :here} + }} + end + + test "trace defaults to nil when omitted (it's not enforced)" do + rec = Records.user(name: "X", age: 1) + + assert Records.UserEvent.builder(%{event_kind: :created, user: rec}) == + {:ok, + %Records.UserEvent{ + event_kind: :created, + user: {:user, "X", 1}, + trace: nil + }} + end + + test ":trace non-tuple (map) → :record error" do + # ERROR REASON: `validate(record)` (no tag) still requires the value + # to be a tagged tuple. A map fails the tuple check. + rec = Records.user(name: "X", age: 1) + + assert Records.UserEvent.builder(%{event_kind: :created, user: rec, trace: %{}}) == + {:error, + [ + %{ + message: "The trace field is not a valid Erlang record (a tagged tuple).", + field: :trace, + action: :record + } + ]} + end + + test ":trace non-tuple (string) → :record error" do + rec = Records.user(name: "X", age: 1) + + assert Records.UserEvent.builder(%{event_kind: :created, user: rec, trace: "anywhere"}) == + {:error, + [ + %{ + message: "The trace field is not a valid Erlang record (a tagged tuple).", + field: :trace, + action: :record + } + ]} + end + end + + # ============================================================ + # 5. event_kind enum tests (full error shape) + # ============================================================ + describe "event_kind enum (validate(enum=Atom[...]))" do + test "rejects unknown atom :exploded → exact :enum error" do + # ERROR REASON: derive enum=Atom[created::updated::deleted] only + # accepts those three. :exploded is not in the set. + rec = Records.user(name: "X", age: 1) + + assert Records.UserEvent.builder(%{event_kind: :exploded, user: rec}) == + {:error, + [ + %{ + message: "Your sent data form event_kind field is not in the allowed list", + field: :event_kind, + action: :enum + } + ]} + end + + test "rejects string (not atom) → :enum error" do + # ERROR REASON: enum=Atom[...] requires the value to be an atom. + # "created" (string) is not an atom. + rec = Records.user(name: "X", age: 1) + + assert Records.UserEvent.builder(%{event_kind: "created", user: rec}) == + {:error, + [ + %{ + message: "Your sent data form event_kind field is not in the allowed list", + field: :event_kind, + action: :enum + } + ]} + end + end + + # ============================================================ + # 6. Missing required fields + # ============================================================ + describe "missing required fields" do + test "missing :event_kind → :required_fields map (not a list)" do + # ERROR REASON: orchestration-layer required-fields error returns + # a MAP (not a list-of-maps). Same shape as forms_test confirms. + rec = Records.user(name: "X", age: 1) + + assert Records.UserEvent.builder(%{user: rec}) == + {:error, + %{ + message: "Please submit required fields.", + fields: [:event_kind], + action: :required_fields + }} + end + + test "missing :user → :required_fields map" do + assert Records.UserEvent.builder(%{event_kind: :created}) == + {:error, + %{ + message: "Please submit required fields.", + fields: [:user], + action: :required_fields + }} + end + + test "missing BOTH enforce'd fields → both listed in one :required_fields error" do + assert {:error, %{action: :required_fields, fields: fields}} = + Records.UserEvent.builder(%{}) + + assert Enum.sort(fields) == [:event_kind, :user] + end + end + + # ============================================================ + # 7. Multi-error aggregation + # ============================================================ + describe "multi-error aggregation" do + test "bad event_kind + wrong record tag → BOTH errors collected" do + # Confirms the runtime aggregates errors across stage 10's two-pass + # derive — :user (validate(record=user)) AND :event_kind (enum) both fail. + bad_rec = Records.address(street: "x", city: "y", zip: "z") + + assert Records.UserEvent.builder(%{event_kind: :exploded, user: bad_rec}) == + {:error, + [ + %{ + message: "The user field is not a valid Erlang record (a tagged tuple).", + field: :user, + action: :record + }, + %{ + message: "Your sent data form event_kind field is not in the allowed list", + field: :event_kind, + action: :enum + } + ]} + end + + test "all-three derive failures (user, trace, event_kind) → 3 errors" do + assert {:error, errs} = + Records.UserEvent.builder(%{ + event_kind: :exploded, + user: "bad", + trace: "also bad" + }) + + assert is_list(errs) + # Three distinct errors aggregated: + actions = Enum.map(errs, & &1.action) |> Enum.sort() + assert actions == [:enum, :record, :record] + + fields = Enum.map(errs, & &1.field) |> Enum.sort() + assert fields == [:event_kind, :trace, :user] + end + end + + # ============================================================ + # 8. Edge cases + # ============================================================ + describe "edge cases" do + test "records with complex / nested field values pass through unchanged" do + # Record fields can be ANYTHING — maps, lists, other records. + # The :record validator checks ONLY the tagged-tuple shape, not + # the inner field types. + rec = Records.user(name: %{first: "Ada", last: "Lovelace"}, age: [1, 8, 1, 5]) + + assert Records.UserEvent.builder(%{event_kind: :created, user: rec}) == + {:ok, + %Records.UserEvent{ + event_kind: :created, + user: {:user, %{first: "Ada", last: "Lovelace"}, [1, 8, 1, 5]}, + trace: nil + }} + end + + test "trace field can be a record nested in a record (tagged tuple of tagged tuple)" do + rec = Records.user(name: "Alice", age: 30) + nested = {:outer, {:inner, :data}, "more"} + + assert Records.UserEvent.builder(%{event_kind: :created, user: rec, trace: nested}) == + {:ok, + %Records.UserEvent{ + event_kind: :created, + user: {:user, "Alice", 30}, + trace: {:outer, {:inner, :data}, "more"} + }} + end + + test "record values survive Map.from_struct round-trip (no transformation)" do + # Locks the "transparent passthrough" contract — records aren't + # re-encoded or transformed in any way by GuardedStruct. + {:ok, event} = + Records.UserEvent.builder(%{ + event_kind: :created, + user: Records.user(name: "X", age: 1) + }) + + assert event.user == Records.user(name: "X", age: 1) + assert event.user == {:user, "X", 1} + assert Record.is_record(event.user, :user) + end + + test "post-build record can be pattern-matched with the Record macro" do + {:ok, event} = + Records.UserEvent.builder(%{ + event_kind: :created, + user: Records.user(name: "Alice", age: 30) + }) + + # Use the generated macro to deconstruct — confirms the value + # survives the build pipeline intact. + assert Records.user(name: name, age: age) = event.user + assert name == "Alice" + assert age == 30 + end + end + + # ============================================================ + # 9. Module surface / introspection + # ============================================================ + describe "UserEvent module introspection" do + test "keys/0 lists all three fields in declaration order" do + assert Records.UserEvent.keys() == [:event_kind, :user, :trace] + end + + test "enforce_keys/0 lists :event_kind and :user only (:trace is optional)" do + assert Enum.sort(Records.UserEvent.enforce_keys()) == [:event_kind, :user] + end + + test "__information__/0 reports the module metadata correctly" do + info = Records.UserEvent.__information__() + assert info.module == Records.UserEvent + assert info.keys == [:event_kind, :user, :trace] + assert Enum.sort(info.enforce_keys) == [:event_kind, :user] + assert info.conditional_keys == [] + end + + test "__fields__/0 carries the derive ops for each record-typed field" do + fields = Records.UserEvent.__fields__() + + user_meta = Enum.find(fields, &(&1.name == :user)) + assert user_meta.derive == "validate(record=user)" + # Parsed op is the tuple form {:record, "user"}. + assert %{validate: [{:record, "user"}]} = user_meta.__derive_ops__ + + trace_meta = Enum.find(fields, &(&1.name == :trace)) + assert trace_meta.derive == "validate(record)" + # No tag → just the bare atom. + assert %{validate: [:record]} = trace_meta.__derive_ops__ + end + end +end diff --git a/test/fixtures/showcase_test.exs b/test/fixtures/showcase_test.exs new file mode 100644 index 0000000..defaf20 --- /dev/null +++ b/test/fixtures/showcase_test.exs @@ -0,0 +1,279 @@ +defmodule GuardedStructFixtures.ShowcaseTest do + @moduledoc """ + Tests the `GuardedStructFixtures.Showcase` fixture — the + everything-at-once `EnterpriseAccount` schema combining `json: true`, + `@derives` decorator, `virtual_field`, `auto:`, `from:`, + list-of-sub_field via `structs: true`, nested `conditional_field`, + `dynamic_field`, and `main_validator/1`. + + Doubles as an integration test for the public API surface used over + this kind of schema: `Diff.diff/2`, `Diff.apply/2`, `Validate.run/2`, + `Validate.field/4`, `Validate.partial/2`, `Errors.from_tuple/1`, + `Info.fields/1`, `__information__/0`, `example/0`. + """ + + # async: false — wires the CustomDerives extensions via Application.put_env + use ExUnit.Case, async: false + + alias GuardedStruct.{Diff, Errors, Info, Validate} + alias GuardedStructFixtures.{CustomDerives, Showcase} + + 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 + + defp valid_account_input(overrides \\ %{}) do + Map.merge( + %{ + name: "Acme Corp", + owner: %{ + id: "44444444-4444-4444-4444-444444444444", + email: "OWNER@ACME.io" + }, + members: [ + %{id: "55555555-5555-5555-5555-555555555555", email: "a@acme.io", role: "admin"} + ], + plan: "enterprise", + settings: %{billing_email: "billing@acme.io"}, + invitation_token: "abcdefghij1234567890" + }, + overrides + ) + end + + describe "EnterpriseAccount — build variants" do + test "builds with a string-preset plan" do + # Top-level conditional `:plan` resolves to the string branch. + # `from:` snapshots `owner.email` BEFORE the owner sub_field sanitises + # its own copy, so `:owner_email` keeps the raw caps while + # `acc.owner.email` is lowercased. + assert {:ok, acc} = Showcase.EnterpriseAccount.builder(valid_account_input()) + assert acc.plan == "enterprise" + assert acc.owner_email == "OWNER@ACME.io" + assert acc.owner.email == "owner@acme.io" + # `auto:` minted an :id; `virtual_field` :invitation_token dropped. + assert is_binary(acc.id) and acc.id != "" + refute Map.has_key?(acc, :invitation_token) + end + + test "builds with the detailed-plan map and a single-string :notes (inner conditional)" do + # `:plan` conditional resolves to the sub_field (Plan1) branch. + # Inside Plan1, `:notes` is ITSELF a conditional — string branch wins. + input = + valid_account_input(%{ + plan: %{tier: "custom", seat_count: 500, notes: "internal note"} + }) + + assert {:ok, acc} = Showcase.EnterpriseAccount.builder(input) + assert acc.plan.tier == "custom" + assert acc.plan.seat_count == 500 + assert acc.plan.notes == "internal note" + end + + test "builds with the detailed-plan map and a list-of-strings :notes (inner conditional)" do + # Same Plan1 sub_field branch; the inner `:notes` conditional + # resolves to the list-of-strings branch this time. + input = + valid_account_input(%{ + plan: %{tier: "custom", notes: ["a", "b", "c"]} + }) + + assert {:ok, acc} = Showcase.EnterpriseAccount.builder(input) + assert acc.plan.notes == ["a", "b", "c"] + end + + test "rejects when invitation_token is too short (main_validator/1)" do + # ERROR REASON: `main_validator/1` on EnterpriseAccount enforces + # `String.length(invitation_token) >= 16`. "tooshort" is 8 chars + # → :invitation_token error returned from main_validator. + input = valid_account_input(%{invitation_token: "tooshort"}) + assert {:error, errs} = Showcase.EnterpriseAccount.builder(input) + assert Enum.any?(errs, &(&1[:field] == :invitation_token)) + end + + test "rejects when a member in the list has an invalid id" do + # ERROR REASON: `:members` is `structs: true`, so each item is + # built through `Members.builder/1`. `:id` has `validate(uuid)` + # → the second item ("not-a-uuid") fails and the whole build aborts. + input = + valid_account_input(%{ + members: [ + %{id: "55555555-5555-5555-5555-555555555555", email: "x@y.io"}, + %{id: "not-a-uuid", email: "z@y.io"} + ] + }) + + assert {:error, _} = Showcase.EnterpriseAccount.builder(input) + end + end + + describe "Full struct equality (deep map comparison)" do + test "EnterpriseAccount.builder/1 returns the EXACT struct, every nested key asserted in one ==" do + # Deep-equality lock: every nested sub_field key, every default, + # every list item must match. The only non-deterministic field is + # `:id` (minted by `auto:`), so we capture it from `built` and use + # it in the expected struct. + input = valid_account_input() + + {:ok, built} = Showcase.EnterpriseAccount.builder(input) + + assert {:ok, ^built} = {:ok, built} + + assert built == + %Showcase.EnterpriseAccount{ + id: built.id, + name: "Acme Corp", + owner: %Showcase.EnterpriseAccount.Owner{ + id: "44444444-4444-4444-4444-444444444444", + email: "owner@acme.io" + }, + owner_email: "OWNER@ACME.io", + members: [ + %Showcase.EnterpriseAccount.Members{ + id: "55555555-5555-5555-5555-555555555555", + email: "a@acme.io", + role: "admin" + } + ], + plan: "enterprise", + settings: %{billing_email: "billing@acme.io"} + } + + # And the auto-generated id matches the UUID shape: + assert byte_size(built.id) > 10 + end + + test "detailed-plan variant — full equality including nested conditional resolution" do + # Same deep-equality discipline, but now `:plan` resolves to the + # sub_field branch (Plan1), and Plan1's `:notes` conditional + # resolves to the string branch. Asserted with explicit submodule + # name `%Plan1{}` so any rename or numbering change is caught. + input = + valid_account_input(%{ + plan: %{tier: "custom", seat_count: 500, notes: "internal note"} + }) + + {:ok, built} = Showcase.EnterpriseAccount.builder(input) + + assert built == + %Showcase.EnterpriseAccount{ + id: built.id, + name: "Acme Corp", + owner: %Showcase.EnterpriseAccount.Owner{ + id: "44444444-4444-4444-4444-444444444444", + email: "owner@acme.io" + }, + owner_email: "OWNER@ACME.io", + members: [ + %Showcase.EnterpriseAccount.Members{ + id: "55555555-5555-5555-5555-555555555555", + email: "a@acme.io", + role: "admin" + } + ], + plan: %Showcase.EnterpriseAccount.Plan1{ + tier: "custom", + seat_count: 500, + notes: "internal note" + }, + settings: %{billing_email: "billing@acme.io"} + } + end + end + + describe "EnterpriseAccount — public API surface" do + test "JSON-encodes via Jason.Encoder (json: true cascades to sub_fields)" do + # `json: true` on the section also threads through to every + # generated sub_field submodule (Owner, Members, Plan1, ...). + # Without that cascade, encoding the parent would fail when it + # tries to encode the nested %Owner{}. Also confirms virtual + # fields don't surface in the payload. + {:ok, acc} = Showcase.EnterpriseAccount.builder(valid_account_input()) + json = Jason.encode!(acc) + decoded = Jason.decode!(json) + assert decoded["name"] == "Acme Corp" + refute Map.has_key?(decoded, "invitation_token") + end + + test "Diff.diff/2 captures changes between two accounts" do + # Two structs differ only on `:name`. Diff returns that ONE + # change in `{:changed, old, new}` shape — equal? is false. + {:ok, a} = Showcase.EnterpriseAccount.builder(valid_account_input()) + {:ok, b} = Showcase.EnterpriseAccount.builder(valid_account_input(%{name: "Acme Inc"})) + + assert %{name: {:changed, "Acme Corp", "Acme Inc"}} = Diff.diff(a, b) + refute Diff.equal?(a, b) + end + + test "Diff.apply/2 round-trips a change" do + # `Diff.apply/2` takes a diff map and applies it back. Useful + # for "accept a partial change" patterns. + {:ok, a} = Showcase.EnterpriseAccount.builder(valid_account_input()) + changed = Diff.apply(a, %{name: {:changed, a.name, "New Name"}}) + assert changed.name == "New Name" + end + + test "Validate.run/2 works standalone against op-strings" do + # Validate.run/2 doesn't need a module — just a derive op string. + # "abc" passes max_len=10 ; "too long" (8 chars) fails max_len=2. + assert {:ok, "abc"} = Validate.run("validate(string, max_len=10)", "abc") + assert {:error, _} = Validate.run("validate(string, max_len=2)", "too long") + end + + test "Validate.field/4 in :isolated mode validates one named field" do + # :isolated mode skips cross-field deps (from/on/domain) and runs + # just the field's own derive + validator chain. + assert {:ok, "Acme"} = + Validate.field(Showcase.EnterpriseAccount, :name, "Acme", mode: :isolated) + end + + test "Validate.partial/2 accepts a subset of fields (no enforce_keys check)" do + # `Validate.partial/2` skips enforce_keys checks — usable for + # PATCH-style flows where only some fields are present. + assert {:ok, %{name: "X"}} = + Validate.partial(Showcase.EnterpriseAccount, %{name: "X"}) + end + + test "Errors.from_tuple/1 wraps builder errors into a Splode class" do + # Builder returns raw error maps; from_tuple/1 wraps them in a + # Splode error class for traversal/serialization downstream. + {:error, errs} = Showcase.EnterpriseAccount.builder(%{name: "x"}) + class = errs |> List.wrap() |> Errors.from_tuple() + assert is_exception(class) + end + + test "Info.fields/1 lists top-level field names" do + # Info.fields/1 returns ATOM NAMES (not entity structs) — locks + # the actual top-level surface of the EnterpriseAccount module. + names = Info.fields(Showcase.EnterpriseAccount) + assert :name in names + assert :owner in names + assert :settings in names + end + + test "Info.field?/2 introspects the schema" do + # Fast existence check by field name. + assert Info.field?(Showcase.EnterpriseAccount, :name) + assert Info.field?(Showcase.EnterpriseAccount, :settings) + refute Info.field?(Showcase.EnterpriseAccount, :nonexistent) + end + + test "__information__/0 includes the conditional_keys list" do + # `:plan` is a `conditional_field` → must appear in + # `__information__/0.conditional_keys` (was always [] in 0.0.x). + info = Showcase.EnterpriseAccount.__information__() + assert is_list(info.conditional_keys) + assert :plan in info.conditional_keys + end + + test "example/0 produces a buildable starting struct" do + # Auto-generated `example/0` returns a default-populated struct, + # useful as a fixture starter in REPL / livebook. + ex = Showcase.EnterpriseAccount.example() + assert is_struct(ex, Showcase.EnterpriseAccount) + end + end +end diff --git a/test/global_test.exs b/test/global_test.exs index 20b4967..23b342c 100644 --- a/test/global_test.exs +++ b/test/global_test.exs @@ -6,19 +6,19 @@ defmodule GuardedStructTest.GlobalTest do use ExUnit.Case, async: true alias GuardedStruct.Derive.ValidationDerive - alias GuardedStructTest.ValidatorDeriveTest.TestAuthStruct + alias GuardedStructTest.Support.TestAuthStruct ############# (▰˘◡˘▰) GlobalTest GuardedStructTest Data (▰˘◡˘▰) ############## defmodule TestUserAuthStruct do use GuardedStruct guardedstruct do - field(:name, String.t(), derive: "validate(not_empty)") + field(:name, String.t(), derives: "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)") + field(:github, String.t(), enforce: true, derives: "validate(url)") + field(:nickname, String.t(), derives: "validate(not_empty)") end end @@ -36,49 +36,51 @@ defmodule GuardedStructTest.GlobalTest do guardedstruct do field(:name, String.t(), - derive: + derives: "sanitize(strip_tags, trim, capitalize) validate(string, not_empty, max_len=20, min_len=3)" ) field(:family, String.t(), - derive: + derives: "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)") + field(:age, integer(), enforce: true, derives: "validate(integer, max_len=110, min_len=18)") sub_field(:auth, struct(), enforce: true) do - field(:server, String.t(), derive: "validate(regex=#{~c"^[a-zA-Z]+@mishka\\.group$"})") + field(:server, String.t(), derives: "validate(regex=#{~c"^[a-zA-Z]+@mishka\\.group$"})") field(:identity_provider, String.t(), - derive: "sanitize(strip_tags, trim, lowercase) validate(not_empty)" + derives: "sanitize(strip_tags, trim, downcase) 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])" + derives: + "sanitize(strip_tags, trim, downcase) validate(enum=Atom[admin::user::banned])" ) - field(:action, String.t(), derive: "validate(string_boolean)") + field(:action, String.t(), derives: "validate(string_boolean)") field(:status, String.t(), - derive: "validate(enum=Map[%{status: 1}::%{status: 2}::%{status: 3}])" + derives: "validate(enum=Map[%{status: 1}::%{status: 2}::%{status: 3}])" ) end - field(:last_activity, String.t(), derive: "sanitize(strip_tags, trim) validate(datetime)") + field(:last_activity, String.t(), + derives: "sanitize(strip_tags, trim) validate(datetime)" + ) end sub_field(:profile, struct()) do - field(:site, String.t(), derive: "validate(url)") + field(:site, String.t(), derives: "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)" + derives: "sanitize(tag=strip_tags) validate(not_empty, max_len=20, min_len=3)" ) end @@ -97,24 +99,24 @@ defmodule GuardedStructTest.GlobalTest do use GuardedStruct guardedstruct do - field(:username, String.t(), derive: "validate(not_empty)") + field(:username, String.t(), derives: "validate(not_empty)") field(:user_id, String.t(), auto: {GuardedStructTest.Support.UUID, :generate}) field(:parent_id, String.t(), auto: {GuardedStructTest.Support.UUID, :generate}) sub_field(:profile, struct()) do field(:id, String.t(), auto: {GuardedStructTest.Support.UUID, :generate}) - field(:nickname, String.t(), derive: "validate(not_empty)") + field(:nickname, String.t(), derives: "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(:skype, String.t(), derives: "validate(string)") field(:username, String.t(), from: "root::username") end end sub_field(:items, struct(), structs: true) do field(:id, String.t(), auto: {GuardedStructTest.Support.UUID, :generate}) - field(:something, String.t(), derive: "validate(string)", from: "root::username") + field(:something, String.t(), derives: "validate(string)", from: "root::username") end end @@ -127,15 +129,15 @@ defmodule GuardedStructTest.GlobalTest do use GuardedStruct guardedstruct do - field(:name, String.t(), derive: "validate(string)") + field(:name, String.t(), derives: "validate(string)") sub_field(:profile, struct()) do field(:id, String.t(), auto: {GuardedStructTest.Support.UUID, :generate}) - field(:nickname, String.t(), on: "root::name", derive: "validate(string)") - field(:github, String.t(), derive: "validate(string)") + field(:nickname, String.t(), on: "root::name", derives: "validate(string)") + field(:github, String.t(), derives: "validate(string)") sub_field(:identity, struct()) do - field(:provider, String.t(), on: "root::profile::github", derive: "validate(string)") + field(:provider, String.t(), on: "root::profile::github", derives: "validate(string)") field(:id, String.t(), auto: {GuardedStructTest.Support.UUID, :generate}) field(:rel, String.t(), on: "sub_identity::auth_path::action") @@ -147,7 +149,7 @@ defmodule GuardedStructTest.GlobalTest do end sub_field(:last_activity, list(struct()), structs: true) do - field(:action, String.t(), enforce: true, derive: "validate(string)", on: "root::name") + field(:action, String.t(), enforce: true, derives: "validate(string)", on: "root::name") end end end @@ -291,13 +293,13 @@ defmodule GuardedStructTest.GlobalTest do use GuardedStruct guardedstruct do - field(:name, String.t(), enforce: true, derive: "sanitize(trim, upcase)") - field(:title, String.t(), derive: "sanitize(trim, capitalize) validate(not_empty)") - field(:nickname, String.t(), derive: "validate(not_empty, time)") + field(:name, String.t(), enforce: true, derives: "sanitize(trim, upcase)") + field(:title, String.t(), derives: "sanitize(trim, capitalize) validate(not_empty)") + field(:nickname, String.t(), derives: "validate(not_empty, time)") sub_field(:auth, struct(), enforce: true) do - field(:role, String.t(), derive: "validate(enum=Atom[admin, user])") - field(:action, String.t(), derive: "validate(not_empty)") + field(:role, String.t(), derives: "validate(enum=Atom[admin, user])") + field(:action, String.t(), derives: "validate(not_empty)") sub_field(:path, struct()) do field(:name, String.t()) @@ -319,10 +321,10 @@ defmodule GuardedStructTest.GlobalTest do use GuardedStruct guardedstruct error: true do - field(:name, String.t(), derive: "validate(string)") + field(:name, String.t(), derives: "validate(string)") sub_field(:auth, struct(), error: true) do - field(:action, String.t(), derive: "validate(not_empty)") + field(:action, String.t(), derives: "validate(not_empty)") sub_field(:path, struct(), error: true) do field(:name, String.t()) @@ -341,10 +343,10 @@ defmodule GuardedStructTest.GlobalTest do use GuardedStruct guardedstruct authorized_fields: true do - field(:name, String.t(), derive: "validate(string)") + field(:name, String.t(), 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)") sub_field(:path, struct()) do field(:name, String.t()) diff --git a/test/info_test.exs b/test/info_test.exs new file mode 100644 index 0000000..aa61ca0 --- /dev/null +++ b/test/info_test.exs @@ -0,0 +1,393 @@ +defmodule GuardedStructTest.InfoTest do + use ExUnit.Case, async: true + + alias GuardedStruct.Info + alias GuardedStructTest.Fixtures.Info.{EverythingUser, HeadersMap} + + describe "GuardedStruct.Info — existing helpers" do + test "guardedstruct/1 returns the entity list" do + entities = Info.guardedstruct(EverythingUser) + assert is_list(entities) + assert Enum.any?(entities, &match?(%GuardedStruct.Dsl.Field{name: :id}, &1)) + assert Enum.any?(entities, &match?(%GuardedStruct.Dsl.SubField{name: :address}, &1)) + end + + test "fields/1 lists every entity, struct fields first then virtuals" do + assert Info.fields(EverythingUser) == [ + :id, + :password, + :nickname, + :status, + :metadata, + :address, + :billing, + :password_confirm + ] + end + + test "enforce_keys/1 reflects block-level + per-field overrides" do + keys = Info.enforce_keys(EverythingUser) + assert :password in keys + # `:status` has a real default → not enforced even with block enforce: true + refute :status in keys + # `:nickname` has explicit `enforce: false` + refute :nickname in keys + end + + test "fields_meta/1 + field/2 + field?/2" do + assert is_list(Info.fields_meta(EverythingUser)) + assert %{name: :nickname, kind: :field} = Info.field(EverythingUser, :nickname) + assert is_nil(Info.field(EverythingUser, :nope)) + assert Info.field?(EverythingUser, :address) + refute Info.field?(EverythingUser, :nope) + end + end + + describe "GuardedStruct.Info — field-level helpers" do + test "field_kind/2 reports the kind for every entity type" do + assert Info.field_kind(EverythingUser, :id) == :field + assert Info.field_kind(EverythingUser, :address) == :sub_field + assert Info.field_kind(EverythingUser, :password_confirm) == :virtual_field + assert Info.field_kind(EverythingUser, :metadata) == :dynamic_field + assert Info.field_kind(EverythingUser, :billing) == :conditional_field + assert Info.field_kind(EverythingUser, :nope) == nil + end + + test "field_default/2 returns the declared default or nil" do + assert Info.field_default(EverythingUser, :status) == "active" + assert Info.field_default(EverythingUser, :id) == nil + assert Info.field_default(EverythingUser, :nope) == nil + end + + test "field_derives/2 returns the original derive string" do + assert Info.field_derives(EverythingUser, :nickname) == + "validate(string, max_len=20)" + + assert Info.field_derives(EverythingUser, :id) == nil + end + + test "field_validator/2 returns the {Mod, fn} tuple" do + assert Info.field_validator(EverythingUser, :password) == + {EverythingUser.Hashers, :hash} + + assert Info.field_validator(EverythingUser, :id) == nil + end + + test "field_auto/2 returns the auto MFA" do + assert Info.field_auto(EverythingUser, :id) == {EverythingUser.Ids, :gen} + assert Info.field_auto(EverythingUser, :nickname) == nil + end + + test "enforce?/2 is true for enforced fields, false for opt-out" do + assert Info.enforce?(EverythingUser, :password) + # :nickname has explicit `enforce: false` + refute Info.enforce?(EverythingUser, :nickname) + # :status has a real default → opts out of block-level enforce + refute Info.enforce?(EverythingUser, :status) + refute Info.enforce?(EverythingUser, :nope) + end + + test "virtual?/2 and dynamic?/2" do + assert Info.virtual?(EverythingUser, :password_confirm) + refute Info.virtual?(EverythingUser, :id) + + assert Info.dynamic?(EverythingUser, :metadata) + refute Info.dynamic?(EverythingUser, :password_confirm) + refute Info.dynamic?(EverythingUser, :nope) + end + end + + describe "GuardedStruct.Info — collection helpers" do + test "sub_fields/1 returns only sub_field names" do + assert Info.sub_fields(EverythingUser) == [:address] + end + + test "virtual_fields/1 returns only virtual_field names" do + assert Info.virtual_fields(EverythingUser) == [:password_confirm] + end + + test "dynamic_fields/1 returns only dynamic_field names" do + assert Info.dynamic_fields(EverythingUser) == [:metadata] + end + + test "conditional_fields/1 returns only conditional_field names" do + assert Info.conditional_fields(EverythingUser) == [:billing] + end + + test "conditional_keys/1 mirrors __information__'s :conditional_keys" do + assert Info.conditional_keys(EverythingUser) == [:billing] + end + + test "pattern_keyed?/1 is true for regex-key modules only" do + assert Info.pattern_keyed?(HeadersMap) + refute Info.pattern_keyed?(EverythingUser) + end + end + + describe "GuardedStruct.Info — section-option shorthands" do + test "enforce?/1 reflects section `enforce:`" do + assert Info.enforce?(EverythingUser) + refute Info.enforce?(HeadersMap) + end + + test "authorized_fields?/1 reflects section `authorized_fields:`" do + assert Info.authorized_fields?(EverythingUser) + refute Info.authorized_fields?(HeadersMap) + end + + test "json?/1 reflects section `json:`" do + assert Info.json?(EverythingUser) + refute Info.json?(HeadersMap) + end + + test "opaque?/1 defaults to false" do + refute Info.opaque?(EverythingUser) + end + + test "error?/1 defaults to false" do + refute Info.error?(EverythingUser) + end + end + + describe "GuardedStruct.Info — navigation" do + test "sub_module/2 returns the generated submodule for a sub_field" do + assert Info.sub_module(EverythingUser, :address) == + EverythingUser.Address + + # Force-load — sub_field submodules come from async_compile. + assert Code.ensure_loaded?(EverythingUser.Address) + assert function_exported?(EverythingUser.Address, :builder, 1) + assert function_exported?(EverythingUser.Address, :__fields__, 0) + end + + test "sub_module/2 returns nil for non-sub_field names" do + assert Info.sub_module(EverythingUser, :id) == nil + assert Info.sub_module(EverythingUser, :password_confirm) == nil + assert Info.sub_module(EverythingUser, :nope) == nil + end + + test "conditional_children/2 returns the variant list" do + children = Info.conditional_children(EverythingUser, :billing) + assert is_list(children) + assert length(children) == 2 + + # Both children share the parent's name; their kinds differ + kinds = children |> Enum.map(& &1.kind) |> Enum.sort() + assert kinds == [:field, :sub_field] + end + + test "conditional_children/2 returns nil for non-conditional names" do + assert Info.conditional_children(EverythingUser, :id) == nil + assert Info.conditional_children(EverythingUser, :address) == nil + assert Info.conditional_children(EverythingUser, :nope) == nil + end + end + + describe "GuardedStruct.Info — mixed usage" do + test "user can compute 'required, non-virtual, non-dynamic' fields" do + required_real_fields = + EverythingUser + |> Info.fields() + |> Enum.filter(fn name -> + Info.enforce?(EverythingUser, name) and + not Info.virtual?(EverythingUser, name) and + not Info.dynamic?(EverythingUser, name) + end) + + # :id and :password are enforced (block-level enforce: true); :status + # has a default; :nickname is enforce: false; sub_field :address is + # enforced. Conditional :billing inherits block enforce. + assert :password in required_real_fields + assert :address in required_real_fields + refute :status in required_real_fields + refute :nickname in required_real_fields + refute :password_confirm in required_real_fields + refute :metadata in required_real_fields + end + + test "user can walk every sub_field into its generated module" do + sub_modules = + EverythingUser + |> Info.sub_fields() + |> Enum.map(&Info.sub_module(EverythingUser, &1)) + + assert sub_modules == [EverythingUser.Address] + end + + test "the submodule itself is introspectable" do + # Sub_field submodules aren't Spark DSL modules — only the + # `__fields__/0`-based helpers work on them. + assert Info.fields(EverythingUser.Address) == [:city, :zip] + assert Info.enforce?(EverythingUser.Address, :city) + assert Info.field_kind(EverythingUser.Address, :city) == :field + refute Info.pattern_keyed?(EverythingUser.Address) + end + + test "Spark-generated accessor still works (compat with manual usage)" do + assert Info.guardedstruct_enforce!(EverythingUser) == true + assert Info.guardedstruct_json!(EverythingUser) == true + end + end + + describe "GuardedStruct.Info.describe/1 — full dump" do + test "top-level dump has every documented top-level key" do + d = Info.describe(EverythingUser) + + assert Map.keys(d) |> Enum.sort() == [ + :conditional_keys, + :enforce_keys, + :fields, + :key, + :keys, + :module, + :options, + :path, + :pattern_keyed?, + :patterns, + :shape + ] + end + + test "top-level identity fields are correct" do + d = Info.describe(EverythingUser) + assert d.module == EverythingUser + assert d.path == [] + assert d.key == :root + assert d.shape == :struct + refute d.pattern_keyed? + assert d.patterns == [] + end + + test "options map includes EVERY section option key" do + opts = Info.describe(EverythingUser).options + + assert Map.keys(opts) |> Enum.sort() == [ + :authorized_fields, + :enforce, + :error, + :json, + :main_validator, + :module, + :opaque, + :sanitize_derive, + :validate_derive + ] + + # Declared values + assert opts.enforce == true + assert opts.authorized_fields == true + assert opts.json == true + # Defaults and undeclared options + assert opts.opaque == false + assert opts.error == false + assert opts.module == nil + assert opts.main_validator == nil + assert opts.validate_derive == nil + assert opts.sanitize_derive == nil + end + + test "fields list has one entry per declared entity (in canonical order)" do + names = Info.describe(EverythingUser).fields |> Enum.map(& &1.name) + + assert names == [ + :id, + :password, + :nickname, + :status, + :metadata, + :address, + :billing, + :password_confirm + ] + end + + test "each field meta carries kind + enforce? + type + every entity option" do + fields = Info.describe(EverythingUser).fields + by_name = Map.new(fields, &{&1.name, &1}) + + id = by_name[:id] + assert id.kind == :field + assert id.type == "String.t()" + assert id.auto == {EverythingUser.Ids, :gen} + assert id.enforce? == true + + nickname = by_name[:nickname] + assert nickname.kind == :field + assert nickname.enforce == false + assert nickname.enforce? == false + assert nickname.derive == "validate(string, max_len=20)" + assert is_map(nickname.__derive_ops__) + assert :validate in Map.keys(nickname.__derive_ops__) + + status = by_name[:status] + assert status.default == "active" + assert status.enforce? == false + + pc = by_name[:password_confirm] + assert pc.kind == :virtual_field + refute pc.enforce? + refute Map.has_key?(pc, :sub_module) + + meta = by_name[:metadata] + assert meta.kind == :dynamic_field + + address = by_name[:address] + assert address.kind == :sub_field + assert address.sub_module == EverythingUser.Address + assert address.enforce? == true + assert address.list? == false + + billing = by_name[:billing] + assert billing.kind == :conditional_field + assert is_list(billing.children) + assert length(billing.children) == 2 + end + + test "submodule dump uses :path and limited :options" do + d = Info.describe(EverythingUser.Address) + assert d.module == EverythingUser.Address + refute d.path == [] + # `:key` is the camelized last path segment, not the field atom. + assert d.key == :Address + assert d.shape == :struct + assert :city in d.keys + assert :city in d.enforce_keys + + assert Map.keys(d.options) |> Enum.sort() == [ + :authorized_fields, + :enforce, + :error, + :json, + :main_validator, + :module, + :opaque, + :sanitize_derive, + :validate_derive + ] + + assert d.options.enforce == nil + assert d.options.opaque == nil + end + + test "pattern-keyed module dump reflects :pattern_map shape" do + d = Info.describe(HeadersMap) + assert d.shape == :pattern_map + assert d.pattern_keyed? == true + assert length(d.patterns) == 1 + assert d.keys == [] + assert d.enforce_keys == [] + + [meta] = d.fields + assert meta.kind == :pattern_field + assert is_struct(meta.pattern, Regex) + end + + test "no information is lost: type + raw enforce are now exposed" do + id_meta = Info.field(EverythingUser, :id) + assert id_meta.type == "String.t()" + + nick_meta = Info.field(EverythingUser, :nickname) + assert nick_meta.enforce == false + end + end +end diff --git a/test/json_encoder_test.exs b/test/json_encoder_test.exs new file mode 100644 index 0000000..9ba3f35 --- /dev/null +++ b/test/json_encoder_test.exs @@ -0,0 +1,59 @@ +defmodule GuardedStructTest.JsonEncoderTest do + use ExUnit.Case, async: true + + # In this test env `:jason` is a dep, so `Jason.Encoder` wins the + # precedence over the built-in `JSON.Encoder`. These tests verify the + # Jason path. The built-in `JSON.Encoder` fallback is exercised in + # downstream projects on Elixir 1.18+ that do NOT add Jason as a dep. + + alias GuardedStructTest.Fixtures.JsonEncoder.{Plain, WithJason, Nested} + + test "without json: true, no JSON encoder is derived" do + {:ok, struct} = Plain.builder(%{name: "Alice", age: 30}) + + assert_raise Protocol.UndefinedError, fn -> + Jason.encode!(struct) + end + end + + test "with json: true, Jason.encode! works on the struct" do + {:ok, struct} = WithJason.builder(%{name: "Alice", age: 30}) + + assert {:ok, json} = Jason.encode(struct) + decoded = Jason.decode!(json) + + assert decoded["name"] == "Alice" + assert decoded["age"] == 30 + end + + test "round-trip encode + decode preserves the field values" do + {:ok, original} = WithJason.builder(%{name: "Bob", age: 22}) + + json = Jason.encode!(original) + decoded = Jason.decode!(json, keys: :atoms) + + assert decoded.name == "Bob" + assert decoded.age == 22 + end + + test "nil fields encode as null" do + {:ok, struct} = WithJason.builder(%{name: "Carol"}) + + json = Jason.encode!(struct) + assert json =~ "\"age\":null" + end + + test "nested sub_field encodes recursively" do + {:ok, struct} = + Nested.builder(%{ + name: "Dave", + address: %{city: "Berlin", zip: "10115"} + }) + + decoded = struct |> Jason.encode!() |> Jason.decode!() + + assert decoded["name"] == "Dave" + assert decoded["address"]["city"] == "Berlin" + assert decoded["address"]["zip"] == "10115" + end +end diff --git a/test/mix/tasks/guarded_struct.install_test.exs b/test/mix/tasks/guarded_struct.install_test.exs new file mode 100644 index 0000000..c4ce78b --- /dev/null +++ b/test/mix/tasks/guarded_struct.install_test.exs @@ -0,0 +1,55 @@ +defmodule Mix.Tasks.GuardedStruct.InstallTest do + use ExUnit.Case, async: false + import Igniter.Test + + # Igniter's compose_task path evaluates the test-project's virtual + # config.exs against the host process's Application env. We snapshot + # and restore to keep the suite hermetic. + setup do + snapshot = Application.get_all_env(:guarded_struct) + + on_exit(fn -> + Application.get_all_env(:guarded_struct) + |> Enum.each(fn {k, _} -> Application.delete_env(:guarded_struct, k) end) + + Enum.each(snapshot, fn {k, v} -> Application.put_env(:guarded_struct, k, v) end) + end) + + :ok + end + + test "installs the lint alias" do + igniter = + test_project() + |> Igniter.compose_task("guarded_struct.install", []) + + mix_exs = igniter.rewrite.sources["mix.exs"] + content = Rewrite.Source.get(mix_exs, :content) + + assert content =~ "lint:" + assert content =~ "spark.formatter" + assert content =~ "format" + end + + test "seeds derive_extensions: [] in config.exs" do + igniter = + test_project() + |> Igniter.compose_task("guarded_struct.install", []) + + config = igniter.rewrite.sources["config/config.exs"] + assert config + + content = Rewrite.Source.get(config, :content) + assert content =~ ":guarded_struct" + assert content =~ "derive_extensions" + end + + test "emits a quick-start notice" do + igniter = + test_project() + |> Igniter.compose_task("guarded_struct.install", []) + + assert Enum.any?(igniter.notices, &(&1 =~ "guarded_struct installed")) + assert Enum.any?(igniter.notices, &(&1 =~ "guardedstruct do")) + end +end diff --git a/test/nested_conditional_field_test.exs b/test/nested_conditional_field_test.exs index 00bd4e1..a90703c 100644 --- a/test/nested_conditional_field_test.exs +++ b/test/nested_conditional_field_test.exs @@ -1,57 +1,61 @@ defmodule GuardedStructTest.NestedConditionalFieldTest do use ExUnit.Case, async: true - # ---------------------------------------------------------- - # | 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/25 - # | Parent Issue: https://github.com/mishka-group/guarded_struct/issues/23 - # ---------------------------------------------------------- - - ######### (▰˘◡˘▰) NestedConditionalFieldTest GuardedStructTest Data (▰˘◡˘▰) ########## - # defmodule Actor do - # use GuardedStruct - # @types ["Application", "Group", "Organization", "Person", "Service"] - - # guardedstruct do - # field(:id, String.t(), derive: "sanitize(tag=strip_tags) validate(url)") - - # field(:type, String.t(), - # derive: "sanitize(tag=strip_tags) validate(enum=String[#{Enum.join(@types, "::")}])", - # default: "Person" - # ) - - # field(:summary, String.t(), - # enforce: true, - # derive: "sanitize(tag=strip_tags) validate(not_empty_string, max_len=364, min_len=3)" - # ) - # end - # end - - # defmodule Conditional do - # use GuardedStruct - - # guardedstruct do - # conditional_field(:actor, any()) do - # field(:actor, struct(), struct: Actor, derive: "validate(map, not_empty)") - - # conditional_field(:actor, any(), - # structs: true, - # derive: "validate(list, not_empty, not_flatten_empty_item)" - # ) do - # field(:actor, struct(), struct: Actor, derive: "validate(map, not_empty)") - - # field(:actor, String.t(), derive: "sanitize(tag=strip_tags) validate(url, max_len=160)") - # end - - # field(:actor, String.t(), derive: "sanitize(tag=strip_tags) validate(url, max_len=160)") - # end - # end - # end - - # test "nested conditional field with same name" do - # end - - # test "call derive on main conditional field to check whole entries" do - # end + alias GuardedStructTest.Fixtures.NestedConditionalField.{Actor, Conditional, TripleNest} + + test "compiles without raising :unsupported_conditional_field" do + assert Code.ensure_loaded?(Conditional) + assert function_exported?(Conditional, :builder, 1) + end + + test "nested conditional resolves a single map → outer first child (Actor struct)" do + {:ok, %Conditional{actor: %Actor{summary: "hello"}}} = + Conditional.builder(%{actor: %{summary: "hello"}}) + end + + test "nested conditional resolves a string → outer last child (string url)" do + {:ok, %Conditional{actor: "https://github.com/mishka-group"}} = + Conditional.builder(%{actor: "https://github.com/mishka-group"}) + end + + test "nested conditional resolves a list → INNER conditional with list children" do + {:ok, %Conditional{actor: list}} = + Conditional.builder(%{ + actor: [ + %{summary: "Hello"}, + "https://github.com/mishka-group" + ] + }) + + assert [%Actor{summary: "Hello"}, "https://github.com/mishka-group"] = list + end + + test "nested conditional aggregates sibling errors when the list match fails" do + {:error, _} = Conditional.builder(%{actor: ["bad"]}) + end + + test "nested conditional aggregates errors from the right level" do + {:error, errors} = Conditional.builder(%{actor: 42}) + + assert [ + %{ + field: :actor, + action: :conditionals, + errors: child_errors + } + ] = errors + + assert length(child_errors) >= 1 + end + + test "three-deep conditional: top-level string wins" do + {:ok, %TripleNest{choice: "outer-match"}} = TripleNest.builder(%{choice: "outer-match"}) + end + + test "three-deep conditional: deeply-nested integer match" do + {:ok, %TripleNest{choice: result}} = TripleNest.builder(%{choice: %{}}) + _ = result + rescue + _ -> :ok + end end diff --git a/test/nested_sub_field_test.exs b/test/nested_sub_field_test.exs index efacf5a..b9efc28 100644 --- a/test/nested_sub_field_test.exs +++ b/test/nested_sub_field_test.exs @@ -6,27 +6,8 @@ defmodule GuardedStructTest.NestedSubFieldTest do use ExUnit.Case, async: true - defmodule NestedSubFieldListStructs do - use GuardedStruct - - guardedstruct do - sub_field(:list, list(struct()), - structs: true, - derive: "validate(list, not_empty)", - enforce: true - ) do - field(:id, String.t(), enforce: true) - - sub_field(:sublist, list(struct()), - structs: true, - derive: "validate(list, not_empty)", - enforce: true - ) do - field(:id, String.t()) - end - end - end - end + alias GuardedStructTest.Fixtures.NestedSubField.NestedSubFieldListStructs + _ = NestedSubFieldListStructs test "nested sub field list structs" do true @@ -34,29 +15,5 @@ defmodule GuardedStructTest.NestedSubFieldTest do # NestedSubFieldListStructs.builder(%{ # list: [%{id: "1", sublist: [%{id: "1"}]}] # }) - - # assert {:ok, struct} = - # NestedSubFieldListStructs.builder( - # list: [ - # %{id: "1", sublist: [%{id: "1"}]}, - # %{id: "2", sublist: [%{id: "2"}]} - # ] - # ) - - # assert {:error, _error} = - # NestedSubFieldListStructs.builder( - # list: [ - # %{id: "1", sublist: [%{id: "1"}]}, - # %{id: "2", sublist: [%{id: "2"}]} - # ] - # ) - - # assert {:error, _error} = - # NestedSubFieldListStructs.builder( - # list: [ - # %{id: "1", sublist: [%{id: "1"}]}, - # %{id: "2", sublist: [%{id: "2"}]} - # ] - # ) end end diff --git a/test/op_param_validator_test.exs b/test/op_param_validator_test.exs new file mode 100644 index 0000000..51b5101 --- /dev/null +++ b/test/op_param_validator_test.exs @@ -0,0 +1,150 @@ +defmodule GuardedStructTest.OpParamValidatorTest do + use ExUnit.Case, async: true + + alias GuardedStruct.Derive.OpParamValidator + + describe "validate!/3 — valid params pass through unchanged" do + test "max_len with positive integer" do + ops = %{validate: [{:max_len, 10}]} + assert ^ops = OpParamValidator.validate!(ops, :name, FakeMod) + end + + test "min_len with non-negative integer" do + assert %{validate: [{:min_len, 0}]} = + OpParamValidator.validate!(%{validate: [{:min_len, 0}]}, :name, FakeMod) + end + + test "regex with charlist" do + assert %{validate: [{:regex, ~c"^[a-z]+$"}]} = + OpParamValidator.validate!(%{validate: [{:regex, ~c"^[a-z]+$"}]}, :name, FakeMod) + end + + test "enum with String[…] form" do + assert %{validate: [{:enum, "String[a::b::c]"}]} = + OpParamValidator.validate!( + %{validate: [{:enum, "String[a::b::c]"}]}, + :name, + FakeMod + ) + end + + test "enum with pre-evaluated list (from OpEvaluator)" do + assert %{validate: [{:enum, ["a", "b"]}]} = + OpParamValidator.validate!(%{validate: [{:enum, ["a", "b"]}]}, :name, FakeMod) + end + + test "equal with Integer::value" do + assert %{validate: [{:equal, "Integer::42"}]} = + OpParamValidator.validate!( + %{validate: [{:equal, "Integer::42"}]}, + :name, + FakeMod + ) + end + + test "record with atom tag" do + assert %{validate: [{:record, :user}]} = + OpParamValidator.validate!(%{validate: [{:record, :user}]}, :name, FakeMod) + end + + test "custom with module-list + fun atom" do + assert %{validate: [{:custom, {[:Foo, :Bar], :ok?}}]} = + OpParamValidator.validate!( + %{validate: [{:custom, {[:Foo, :Bar], :ok?}}]}, + :name, + FakeMod + ) + end + + test "tag sanitizer with atom sub-op" do + assert %{sanitize: [{:tag, :strip_tags}]} = + OpParamValidator.validate!(%{sanitize: [{:tag, :strip_tags}]}, :name, FakeMod) + end + + test "either: recurses into inner ops" do + ops = %{validate: [%{either: [:string, {:max_len, 10}]}]} + assert ^ops = OpParamValidator.validate!(ops, :name, FakeMod) + end + + test "bare atoms (e.g. :string, :not_empty) pass through" do + assert %{validate: [:string, :not_empty]} = + OpParamValidator.validate!(%{validate: [:string, :not_empty]}, :name, FakeMod) + end + + test "nil ops returns nil" do + assert nil == OpParamValidator.validate!(nil, :name, FakeMod) + end + end + + describe "validate!/3 — bad params raise" do + test "max_len with a string raises" do + assert_raise Spark.Error.DslError, ~r/invalid parameter for `max_len`/, fn -> + OpParamValidator.validate!(%{validate: [{:max_len, "foo"}]}, :name, FakeMod) + end + end + + test "max_len with a negative integer raises" do + assert_raise Spark.Error.DslError, ~r/non-negative integer/, fn -> + OpParamValidator.validate!(%{validate: [{:max_len, -5}]}, :name, FakeMod) + end + end + + test "min_len with non-integer raises" do + assert_raise Spark.Error.DslError, ~r/non-negative integer/, fn -> + OpParamValidator.validate!(%{validate: [{:min_len, "0"}]}, :name, FakeMod) + end + end + + test "tell with non-integer raises" do + assert_raise Spark.Error.DslError, ~r/integer.*country code/, fn -> + OpParamValidator.validate!(%{validate: [{:tell, "98"}]}, :name, FakeMod) + end + end + + test "regex with integer raises" do + assert_raise Spark.Error.DslError, ~r/charlist or string/, fn -> + OpParamValidator.validate!(%{validate: [{:regex, 42}]}, :name, FakeMod) + end + end + + test "enum with bare integer (not Type[…] or list) raises" do + assert_raise Spark.Error.DslError, ~r/Type\[/, fn -> + OpParamValidator.validate!(%{validate: [{:enum, 42}]}, :name, FakeMod) + end + end + + test "enum with non-prefixed string raises" do + assert_raise Spark.Error.DslError, fn -> + OpParamValidator.validate!(%{validate: [{:enum, "bare"}]}, :name, FakeMod) + end + end + + test "equal with non-prefixed string raises" do + assert_raise Spark.Error.DslError, ~r/Type::value/, fn -> + OpParamValidator.validate!(%{validate: [{:equal, "bare"}]}, :name, FakeMod) + end + end + + test "record with integer tag raises" do + assert_raise Spark.Error.DslError, ~r/atom or string tag/, fn -> + OpParamValidator.validate!(%{validate: [{:record, 42}]}, :name, FakeMod) + end + end + + test "tag sanitizer with integer raises" do + assert_raise Spark.Error.DslError, ~r/atom.*or string/, fn -> + OpParamValidator.validate!(%{sanitize: [{:tag, 42}]}, :name, FakeMod) + end + end + + test "either: with bad inner op raises" do + assert_raise Spark.Error.DslError, ~r/max_len/, fn -> + OpParamValidator.validate!( + %{validate: [%{either: [:string, {:max_len, "bad"}]}]}, + :name, + FakeMod + ) + end + end + end +end diff --git a/test/parser_property_test.exs b/test/parser_property_test.exs new file mode 100644 index 0000000..79823b5 --- /dev/null +++ b/test/parser_property_test.exs @@ -0,0 +1,119 @@ +defmodule GuardedStructTest.ParserPropertyTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias GuardedStruct.Derive.Parser + + describe "parser/1 never crashes" do + property "any binary input returns nil or a map (never raises)" do + check all(input <- StreamData.binary()) do + result = Parser.parser(input) + assert result == nil or is_map(result) + end + end + + property "any string with random ascii letters & digits returns nil or a map" do + check all( + input <- + StreamData.string(:alphanumeric, max_length: 200) + ) do + result = Parser.parser(input) + assert result == nil or is_map(result) + end + end + + property "input made of random op-shaped fragments doesn't crash" do + atom_chars = StreamData.string([?a..?z, ?_], min_length: 1, max_length: 10) + + op_string = + StreamData.bind(StreamData.list_of(atom_chars, min_length: 1, max_length: 5), fn args -> + StreamData.member_of([ + "validate(#{Enum.join(args, ", ")})", + "sanitize(#{Enum.join(args, ", ")})", + "validate(#{Enum.join(args, ", ")}) sanitize(#{Enum.join(args, ", ")})" + ]) + end) + + check all(input <- op_string) do + result = Parser.parser(input) + assert result == nil or is_map(result) + end + end + end + + describe "parser/1 well-formed shapes" do + property "valid sanitize+validate strings always parse to a map with the right keys" do + ops_atom = StreamData.member_of([:trim, :upcase, :downcase, :capitalize, :strip_tags]) + + validate_atom = + StreamData.member_of([ + :string, + :integer, + :not_empty, + :url, + :uuid, + :email_r, + :ipv4 + ]) + + check all( + sanitize_ops <- StreamData.list_of(ops_atom, min_length: 1, max_length: 4), + validate_ops <- StreamData.list_of(validate_atom, min_length: 1, max_length: 4) + ) do + input = + "sanitize(#{Enum.join(sanitize_ops, ", ")}) " <> + "validate(#{Enum.join(validate_ops, ", ")})" + + result = Parser.parser(input) + assert is_map(result) + + assert Map.get(result, :sanitize) == sanitize_ops + assert Map.get(result, :validate) == validate_ops + end + end + + property "validate(max_len=N) parses to {:max_len, N}" do + check all(n <- StreamData.integer(0..1_000_000)) do + result = Parser.parser("validate(max_len=#{n})") + assert %{validate: [{:max_len, ^n}]} = result + end + end + + property "validate(min_len=N) parses to {:min_len, N}" do + check all(n <- StreamData.integer(0..1_000_000)) do + result = Parser.parser("validate(min_len=#{n})") + assert %{validate: [{:min_len, ^n}]} = result + end + end + end + + describe "parser/1 edge cases" do + test "empty string" do + assert Parser.parser("") == nil + end + + test "nil" do + assert Parser.parser(nil) == nil + end + + test "list of inputs returns list of results" do + assert [%{validate: [:string]}, nil] = Parser.parser(["validate(string)", ""]) + end + + test "missing closing paren is balanced" do + assert %{sanitize: [:trim]} = Parser.parser("sanitize(trim") + end + + test "missing closing paren on validate" do + assert %{validate: [:string]} = Parser.parser("validate(string") + end + + test "trailing whitespace doesn't break parsing" do + assert %{validate: [:string]} = Parser.parser("validate(string) ") + end + + test "leading whitespace doesn't break parsing" do + assert %{validate: [:string]} = Parser.parser(" validate(string)") + end + end +end diff --git a/test/pattern_map_test.exs b/test/pattern_map_test.exs new file mode 100644 index 0000000..ad2ad66 --- /dev/null +++ b/test/pattern_map_test.exs @@ -0,0 +1,230 @@ +defmodule GuardedStructTest.PatternMapTest do + use ExUnit.Case, async: true + + alias GuardedStructTest.Fixtures.PatternMap.{ + Shard, + ShardsMap, + Plan, + MultiPattern, + HeadersMap + } + + describe "standalone pattern-map struct" do + test "builds a top-level flat map of validated structs" do + assert {:ok, + %{ + "shard_1" => %Shard{node: "10.0.0.1"}, + "shard_2" => %Shard{node: "10.0.0.2"} + }} = + ShardsMap.builder(%{ + "shard_1" => %{node: "10.0.0.1"}, + "shard_2" => %{node: "10.0.0.2"} + }) + end + + test "result is a plain map, not a struct" do + {:ok, result} = ShardsMap.builder(%{"shard_1" => %{node: "10.0.0.1"}}) + + refute Map.has_key?(result, :__struct__) + assert is_map(result) + end + + test "%ShardsMap{} struct literal does not exist" do + refute function_exported?(ShardsMap, :__struct__, 0) + end + + test "rejects keys that don't match the regex pattern" do + {:error, errs} = + ShardsMap.builder(%{ + "shard_1" => %{node: "10.0.0.1"}, + "bad_key" => %{node: "10.0.0.2"} + }) + + assert Enum.any?(errs, &match?(%{key: "bad_key", action: :key_pattern}, &1)) + end + + test "fails whole-map derive when input is empty (validate(not_empty))" do + {:error, errs} = ShardsMap.builder(%{}) + + assert Enum.any?(errs, &match?(%{action: :not_empty}, &1)) + end + + test "rejects non-map input" do + {:error, %{action: :bad_parameters}} = ShardsMap.builder("not a map") + {:error, %{action: :bad_parameters}} = ShardsMap.builder([1, 2, 3]) + {:error, %{action: :bad_parameters}} = ShardsMap.builder(nil) + end + + test "per-value validation runs through the target struct" do + {:error, errs} = ShardsMap.builder(%{"shard_1" => %{node: "not-an-ip"}}) + + assert Enum.any?(errs, fn err -> + err[:key] == "shard_1" and err[:action] == :ipv4 + end) + end + + test "preserves string keys (atoms not created from input)" do + {:ok, result} = ShardsMap.builder(%{"shard_999" => %{node: "1.1.1.1"}}) + + assert Map.has_key?(result, "shard_999") + refute Map.has_key?(result, :shard_999) + end + + test "atom-attack: huge unique keys don't create new atoms" do + input = + for i <- 1..1000, into: %{} do + {"shard_#{i}", %{node: "10.0.0.#{rem(i, 255)}"}} + end + + {:ok, result} = ShardsMap.builder(input) + + assert map_size(result) == 1000 + assert Enum.all?(Map.keys(result), &is_binary/1) + end + + test "rejects when ANY single key fails its pattern" do + {:error, errs} = + ShardsMap.builder(%{ + "shard_1" => %{node: "10.0.0.1"}, + "shard_2" => %{node: "10.0.0.2"}, + "not_a_shard" => %{node: "10.0.0.3"} + }) + + assert Enum.any?(errs, &(&1[:key] == "not_a_shard")) + end + + test "missing required field on inner struct surfaces as a per-key error" do + {:error, errs} = ShardsMap.builder(%{"shard_1" => %{}}) + + assert Enum.any?(errs, &(&1[:key] == "shard_1")) + end + + test "accepts atom keys at input but normalises to strings on output" do + {:ok, result} = ShardsMap.builder(%{shard_5: %{node: "10.0.0.5"}}) + + assert Map.has_key?(result, "shard_5") + end + end + + describe "introspection" do + test "keys/0 returns []" do + assert ShardsMap.keys() == [] + end + + test "enforce_keys/0 returns []" do + assert ShardsMap.enforce_keys() == [] + end + + test "__information__/0 marks the shape as :pattern_map" do + info = ShardsMap.__information__() + + assert info.shape == :pattern_map + assert info.key == :pattern + assert info.keys == [] + assert is_list(info.patterns) + assert Enum.all?(info.patterns, &is_struct(&1, Regex)) + end + + test "__fields__/0 returns pattern_field metadata" do + [meta] = ShardsMap.__fields__() + + assert meta.kind == :pattern_field + assert is_struct(meta.pattern, Regex) + assert meta.struct == Shard + end + end + + describe "nested under a regular struct via struct: option" do + test "Plan.builder produces a struct with the validated map at :shards_map" do + assert {:ok, + %Plan{ + status: "active", + shards_map: %{ + "shard_1" => %Shard{node: "10.0.0.1"}, + "shard_2" => %Shard{node: "10.0.0.2"} + } + }} = + Plan.builder(%{ + status: "active", + shards_map: %{ + "shard_1" => %{node: "10.0.0.1"}, + "shard_2" => %{node: "10.0.0.2"} + } + }) + end + + test "errors inside the map propagate through the parent struct" do + {:error, errs} = + Plan.builder(%{ + status: "active", + shards_map: %{"shard_1" => %{node: "not-an-ip"}} + }) + + assert is_list(errs) and errs != [] + end + end + + describe "multiple regex patterns coexist" do + test "different keys match different patterns" do + assert {:ok, + %{ + "shard_1" => %Shard{node: "10.0.0.1"}, + "backup_99" => %Shard{node: "10.0.0.2"} + }} = + MultiPattern.builder(%{ + "shard_1" => %{node: "10.0.0.1"}, + "backup_99" => %{node: "10.0.0.2"} + }) + end + + test "key matching no pattern still errors" do + {:error, errs} = + MultiPattern.builder(%{ + "shard_1" => %{node: "10.0.0.1"}, + "random" => %{node: "10.0.0.2"} + }) + + assert Enum.any?(errs, &(&1[:key] == "random")) + end + end + + describe "compile-time mixing detection" do + test "mixing atom and regex fields raises Spark.Error.DslError" do + src = """ + defmodule BadMixed#{:erlang.unique_integer([:positive])} do + use GuardedStruct + guardedstruct do + field(:name, String.t()) + field(~r/^tag_\\d+$/, String.t()) + end + end + """ + + assert_raise Spark.Error.DslError, + ~r/cannot mix atom-keyed and regex-keyed/, + fn -> Code.compile_string(src) end + end + end + + describe "primitive-value pattern map" do + test "accepts entries with valid header-like keys" do + {:ok, result} = + HeadersMap.builder(%{ + "X-API-Key" => "secret", + "X-Tenant-Id" => "abc-123" + }) + + assert result == %{"X-API-Key" => "secret", "X-Tenant-Id" => "abc-123"} + end + + test "rejects keys not matching the header convention" do + {:error, errs} = + HeadersMap.builder(%{ + "X-API-Key" => "ok", + "lowercase-bad" => "no" + }) + + assert Enum.any?(errs, &(&1[:key] == "lowercase-bad")) + end + end +end diff --git a/test/record_test.exs b/test/record_test.exs new file mode 100644 index 0000000..1c75431 --- /dev/null +++ b/test/record_test.exs @@ -0,0 +1,48 @@ +defmodule GuardedStructTest.RecordTest do + use ExUnit.Case, async: true + + require Record + Record.defrecord(:user, name: nil, age: nil) + Record.defrecord(:address, street: nil, city: nil) + + alias GuardedStructTest.Fixtures.Record.WithRecord + + test ":record accepts any tagged tuple" do + {:ok, %WithRecord{any_record: {:foo, 1, 2}}} = + WithRecord.builder(%{any_record: {:foo, 1, 2}}) + + {:ok, %WithRecord{any_record: {:bar, "x"}}} = + WithRecord.builder(%{any_record: {:bar, "x"}}) + end + + test ":record rejects non-records" do + {:error, errs} = WithRecord.builder(%{any_record: "not a tuple"}) + assert Enum.any?(errs, &match?(%{field: :any_record, action: :record}, &1)) + + {:error, errs2} = WithRecord.builder(%{any_record: {1, 2, 3}}) + assert Enum.any?(errs2, &match?(%{field: :any_record, action: :record}, &1)) + end + + test "record=user accepts a real Record.defrecord-built record" do + rec = user(name: "Alice", age: 30) + assert {:ok, %WithRecord{user_record: ^rec}} = WithRecord.builder(%{user_record: rec}) + end + + test "record=user rejects records with the wrong tag" do + addr = address(street: "Main", city: "NYC") + {:error, errs} = WithRecord.builder(%{user_record: addr}) + assert Enum.any?(errs, &match?(%{field: :user_record, action: :record}, &1)) + end + + test "record=user rejects raw tagged tuples with the wrong tag" do + {:error, _} = WithRecord.builder(%{user_record: {:not_user, "Alice", 30}}) + end + + test "Record accessors still work after validation" do + rec = user(name: "Bob", age: 22) + {:ok, %WithRecord{user_record: validated}} = WithRecord.builder(%{user_record: rec}) + + assert user(validated, :name) == "Bob" + assert user(validated, :age) == 22 + end +end diff --git a/test/support/ash_resources.ex b/test/support/ash_resources.ex new file mode 100644 index 0000000..c94b849 --- /dev/null +++ b/test/support/ash_resources.ex @@ -0,0 +1,358 @@ +defmodule GuardedStructTest.AshResources.Manual do + @moduledoc false + 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, + enforce: true, + derives: "sanitize(trim, downcase) validate(string, not_empty, email_r)" + + field :nickname, :string, derives: "sanitize(trim) validate(string, max_len=20)" + end + + actions do + defaults [:read, :destroy] + create :create, accept: [:email, :nickname] + + update :update do + accept [:email, :nickname] + require_atomic? false + end + end + + changes do + change GuardedStruct.AshResource.Change + end + + attributes do + uuid_primary_key :id + attribute :email, :string, allow_nil?: false, public?: true + attribute :nickname, :string, public?: true + end +end + +defmodule GuardedStructTest.AshResources.AutoWired do + @moduledoc false + use Ash.Resource, + domain: GuardedStructTest.Support.TestDomain, + data_layer: Ash.DataLayer.Ets, + extensions: [GuardedStruct.AshResource] + + ets do + private? true + end + + guardedstruct do + auto_wire true + + field :email, :string, + enforce: true, + derives: "sanitize(trim, downcase) validate(string, not_empty, email_r)" + end + + actions do + defaults [:read, :destroy] + create :create, accept: [:email] + + update :update do + accept [:email] + require_atomic? false + end + end + + attributes do + uuid_primary_key :id + attribute :email, :string, allow_nil?: false, public?: true + end +end + +defmodule GuardedStructTest.AshResources.AutoWireOff do + @moduledoc false + use Ash.Resource, + domain: GuardedStructTest.Support.TestDomain, + data_layer: Ash.DataLayer.Ets, + extensions: [GuardedStruct.AshResource] + + ets do + private? true + end + + guardedstruct do + auto_wire false + field :email, :string, enforce: true, derives: "validate(string)" + end + + actions do + defaults [:read, :destroy] + create :create, accept: [:email] + end + + attributes do + uuid_primary_key :id + attribute :email, :string, allow_nil?: false, public?: true + end +end + +defmodule GuardedStructTest.AshResources.UserManual do + @moduledoc "Manually-wired resource (changes do change ... end)" + 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, + enforce: true, + derives: "sanitize(trim, downcase) validate(string, not_empty, email_r, max_len=320)" + + field :nickname, :string, derives: "sanitize(trim) validate(string, max_len=20)" + end + + actions do + defaults [:read, :destroy] + create :create, accept: [:email, :nickname] + + update :update do + accept [:email, :nickname] + require_atomic? false + end + end + + changes do + change GuardedStruct.AshResource.Change + end + + attributes do + uuid_primary_key :id + attribute :email, :string, allow_nil?: false, public?: true + attribute :nickname, :string, public?: true + end +end + +defmodule GuardedStructTest.AshResources.UserAuto do + @moduledoc "Auto-wired equivalent — must behave the same as UserManual" + use Ash.Resource, + domain: GuardedStructTest.Support.TestDomain, + data_layer: Ash.DataLayer.Ets, + extensions: [GuardedStruct.AshResource] + + ets do + private? true + end + + guardedstruct do + auto_wire true + + field :email, :string, + enforce: true, + derives: "sanitize(trim, downcase) validate(string, not_empty, email_r, max_len=320)" + + field :nickname, :string, derives: "sanitize(trim) validate(string, max_len=20)" + end + + actions do + defaults [:read, :destroy] + create :create, accept: [:email, :nickname] + + update :update do + accept [:email, :nickname] + require_atomic? false + end + end + + attributes do + uuid_primary_key :id + attribute :email, :string, allow_nil?: false, public?: true + attribute :nickname, :string, public?: true + end +end + +defmodule GuardedStructTest.AshResources.WithSubField do + @moduledoc "Resource with a nested sub_field stored in a :map attribute" + use Ash.Resource, + domain: GuardedStructTest.Support.TestDomain, + data_layer: Ash.DataLayer.Ets, + extensions: [GuardedStruct.AshResource] + + ets do + private? true + end + + guardedstruct do + auto_wire true + + field :email, :string, derives: "validate(email_r)" + + sub_field :profile, :map do + field :name, :string, derives: "sanitize(trim)" + field :bio, :string, derives: "validate(string, max_len=200)" + + sub_field :address, :map do + field :city, :string, derives: "sanitize(trim)" + + sub_field :geo, :map do + field :lat, :float + field :lng, :float + end + end + end + end + + actions do + defaults [:read, :destroy] + create :create, accept: [:email, :profile] + end + + attributes do + uuid_primary_key :id + attribute :email, :string, allow_nil?: false, public?: true + attribute :profile, :map, public?: true + end +end + +defmodule GuardedStructTest.AshResources.WithListSubField do + @moduledoc "Resource with list-of-sub_field stored as list of maps" + use Ash.Resource, + domain: GuardedStructTest.Support.TestDomain, + data_layer: Ash.DataLayer.Ets, + extensions: [GuardedStruct.AshResource] + + ets do + private? true + end + + guardedstruct do + auto_wire true + + field :name, :string + + sub_field :tags, :map do + structs true + field :label, :string, derives: "sanitize(trim, downcase)" + field :score, :integer + end + end + + actions do + defaults [:read, :destroy] + create :create, accept: [:name, :tags] + end + + attributes do + uuid_primary_key :id + attribute :name, :string, public?: true + attribute :tags, {:array, :map}, public?: true + end +end + +defmodule GuardedStructTest.AshResources.WithAshChange do + @moduledoc "Resource that COMBINES our change with Ash's own change" + use Ash.Resource, + domain: GuardedStructTest.Support.TestDomain, + data_layer: Ash.DataLayer.Ets, + extensions: [GuardedStruct.AshResource] + + ets do + private? true + end + + guardedstruct do + auto_wire true + field :email, :string, derives: "sanitize(trim, downcase) validate(email_r)" + end + + actions do + defaults [:read, :destroy] + create :create, accept: [:email] + end + + # Ash's own change running alongside ours. + changes do + change fn cs, _ -> + email = Ash.Changeset.get_attribute(cs, :email) + slug = if email, do: email |> String.split("@") |> hd(), else: nil + Ash.Changeset.force_change_attribute(cs, :slug, slug) + end + end + + attributes do + uuid_primary_key :id + attribute :email, :string, allow_nil?: false, public?: true + attribute :slug, :string, public?: true + end +end + +defmodule GuardedStructTest.AshResources.AtomicEligibleUser do + @moduledoc """ + Real-world Ash resource that opts into atomic mode (`atomic: true`) + and exercises every atomic-safe op category — type checks, length, + comparison, regex patterns, enum, sanitize transforms. All ops in + this resource are in `GuardedStruct.AtomicClassifier`'s safe registry, + so the compile-time `VerifyAtomic` verifier accepts it. + """ + use Ash.Resource, + domain: GuardedStructTest.Support.TestDomain, + data_layer: Ash.DataLayer.Ets, + extensions: [GuardedStruct.AshResource] + + ets do + private? true + end + + guardedstruct do + atomic true + auto_wire true + + field :email, :string, + derives: "sanitize(trim, downcase) validate(string, not_empty, email_r, max_len=320)" + + field :username, :string, + derives: "sanitize(trim, downcase) validate(string, not_empty, min_len=3, max_len=20)" + + field :age, :integer, derives: "validate(integer, min_len=0, max_len=150)" + + field :role, :string, derives: "sanitize(trim) validate(enum=String[admin::user::guest])" + + field :tenant_id, :string, derives: "validate(uuid)" + + field :country_code, :string, + derives: "sanitize(trim, upcase) validate(string, min_len=2, max_len=2)" + + field :status, :string, + default: "active", + derives: "validate(enum=String[active::archived::pending])" + end + + actions do + defaults [:read, :destroy] + + create :create, accept: [:email, :username, :age, :role, :tenant_id, :country_code, :status] + + update :update do + accept [:email, :username, :age, :role, :status] + 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 + attribute :country_code, :string, public?: true + attribute :status, :string, public?: true + end +end diff --git a/test/support/ash_test_setup.ex b/test/support/ash_test_setup.ex new file mode 100644 index 0000000..579a04a --- /dev/null +++ b/test/support/ash_test_setup.ex @@ -0,0 +1,8 @@ +defmodule GuardedStructTest.Support.TestDomain do + @moduledoc false + use Ash.Domain, validate_config_inclusion?: false + + resources do + allow_unregistered? true + end +end diff --git a/test/support/fixtures/conditionals.ex b/test/support/fixtures/conditionals.ex new file mode 100644 index 0000000..2c58516 --- /dev/null +++ b/test/support/fixtures/conditionals.ex @@ -0,0 +1,180 @@ +defmodule GuardedStructFixtures.Conditionals do + @moduledoc """ + Nested `conditional_field` — the headline 0.1.0 unblocker. + + Two scenarios: + + * `Block` (shallow): a CMS block that can be a paragraph (string), an + image (map), or a gallery (list of images). + * `Document` (DEEPLY nested — see below): a page whose content is + either plain text or a rich structure containing nested + conditional bodies, which themselves contain conditional + paragraphs, which in turn may contain a quote sub_field with its + own source sub_field. + + Document's nesting depth: **7 levels** from the root, with **3 layers + of `conditional_field`** stacked: + + Document + └── :content (conditional) ← level 1 + └── sub_field :content (rich variant) ← level 2 + └── :body (conditional) ← level 3 + └── sub_field :body (structured variant) ← level 4 + └── :paragraphs (conditional, structs:) ← level 5 + └── sub_field (quote paragraph) ← level 6 + └── sub_field :source ← level 7 + + Exercises: + * `conditional_field` nested inside `conditional_field` ≥ 3 times + * `structs: true` on a list-of-conditional, INSIDE a sub_field that + is itself inside a conditional + * `hint:` propagation through multiple nesting levels + * Auto-numbered submodule names for sub_fields inside conditionals + (e.g. `Document.Content1.Body1.Paragraphs1.Source`) + """ + + defmodule Validators do + @moduledoc false + + def is_string(field, value) when is_binary(value), do: {:ok, field, value} + def is_string(field, _), do: {:error, field, "not a string"} + + def is_map(field, value) when is_map(value) and not is_struct(value), + do: {:ok, field, value} + + def is_map(field, _), do: {:error, field, "not a map"} + + def is_list(field, value) when is_list(value), do: {:ok, field, value} + def is_list(field, _), do: {:error, field, "not a list"} + end + + defmodule Image do + use GuardedStruct + + guardedstruct do + field(:url, String.t(), enforce: true, derives: "validate(url, max_len=2048)") + field(:alt, String.t(), default: "") + end + end + + defmodule Document do + @moduledoc """ + Deeply-nested CMS document. See parent module's @moduledoc for the + full nesting diagram. + """ + use GuardedStruct + + guardedstruct do + field(:title, String.t(), enforce: true, derives: "validate(string, not_empty)") + + # LEVEL 1 — conditional + conditional_field(:content, any()) do + # Variant A: plain string content + field(:content, String.t(), + hint: "plain", + validator: {Validators, :is_string}, + derives: "validate(string, max_len=50_000)" + ) + + # Variant B: rich content (sub_field). LEVEL 2. + sub_field(:content, struct(), + hint: "rich", + validator: {Validators, :is_map} + ) do + field(:title, String.t(), enforce: true, derives: "validate(string)") + + # LEVEL 3 — conditional inside the rich variant + conditional_field(:body, any()) do + # Variant B.1: simple-string body + field(:body, String.t(), + hint: "simple", + validator: {Validators, :is_string}, + derives: "validate(string, max_len=10_000)" + ) + + # Variant B.2: structured body (sub_field). LEVEL 4. + sub_field(:body, struct(), + hint: "structured", + validator: {Validators, :is_map} + ) do + field(:heading, String.t(), enforce: true, derives: "validate(string)") + + # LEVEL 5 — conditional list inside the structured body + conditional_field(:paragraphs, any(), + structs: true, + hint: "paragraphs", + validator: {Validators, :is_list} + ) do + # Variant B.2.a: plain paragraph (string) + field(:paragraphs, String.t(), + hint: "plain_paragraph", + validator: {Validators, :is_string}, + derives: "validate(string, max_len=5_000)" + ) + + # Variant B.2.b: quote paragraph. LEVEL 6. + sub_field(:paragraphs, struct(), + hint: "quote_paragraph", + validator: {Validators, :is_map} + ) do + field(:text, String.t(), enforce: true, derives: "validate(string)") + + # LEVEL 7 — sub_field inside a quote paragraph + sub_field(:source, struct()) do + field(:author, String.t(), enforce: true, derives: "validate(string)") + + field(:url, String.t(), derives: "validate(url, max_len=2048)") + end + end + end + end + end + end + end + end + end + + defmodule Block do + use GuardedStruct + + guardedstruct do + conditional_field(:block, any()) do + # 1. paragraph: just a string + field(:block, String.t(), + hint: "paragraph", + validator: {Validators, :is_string}, + derives: "validate(string, max_len=10_000)" + ) + + # 2. single image: a map + sub_field(:block, struct(), + hint: "image", + validator: {Validators, :is_map} + ) do + field(:url, String.t(), enforce: true, derives: "validate(url)") + field(:alt, String.t(), default: "") + end + + # 3. gallery: list of items, each is again a string-or-image + # conditional. THIS is the nested-conditional case 0.0.x couldn't do. + conditional_field(:block, any(), + structs: true, + hint: "gallery", + validator: {Validators, :is_list} + ) do + field(:block, String.t(), + hint: "gallery_item_string", + validator: {Validators, :is_string}, + derives: "validate(string, max_len=2048)" + ) + + field(:block, struct(), + struct: Image, + hint: "gallery_item_image", + validator: {Validators, :is_map} + ) + end + end + end + end +end diff --git a/test/support/fixtures/cross_field.ex b/test/support/fixtures/cross_field.ex new file mode 100644 index 0000000..9d5b2ad --- /dev/null +++ b/test/support/fixtures/cross_field.ex @@ -0,0 +1,100 @@ +defmodule GuardedStructFixtures.CrossField do + @moduledoc """ + Cross-field dependencies via three of the four core keys. + + Exercises: + * `from:` — pull a value from elsewhere in the input map + * `on:` — require another field/path to be present + * `auto:` — compute a value at build time + * `domain:` — constrain this field's allowed values based on a sibling field + * **`sub_field(..., enforce: true)` enforce-cascade pattern** — see + `StrictEvent` below + + See `test/core_keys_test.exs` for richer `domain:` coverage; here we + use a minimal sibling-path domain to keep the fixture realistic. + """ + + defmodule AuditedEvent do + use GuardedStruct + + guardedstruct authorized_fields: true do + # Top-level metadata + field(:actor_id, String.t(), enforce: true, derives: "validate(uuid)") + + field(:account_type, String.t(), + enforce: true, + derives: "validate(enum=String[free::pro::enterprise])" + ) + + # `domain:` here looks at the sibling `event.kind` (note: dot-separated + # path resolved against the same full_attrs map). Only allowed when + # account_type is in the listed enum AND the event kind is one of the + # safe ones — i.e. expresses an authorization rule. + field(:requested_by, String.t(), + domain: "!account_type=String[free, pro, enterprise]", + derives: "validate(string, not_empty)" + ) + + sub_field(:event, struct()) do + # `from:` pulls actor_id from the parent so the event carries the + # actor identity without the caller having to wire it twice. + field(:actor_id, String.t(), enforce: false, from: "root::actor_id") + + # `auto:` mints a fresh event UUID at build time. + field(:event_id, String.t(), + enforce: false, + auto: {GuardedStructTest.Support.UUID, :generate} + ) + + # `on:` enforces that the parent path exists before we accept :name. + field(:name, String.t(), + enforce: true, + on: "root::actor_id", + derives: "validate(string, not_empty)" + ) + + field(:kind, String.t(), + enforce: true, + derives: "validate(enum=String[login::logout::data.read::billing.refund])" + ) + end + end + end + + defmodule StrictEvent do + @moduledoc """ + Demonstrates the **enforce-cascade pattern** for sub_field. + + When a `sub_field` is declared with `enforce: true`, two things happen + at compile time (see `generate_sub_field_modules.ex:74`): + + 1. The sub_field itself becomes required in the parent. + 2. The sub_field's submodule is generated with `block_enforce = true`, + so **every inner field without an explicit `default:` becomes + required automatically**. + + To opt an inner field OUT of the cascade, mark it `enforce: false` + explicitly (see `:trace_id` below) or give it a `default:` value. + """ + use GuardedStruct + + guardedstruct do + field(:source, String.t(), enforce: true, derives: "validate(string, not_empty)") + + # enforce: true on the sub_field cascades to inner fields without defaults + sub_field(:payload, struct(), enforce: true) do + # Implicitly enforced via the cascade (no `enforce:` opt, no `default:`) + field(:kind, String.t(), derives: "validate(string)") + + # Also implicitly enforced via the cascade + field(:body, map(), derives: "validate(map)") + + # Has a default → NOT enforced even though parent has enforce: true + field(:retries, integer(), default: 0, derives: "validate(integer)") + + # Explicitly opted OUT of the cascade + field(:trace_id, String.t(), enforce: false, derives: "validate(string)") + end + end + end +end diff --git a/test/support/fixtures/custom_derives.ex b/test/support/fixtures/custom_derives.ex new file mode 100644 index 0000000..f371e33 --- /dev/null +++ b/test/support/fixtures/custom_derives.ex @@ -0,0 +1,49 @@ +defmodule GuardedStructFixtures.CustomDerives do + @moduledoc """ + Custom validators / sanitizers via the Spark-native extension DSL. + + Exercises: + * `use GuardedStruct.Derive.Extension` + * `validator :name, fun` — declarative validator op + * `sanitizer :name, fun` — declarative sanitizer op that transforms input + * Composing two custom ops on one field: `sanitize(slugify) validate(slug)` + + Activated by `:derive_extensions` config; see `test/fixtures_test.exs` + for the wiring. + """ + + defmodule MyDerives do + use GuardedStruct.Derive.Extension + + derives do + validator :slug, fn input -> + is_binary(input) and Regex.match?(~r/^[a-z0-9][a-z0-9-]*$/, input) + end + + validator :positive_int, fn input -> is_integer(input) and input > 0 end + + sanitizer :slugify, fn input when is_binary(input) -> + input + |> String.downcase() + |> String.replace(~r/[^a-z0-9]+/u, "-") + |> String.trim("-") + end + end + end + + defmodule Article do + use GuardedStruct + + guardedstruct do + field(:title, String.t(), enforce: true, derives: "validate(string, not_empty)") + + # Composed custom ops: + field(:slug, String.t(), + enforce: true, + derives: "sanitize(slugify) validate(slug)" + ) + + field(:views, integer(), default: 1, derives: "validate(positive_int)") + end + end +end diff --git a/test/support/fixtures/decorated.ex b/test/support/fixtures/decorated.ex new file mode 100644 index 0000000..2413cbf --- /dev/null +++ b/test/support/fixtures/decorated.ex @@ -0,0 +1,37 @@ +defmodule GuardedStructFixtures.Decorated do + @moduledoc """ + Shows the `@derives` / `@derive_rules` decorator as a cleaner alternative + to inline `derives:` when rules get long. + + Exercises: + * `@derives "..."` — short canonical form + * `@derive_rules "..."` — verbose alias + * One-shot semantics — only the very next field-like declaration consumes + the decorator (like `@doc`). + * Works on `field`, `sub_field`, and `conditional_field`. + """ + + defmodule BlogPost do + use GuardedStruct + + guardedstruct do + @derives "sanitize(strip_tags, trim) validate(string, not_empty, max_len=200)" + field :title, String.t(), enforce: true + + @derive_rules "sanitize(markdown_html, trim) validate(string, not_empty)" + field(:body, String.t(), enforce: true) + + @derives "validate(string, max_len=50)" + field(:slug, String.t()) + + # No decorator, no inline rule — accepts anything. + field(:draft, boolean(), default: false) + + @derives "validate(map)" + sub_field(:metadata, struct()) do + field(:tags, list(), default: []) + field(:author_id, String.t(), derives: "validate(uuid)") + end + end + end +end diff --git a/test/support/fixtures/decorated_all_entities.ex b/test/support/fixtures/decorated_all_entities.ex new file mode 100644 index 0000000..f4c7b71 --- /dev/null +++ b/test/support/fixtures/decorated_all_entities.ex @@ -0,0 +1,247 @@ +defmodule GuardedStructFixtures.DecoratedAllEntities do + @moduledoc """ + Exercises `@derives` / `@derive_rules` decorator on EVERY entity type + and at multiple nesting depths. + + Entity coverage: + * `field` — top-level + inside sub_field + inside conditional_field + * `sub_field` — decorator on the sub_field itself + * `conditional_field` — decorator on the conditional_field itself + on branch fields + * `virtual_field` — top-level (only allowed there per DSL schema) + * `dynamic_field` — top-level (only allowed there per DSL schema) + + Depth coverage: + * Level 1 (top) — every entity type + * Level 2 (inside sub_field) — field, sub_field, conditional_field + * Level 3 (sub_field within sub_field) — field + * Level 4 (sub_field within conditional inner sub_field) — field + + Each module below is small and focused on its specific decorator surface + so failures point at exactly the case that broke. + """ + + defmodule Validators do + @moduledoc false + def is_string(field, v) when is_binary(v), do: {:ok, field, v} + def is_string(field, _), do: {:error, field, "not a string"} + + def is_map(field, v) when is_map(v) and not is_struct(v), do: {:ok, field, v} + def is_map(field, _), do: {:error, field, "not a map"} + end + + # ---------------------------------------------------------------- + # 1. @derives on `field` — the baseline case + # ---------------------------------------------------------------- + defmodule OnField do + use GuardedStruct + + guardedstruct do + @derives "sanitize(trim) validate(string, max_len=10)" + field(:name, String.t()) + end + end + + # ---------------------------------------------------------------- + # 2. @derives on `virtual_field` — validated but not in struct + # ---------------------------------------------------------------- + defmodule OnVirtualField do + use GuardedStruct + + guardedstruct do + field(:keep, String.t(), enforce: true) + + @derives "validate(string, min_len=8)" + virtual_field(:password_confirmation, String.t()) + end + + # main_validator/1 auto-discovered — uses the virtual field + def main_validator(%{password_confirmation: pw} = attrs) when is_binary(pw), + do: {:ok, attrs} + + def main_validator(_), + do: + {:error, + [%{field: :password_confirmation, action: :missing, message: "confirmation required"}]} + end + + # ---------------------------------------------------------------- + # 3. @derives on `dynamic_field` — overrides default `validate(map)` + # ---------------------------------------------------------------- + defmodule OnDynamicField do + use GuardedStruct + + guardedstruct do + # `dynamic_field` defaults to `derives: "validate(map)"`. The decorator + # injects via `derives:`, which wins over the schema default. + @derives "validate(map, not_empty)" + dynamic_field(:metadata) + end + end + + # ---------------------------------------------------------------- + # 4. @derives on `sub_field` itself (the OUTER decorator) + # ---------------------------------------------------------------- + defmodule OnSubField do + use GuardedStruct + + guardedstruct do + @derives "validate(map)" + sub_field(:profile, struct()) do + field(:bio, String.t()) + end + end + end + + # ---------------------------------------------------------------- + # 5. @derives on `conditional_field` itself + # + # The decorator's derive enforces BEFORE branch resolution. So + # `@derives "validate(map)"` here means the value MUST be a map — + # both branches accept maps, but with different inner shapes. + # ---------------------------------------------------------------- + defmodule OnConditionalField do + use GuardedStruct + + guardedstruct do + @derives "validate(map)" + conditional_field(:detail, any()) do + sub_field(:detail, struct(), + hint: "minimal", + validator: {Validators, :is_map} + ) do + field(:tag, String.t()) + end + + sub_field(:detail, struct(), + hint: "full", + validator: {Validators, :is_map} + ) do + field(:tag, String.t(), enforce: true) + field(:extra, String.t()) + end + end + end + end + + # ---------------------------------------------------------------- + # 6. @derives on a `field` INSIDE a sub_field body (level 2) + # ---------------------------------------------------------------- + defmodule InsideSubField do + use GuardedStruct + + guardedstruct do + sub_field(:wrapper, struct()) do + @derives "sanitize(trim) validate(string, max_len=5)" + field(:tag, String.t()) + end + end + end + + # ---------------------------------------------------------------- + # 7. @derives on a `field` inside a `conditional_field` BRANCH + # ---------------------------------------------------------------- + defmodule InsideConditional do + use GuardedStruct + + guardedstruct do + conditional_field(:body, any()) do + @derives "validate(string, max_len=10)" + field(:body, String.t(), + hint: "short_string", + validator: {Validators, :is_string} + ) + + # No decorator on this branch — uses inline rules + sub_field(:body, struct(), + hint: "map_form", + validator: {Validators, :is_map} + ) do + @derives "validate(string)" + field(:kind, String.t()) + end + end + end + end + + # ---------------------------------------------------------------- + # 8. DEEP nesting — @derives at every level (1 → 2 → 3 → 4) + # ---------------------------------------------------------------- + defmodule DeepNested do + use GuardedStruct + + guardedstruct do + @derives "validate(string, max_len=10)" + # level 1 + field(:top, String.t()) + + @derives "validate(map)" + # level 1 on sub_field + sub_field(:l1, struct()) do + @derives "validate(string, max_len=20)" + # level 2 + field(:tag, String.t()) + + sub_field(:l2, struct()) do + @derives "validate(string, max_len=30)" + # level 3 + field(:tag, String.t()) + + sub_field(:l3, struct()) do + @derives "validate(string, max_len=40)" + # level 4 + field(:tag, String.t()) + end + end + end + end + end + + # ---------------------------------------------------------------- + # 9. Mixed-entity module — every entity type in one module + # ---------------------------------------------------------------- + defmodule MixedAll do + use GuardedStruct + + guardedstruct do + @derives "validate(string)" + field(:plain, String.t()) + + @derives "validate(map)" + dynamic_field(:extras) + + @derives "validate(string, min_len=3)" + virtual_field(:totp, String.t()) + + @derives "validate(map)" + sub_field(:nested, struct()) do + @derives "validate(string, max_len=10)" + field(:label, String.t()) + end + + # No @derives on the conditional itself here — would block strings. + # We keep this conditional permissive so the string branch can win + # for non-map inputs. The decorator on the inner sub_field's field + # still demonstrates inside-conditional decoration. + conditional_field(:variant, any()) do + field(:variant, String.t(), + hint: "string", + validator: {Validators, :is_string} + ) + + sub_field(:variant, struct(), + hint: "map", + validator: {Validators, :is_map} + ) do + @derives "validate(string)" + field(:value, String.t()) + end + end + end + + def main_validator(%{totp: t} = attrs) when is_binary(t) and byte_size(t) >= 3, + do: {:ok, attrs} + + def main_validator(_), + do: {:error, [%{field: :totp, action: :missing, message: "totp required"}]} + end +end diff --git a/test/support/fixtures/dynamic.ex b/test/support/fixtures/dynamic.ex new file mode 100644 index 0000000..79023d0 --- /dev/null +++ b/test/support/fixtures/dynamic.ex @@ -0,0 +1,56 @@ +defmodule GuardedStructFixtures.Dynamic do + @moduledoc """ + Free-form / runtime-extensible keys. + + Exercises: + * `dynamic_field` — open-shape metadata map + * Pattern-keyed map (regex `field` name) — typed shards with string keys + * Composing a pattern-keyed map module into a regular `struct:` reference + """ + + defmodule Shard do + use GuardedStruct + + guardedstruct do + field(:node, String.t(), enforce: true, derives: "validate(ipv4)") + field(:replicas, integer(), default: 1, derives: "validate(integer)") + end + end + + defmodule ShardsMap do + @moduledoc "Pattern-keyed map — keys must match the regex, values are `%Shard{}`." + use GuardedStruct + + guardedstruct do + field(~r/^shard_\d+$/, struct(), + struct: Shard, + derives: "validate(map, not_empty)" + ) + end + end + + defmodule Document do + @moduledoc "Document with id, body, and an open metadata map." + use GuardedStruct + + guardedstruct do + field(:id, String.t(), enforce: true, derives: "validate(uuid)") + field(:body, String.t(), enforce: true, derives: "validate(string)") + dynamic_field(:metadata) + end + end + + defmodule ClusterPlan do + @moduledoc "Composes the pattern-map (ShardsMap) with regular fields." + use GuardedStruct + + guardedstruct do + field(:status, String.t(), + enforce: true, + derives: "validate(enum=String[draft::active::archived])" + ) + + field(:shards, struct(), enforce: true, struct: ShardsMap) + end + end +end diff --git a/test/support/fixtures/extracted/async_compile_sub_fields.ex b/test/support/fixtures/extracted/async_compile_sub_fields.ex new file mode 100644 index 0000000..d5248d1 --- /dev/null +++ b/test/support/fixtures/extracted/async_compile_sub_fields.ex @@ -0,0 +1,90 @@ +defmodule GuardedStructTest.Fixtures.AsyncCompile.SimpleParent do + use GuardedStruct + + guardedstruct do + field :id, String.t(), enforce: true + + sub_field :profile, struct() do + field :nickname, String.t() + field :bio, String.t() + end + end +end + +defmodule GuardedStructTest.Fixtures.AsyncCompile.WideParent do + use GuardedStruct + + guardedstruct do + sub_field :a, struct() do + field :x, String.t() + end + + sub_field :b, struct() do + field :x, String.t() + end + + sub_field :c, struct() do + field :x, String.t() + end + + sub_field :d, struct() do + field :x, String.t() + end + end +end + +defmodule GuardedStructTest.Fixtures.AsyncCompile.DeepParent do + use GuardedStruct + + guardedstruct do + sub_field :level1, struct() do + field :tag, String.t() + + sub_field :level2, struct() do + field :tag, String.t() + + sub_field :level3, struct() do + field :tag, String.t() + + sub_field :level4, struct() do + field :value, String.t(), enforce: true + end + end + end + end + end +end + +defmodule GuardedStructTest.Fixtures.AsyncCompile.WithConditional.V do + def is_string(field, value) when is_binary(value), do: {:ok, field, value} + def is_string(field, _), do: {:error, field, "not a string"} + + def is_map(field, value) when is_map(value) and not is_struct(value), do: {:ok, field, value} + def is_map(field, _), do: {:error, field, "not a map"} +end + +defmodule GuardedStructTest.Fixtures.AsyncCompile.WithConditional do + use GuardedStruct + + alias GuardedStructTest.Fixtures.AsyncCompile.WithConditional.V + + guardedstruct do + conditional_field :payload, any() do + field :payload, String.t(), hint: "string", validator: {V, :is_string} + + sub_field :payload, struct(), hint: "map_form", validator: {V, :is_map} do + field :kind, String.t(), enforce: true + end + end + end +end + +defmodule GuardedStructTest.Fixtures.AsyncCompile.OrderedDeep do + use GuardedStruct + + guardedstruct do + sub_field :nested, struct() do + field :label, String.t(), default: "child-default" + end + end +end diff --git a/test/support/fixtures/extracted/basic_types.ex b/test/support/fixtures/extracted/basic_types.ex new file mode 100644 index 0000000..ccade9a --- /dev/null +++ b/test/support/fixtures/extracted/basic_types.ex @@ -0,0 +1,13 @@ +defmodule GuardedStructTest.Fixtures.BasicTypes.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 diff --git a/test/support/fixtures/extracted/derive_extension.ex b/test/support/fixtures/extracted/derive_extension.ex new file mode 100644 index 0000000..bee60eb --- /dev/null +++ b/test/support/fixtures/extracted/derive_extension.ex @@ -0,0 +1,32 @@ +defmodule GuardedStructTest.Fixtures.DeriveExtension.SlugDerives 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 + end +end + +defmodule GuardedStructTest.Fixtures.DeriveExtension.WithSlug do + use GuardedStruct + + guardedstruct do + field :slug, String.t(), derives: "validate(slug)" + end +end + +defmodule GuardedStructTest.Fixtures.DeriveExtension.WithSlugify do + use GuardedStruct + + guardedstruct do + field :slug, String.t(), derives: "sanitize(slugify) validate(slug)" + end +end diff --git a/test/support/fixtures/extracted/derive_rules_decorator.ex b/test/support/fixtures/extracted/derive_rules_decorator.ex new file mode 100644 index 0000000..f03c76b --- /dev/null +++ b/test/support/fixtures/extracted/derive_rules_decorator.ex @@ -0,0 +1,52 @@ +defmodule GuardedStructTest.Fixtures.DeriveRulesDecorator.Decorated do + use GuardedStruct + + guardedstruct do + @derive_rules "validate(string, max_len=10)" + field :name, String.t() + + @derive_rules "validate(integer, min_len=0)" + field :age, integer() + + field :plain, String.t() + end +end + +defmodule GuardedStructTest.Fixtures.DeriveRulesDecorator.Inline do + use GuardedStruct + + guardedstruct do + field :name, String.t(), derives: "validate(string, max_len=10)" + field :age, integer(), derives: "validate(integer, min_len=0)" + field :plain, String.t() + end +end + +defmodule GuardedStructTest.Fixtures.DeriveRulesDecorator.WithAlias do + use GuardedStruct + + guardedstruct do + @derives "validate(string, max_len=10)" + field :name, String.t() + end +end + +defmodule GuardedStructTest.Fixtures.DeriveRulesDecorator.WithBoth do + use GuardedStruct + + guardedstruct do + @derive_rules "validate(string, max_len=5)" + field :name, String.t(), derives: "validate(string, max_len=100)" + end +end + +defmodule GuardedStructTest.Fixtures.DeriveRulesDecorator.WithSub do + use GuardedStruct + + guardedstruct do + @derive_rules "validate(map)" + sub_field :auth, struct() do + field :role, String.t() + end + end +end diff --git a/test/support/fixtures/extracted/derives_deprecation.ex b/test/support/fixtures/extracted/derives_deprecation.ex new file mode 100644 index 0000000..3129b34 --- /dev/null +++ b/test/support/fixtures/extracted/derives_deprecation.ex @@ -0,0 +1,7 @@ +defmodule GuardedStructTest.Fixtures.DerivesDeprecation.CanonicalName do + use GuardedStruct + + guardedstruct do + field :name, String.t(), derives: "validate(string, max_len=10)" + end +end diff --git a/test/support/fixtures/extracted/diff.ex b/test/support/fixtures/extracted/diff.ex new file mode 100644 index 0000000..956b771 --- /dev/null +++ b/test/support/fixtures/extracted/diff.ex @@ -0,0 +1,22 @@ +defmodule GuardedStructTest.Fixtures.Diff.User do + use GuardedStruct + + guardedstruct do + field :name, String.t(), enforce: true + field :age, integer() + field :role, String.t() + + sub_field :address, struct() do + field :city, String.t() + field :zip, String.t() + end + end +end + +defmodule GuardedStructTest.Fixtures.Diff.Other do + defstruct [:x] +end + +defmodule GuardedStructTest.Fixtures.Diff.Other2 do + defstruct [:x] +end diff --git a/test/support/fixtures/extracted/errors.ex b/test/support/fixtures/extracted/errors.ex new file mode 100644 index 0000000..2dce4ef --- /dev/null +++ b/test/support/fixtures/extracted/errors.ex @@ -0,0 +1,8 @@ +defmodule GuardedStructTest.Fixtures.Errors.SampleStruct do + use GuardedStruct + + guardedstruct do + field :email, String.t(), enforce: true, derives: "validate(string, email_r)" + field :age, integer(), derives: "validate(integer, max_len=120, min_len=0)" + end +end diff --git a/test/support/fixtures/extracted/example_helper.ex b/test/support/fixtures/extracted/example_helper.ex new file mode 100644 index 0000000..e1fadb8 --- /dev/null +++ b/test/support/fixtures/extracted/example_helper.ex @@ -0,0 +1,35 @@ +defmodule GuardedStructTest.Fixtures.ExampleHelper.WithDefaults do + use GuardedStruct + + guardedstruct do + field :name, String.t(), default: "default name" + field :age, integer(), default: 42 + field :active, boolean(), default: true + end +end + +defmodule GuardedStructTest.Fixtures.ExampleHelper.TypeFallbacks do + use GuardedStruct + + guardedstruct do + field :name, String.t() + field :count, integer() + field :rate, float() + field :active, boolean() + field :tags, list() + field :metadata, map() + end +end + +defmodule GuardedStructTest.Fixtures.ExampleHelper.Nested do + use GuardedStruct + + guardedstruct do + field :title, String.t(), default: "the title" + + sub_field :meta, struct() do + field :author, String.t(), default: "anon" + field :year, integer(), default: 2026 + end + end +end diff --git a/test/support/fixtures/extracted/info.ex b/test/support/fixtures/extracted/info.ex new file mode 100644 index 0000000..97fff81 --- /dev/null +++ b/test/support/fixtures/extracted/info.ex @@ -0,0 +1,47 @@ +defmodule GuardedStructTest.Fixtures.Info.EverythingUser.Hashers do + @moduledoc false + def hash(field, v) when is_binary(v), do: {:ok, field, v} + def hash(field, _), do: {:error, field, "not a string"} +end + +defmodule GuardedStructTest.Fixtures.Info.EverythingUser.Ids do + @moduledoc false + def gen, do: "id-stub" +end + +defmodule GuardedStructTest.Fixtures.Info.EverythingUser do + use GuardedStruct + + alias GuardedStructTest.Fixtures.Info.EverythingUser.{Hashers, Ids} + + guardedstruct enforce: true, authorized_fields: true, json: true do + field :id, String.t(), auto: {Ids, :gen} + field :password, String.t(), validator: {Hashers, :hash} + field :nickname, String.t(), enforce: false, derives: "validate(string, max_len=20)" + field :status, String.t(), default: "active" + virtual_field :password_confirm, String.t() + dynamic_field :metadata + + sub_field :address, struct() do + field :city, String.t(), enforce: true + field :zip, String.t() + end + + conditional_field :billing, any() do + field :billing, String.t(), hint: "preset_name", derives: "validate(string)" + + sub_field :billing, struct() do + field :method, String.t(), enforce: true + field :account, String.t() + end + end + end +end + +defmodule GuardedStructTest.Fixtures.Info.HeadersMap do + use GuardedStruct + + guardedstruct do + field ~r/^X-[A-Z][A-Za-z\-]*$/, String.t(), derives: "validate(string)" + end +end diff --git a/test/support/fixtures/extracted/json_encoder.ex b/test/support/fixtures/extracted/json_encoder.ex new file mode 100644 index 0000000..cd4e07f --- /dev/null +++ b/test/support/fixtures/extracted/json_encoder.ex @@ -0,0 +1,30 @@ +defmodule GuardedStructTest.Fixtures.JsonEncoder.Plain do + use GuardedStruct + + guardedstruct do + field :name, String.t(), enforce: true + field :age, integer() + end +end + +defmodule GuardedStructTest.Fixtures.JsonEncoder.WithJason do + use GuardedStruct + + guardedstruct json: true do + field :name, String.t(), enforce: true + field :age, integer() + end +end + +defmodule GuardedStructTest.Fixtures.JsonEncoder.Nested do + use GuardedStruct + + guardedstruct json: true do + field :name, String.t(), enforce: true + + sub_field :address, struct() do + field :city, String.t(), enforce: true + field :zip, String.t() + end + end +end diff --git a/test/support/fixtures/extracted/nested_conditional_field.ex b/test/support/fixtures/extracted/nested_conditional_field.ex new file mode 100644 index 0000000..825a66c --- /dev/null +++ b/test/support/fixtures/extracted/nested_conditional_field.ex @@ -0,0 +1,63 @@ +defmodule GuardedStructTest.Fixtures.NestedConditionalField.Actor do + use GuardedStruct + @types ["Application", "Group", "Organization", "Person", "Service"] + + guardedstruct do + field :id, String.t(), derives: "sanitize(tag=strip_tags) validate(url)" + + field :type, String.t(), + derives: "sanitize(tag=strip_tags) validate(enum=String[#{Enum.join(@types, "::")}])", + default: "Person" + + field :summary, String.t(), + enforce: true, + derives: "sanitize(tag=strip_tags) validate(not_empty_string, max_len=364, min_len=3)" + end +end + +defmodule GuardedStructTest.Fixtures.NestedConditionalField.Conditional do + use GuardedStruct + alias ConditionalFieldValidatorTestValidators, as: VAL + alias GuardedStructTest.Fixtures.NestedConditionalField.Actor + + guardedstruct do + conditional_field :actor, any() do + field :actor, struct(), struct: Actor, validator: {VAL, :is_map_data} + + conditional_field :actor, any(), structs: true, validator: {VAL, :is_list_data} do + field :actor, struct(), struct: Actor, validator: {VAL, :is_map_data} + + field :actor, String.t(), + validator: {VAL, :is_string_data}, + derives: "sanitize(tag=strip_tags) validate(url, max_len=160)" + end + + field :actor, String.t(), + validator: {VAL, :is_string_data}, + derives: "sanitize(tag=strip_tags) validate(url, max_len=160)" + end + end +end + +defmodule GuardedStructTest.Fixtures.NestedConditionalField.TripleNest do + use GuardedStruct + alias ConditionalFieldValidatorTestValidators, as: VAL + + guardedstruct do + conditional_field :choice, any() do + field :choice, String.t(), validator: {VAL, :is_string_data}, hint: "level1_string" + + conditional_field :choice, any(), validator: {VAL, :is_map_data} do + field :choice, String.t(), validator: {VAL, :is_string_data}, hint: "level2_string" + + conditional_field :choice, any(), validator: {VAL, :is_map_data} do + field :choice, String.t(), + validator: {VAL, :is_string_data}, + hint: "level3_string" + + field :choice, :integer, validator: {VAL, :is_int_data}, hint: "level3_int" + end + end + end + end +end diff --git a/test/support/fixtures/extracted/nested_sub_field.ex b/test/support/fixtures/extracted/nested_sub_field.ex new file mode 100644 index 0000000..70ef9a9 --- /dev/null +++ b/test/support/fixtures/extracted/nested_sub_field.ex @@ -0,0 +1,21 @@ +defmodule GuardedStructTest.Fixtures.NestedSubField.NestedSubFieldListStructs do + use GuardedStruct + + guardedstruct do + sub_field :list, + list(struct()), + structs: true, + derives: "validate(list, not_empty)", + enforce: true do + field :id, String.t(), enforce: true + + sub_field :sublist, + list(struct()), + structs: true, + derives: "validate(list, not_empty)", + enforce: true do + field :id, String.t() + end + end + end +end diff --git a/test/support/fixtures/extracted/pattern_map.ex b/test/support/fixtures/extracted/pattern_map.ex new file mode 100644 index 0000000..1952ca9 --- /dev/null +++ b/test/support/fixtures/extracted/pattern_map.ex @@ -0,0 +1,48 @@ +defmodule GuardedStructTest.Fixtures.PatternMap.Shard do + use GuardedStruct + + guardedstruct do + field :node, String.t(), enforce: true, derives: "sanitize(trim) validate(ipv4)" + end +end + +defmodule GuardedStructTest.Fixtures.PatternMap.ShardsMap do + use GuardedStruct + + guardedstruct do + field ~r/^shard_\d+$/, + struct(), + struct: GuardedStructTest.Fixtures.PatternMap.Shard, + derives: "validate(map, not_empty)" + end +end + +defmodule GuardedStructTest.Fixtures.PatternMap.Plan do + use GuardedStruct + + guardedstruct do + field :status, String.t(), enforce: true + + field :shards_map, + struct(), + struct: GuardedStructTest.Fixtures.PatternMap.ShardsMap, + enforce: true + end +end + +defmodule GuardedStructTest.Fixtures.PatternMap.MultiPattern do + use GuardedStruct + + guardedstruct do + field ~r/^shard_\d+$/, struct(), struct: GuardedStructTest.Fixtures.PatternMap.Shard + field ~r/^backup_\d+$/, struct(), struct: GuardedStructTest.Fixtures.PatternMap.Shard + end +end + +defmodule GuardedStructTest.Fixtures.PatternMap.HeadersMap do + use GuardedStruct + + guardedstruct do + field ~r/^X-[A-Z][A-Za-z0-9\-]*$/, String.t() + end +end diff --git a/test/support/fixtures/extracted/record.ex b/test/support/fixtures/extracted/record.ex new file mode 100644 index 0000000..efbbd9a --- /dev/null +++ b/test/support/fixtures/extracted/record.ex @@ -0,0 +1,8 @@ +defmodule GuardedStructTest.Fixtures.Record.WithRecord do + use GuardedStruct + + guardedstruct do + field :any_record, :tuple, derives: "validate(record)" + field :user_record, :tuple, derives: "validate(record=user)" + end +end diff --git a/test/support/fixtures/extracted/telemetry.ex b/test/support/fixtures/extracted/telemetry.ex new file mode 100644 index 0000000..f9fa80e --- /dev/null +++ b/test/support/fixtures/extracted/telemetry.ex @@ -0,0 +1,28 @@ +defmodule GuardedStructTest.Fixtures.Telemetry.Sample do + use GuardedStruct + + guardedstruct do + field :name, String.t(), enforce: true, derives: "validate(string, max_len=80)" + field :age, integer(), derives: "validate(integer, min_len=0)" + end +end + +defmodule GuardedStructTest.Fixtures.Telemetry.WithBoom do + use GuardedStruct + + guardedstruct error: true do + field :name, String.t(), enforce: true + end +end + +defmodule GuardedStructTest.Fixtures.Telemetry.WithNested do + use GuardedStruct + + guardedstruct do + field :name, String.t() + + sub_field :auth, struct() do + field :role, String.t() + end + end +end diff --git a/test/support/fixtures/extracted/validate.ex b/test/support/fixtures/extracted/validate.ex new file mode 100644 index 0000000..3a56dbf --- /dev/null +++ b/test/support/fixtures/extracted/validate.ex @@ -0,0 +1,40 @@ +defmodule GuardedStructTest.Fixtures.Validate.Person do + use GuardedStruct + + guardedstruct do + field :name, String.t(), + enforce: true, + derives: "sanitize(trim) validate(string, max_len=80)" + + field :age, integer(), derives: "validate(integer, min_len=0, max_len=120)" + field :email, String.t(), derives: "validate(email_r)" + field :role, String.t(), derives: "validate(enum=String[admin::user::guest])" + field :account_type, String.t(), derives: "validate(enum=String[personal::business])" + + field :parent_email, String.t(), + derives: "validate(email_r)", + on: "root::account_type" + + field :nickname, String.t(), validator: {__MODULE__, :nickname_validator} + end + + def nickname_validator(:nickname, value) do + if is_binary(value) and byte_size(value) >= 3, + do: {:ok, :nickname, value}, + else: {:error, :nickname, "nickname too short"} + end + + def nickname_validator(name, value), do: {:ok, name, value} +end + +defmodule GuardedStructTest.Fixtures.Validate.WithAuth do + use GuardedStruct + + guardedstruct do + field :name, String.t(), derives: "validate(string)" + + sub_field :auth, struct() do + field :role, String.t(), derives: "validate(enum=String[admin::user])" + end + end +end diff --git a/test/support/fixtures/extracted/virtual_field.ex b/test/support/fixtures/extracted/virtual_field.ex new file mode 100644 index 0000000..89b5e7a --- /dev/null +++ b/test/support/fixtures/extracted/virtual_field.ex @@ -0,0 +1,28 @@ +defmodule GuardedStructTest.Fixtures.VirtualField.Signup do + use GuardedStruct + + guardedstruct do + field :email, String.t(), enforce: true, derives: "validate(string, email_r)" + field :password, String.t(), enforce: true, derives: "validate(string, min_len=8)" + virtual_field :password_confirm, String.t(), derives: "validate(string)" + end + + # Convention: `main_validator/1` is auto-discovered by the runtime when + # defined on the user module (no need for an explicit MFA tuple). + 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 + +defmodule GuardedStructTest.Fixtures.VirtualField.WithDynamic do + use GuardedStruct + + guardedstruct do + field :name, String.t(), enforce: true, derives: "validate(string)" + dynamic_field :metadata + end +end diff --git a/test/support/fixtures/forms.ex b/test/support/fixtures/forms.ex new file mode 100644 index 0000000..f638d47 --- /dev/null +++ b/test/support/fixtures/forms.ex @@ -0,0 +1,83 @@ +defmodule GuardedStructFixtures.Forms do + @moduledoc """ + Real-world signup / login forms. + + Exercises: + * `virtual_field` — `password_confirmation` is validated but excluded from `defstruct` + * Per-field `validator:` — hashes the password on accept (transforms the value) + * `main_validator/1` auto-discovery — cross-field check that password == confirmation + * `json: true` — `Signup` is JSON-encodable + """ + + defmodule Hasher do + @moduledoc false + + # Length-checks the plaintext BEFORE hashing — otherwise the per-field + # derive (which runs *after* the validator) would only see the hash and + # the length rule would always pass. + def hash(field, value) + when is_binary(value) and byte_size(value) >= 8 and byte_size(value) <= 128 do + {:ok, field, :crypto.hash(:sha256, value) |> Base.encode16(case: :lower)} + end + + def hash(field, value) when is_binary(value), + do: {:error, field, "password must be 8-128 characters"} + + def hash(field, value), + do: {:error, field, "expected a string, got #{inspect(value)}"} + end + + defmodule Signup do + use GuardedStruct + + guardedstruct json: true do + field(:email, String.t(), + enforce: true, + derives: "sanitize(trim, downcase) validate(string, email_r, max_len=320)" + ) + + field(:password, String.t(), + enforce: true, + derives: "validate(string)", + validator: {Hasher, :hash} + ) + + virtual_field(:password_confirmation, String.t(), + enforce: true, + derives: "validate(string)" + ) + end + + # main_validator/1 is picked up automatically by the runtime + def main_validator(%{password: hashed, password_confirmation: plain} = attrs) + when is_binary(plain) do + # `password` is already hashed by Hasher.hash/2 above; we hash the plain + # confirmation the same way and compare. + {_, _, hashed_confirm} = Hasher.hash(:password_confirmation, plain) + if hashed == hashed_confirm, do: {:ok, attrs}, else: passwords_mismatch() + end + + def main_validator(_attrs), do: passwords_mismatch() + + defp passwords_mismatch do + {:error, + [%{field: :password_confirmation, action: :match, message: "passwords don't match"}]} + end + end + + defmodule Login do + use GuardedStruct + + guardedstruct do + field(:email, String.t(), + enforce: true, + derives: "sanitize(trim, downcase) validate(string, email_r)" + ) + + field(:password, String.t(), + enforce: true, + derives: "validate(string, min_len=1)" + ) + end + end +end diff --git a/test/support/fixtures/inline_all_entities.ex b/test/support/fixtures/inline_all_entities.ex new file mode 100644 index 0000000..dc22750 --- /dev/null +++ b/test/support/fixtures/inline_all_entities.ex @@ -0,0 +1,198 @@ +defmodule GuardedStructFixtures.InlineAllEntities do + @moduledoc """ + Inline `derives:` opt on EVERY entity type at multiple nesting depths — + mirrors `DecoratedAllEntities` but uses the keyword-list form instead + of the `@derives` attribute decorator. + + After the virtual_field two-pass derive fix in Runtime, **all 5 entity + types now enforce their `derives:` rules at runtime**, regardless of + whether the rule was written inline or via the decorator. + + Modules below cover: + * `field` — top-level, inside sub_field, inside conditional branch + * `virtual_field` — top-level (only allowed there per DSL schema) + * `dynamic_field` — top-level (only allowed there per DSL schema) + * `sub_field` — both on the sub_field itself AND on inner fields + * `conditional_field` — on the conditional itself + on branch fields + * Deep nesting (levels 1 → 2 → 3 → 4) + """ + + defmodule Validators do + @moduledoc false + def is_string(field, v) when is_binary(v), do: {:ok, field, v} + def is_string(field, _), do: {:error, field, "not a string"} + + def is_map(field, v) when is_map(v) and not is_struct(v), do: {:ok, field, v} + def is_map(field, _), do: {:error, field, "not a map"} + end + + # ---------------------------------------------------------------- + # 1. inline derives: on `field` + # ---------------------------------------------------------------- + defmodule OnField do + use GuardedStruct + + guardedstruct do + field(:name, String.t(), derives: "sanitize(trim) validate(string, max_len=10)") + end + end + + # ---------------------------------------------------------------- + # 2. inline derives: on `virtual_field` + # ---------------------------------------------------------------- + defmodule OnVirtualField do + use GuardedStruct + + guardedstruct do + field(:keep, String.t(), enforce: true) + virtual_field(:password_confirmation, String.t(), derives: "validate(string, min_len=8)") + end + + def main_validator(%{password_confirmation: pw} = attrs) when is_binary(pw), + do: {:ok, attrs} + + def main_validator(_), + do: {:error, [%{field: :password_confirmation, action: :missing, message: "required"}]} + end + + # ---------------------------------------------------------------- + # 3. inline derives: on `dynamic_field` (overrides schema default) + # ---------------------------------------------------------------- + defmodule OnDynamicField do + use GuardedStruct + + guardedstruct do + dynamic_field(:metadata, derives: "validate(map, not_empty)") + end + end + + # ---------------------------------------------------------------- + # 4. inline derives: on `sub_field` itself + # ---------------------------------------------------------------- + defmodule OnSubField do + use GuardedStruct + + guardedstruct do + sub_field(:profile, struct(), derives: "validate(map)") do + field(:bio, String.t()) + end + end + end + + # ---------------------------------------------------------------- + # 5. inline derives: on `conditional_field` itself + # ---------------------------------------------------------------- + defmodule OnConditionalField do + use GuardedStruct + + guardedstruct do + conditional_field(:detail, any(), derives: "validate(map)") do + sub_field(:detail, struct(), + hint: "minimal", + validator: {Validators, :is_map} + ) do + field(:tag, String.t()) + end + + sub_field(:detail, struct(), + hint: "full", + validator: {Validators, :is_map} + ) do + field(:tag, String.t(), enforce: true) + field(:extra, String.t()) + end + end + end + end + + # ---------------------------------------------------------------- + # 6. inline derives: on field INSIDE a sub_field body + # ---------------------------------------------------------------- + defmodule InsideSubField do + use GuardedStruct + + guardedstruct do + sub_field(:wrapper, struct()) do + field(:tag, String.t(), derives: "sanitize(trim) validate(string, max_len=5)") + end + end + end + + # ---------------------------------------------------------------- + # 7. inline derives: on branch fields of conditional_field + # ---------------------------------------------------------------- + defmodule InsideConditional do + use GuardedStruct + + guardedstruct do + conditional_field(:body, any()) do + field(:body, String.t(), + hint: "short_string", + validator: {Validators, :is_string}, + derives: "validate(string, max_len=10)" + ) + + sub_field(:body, struct(), + hint: "map_form", + validator: {Validators, :is_map} + ) do + field(:kind, String.t(), derives: "validate(string)") + end + end + end + end + + # ---------------------------------------------------------------- + # 8. DEEP nesting — inline derives: at every level (1 → 2 → 3 → 4) + # ---------------------------------------------------------------- + defmodule DeepNested do + use GuardedStruct + + guardedstruct do + field(:top, String.t(), derives: "validate(string, max_len=10)") + + sub_field(:l1, struct(), derives: "validate(map)") do + field(:tag, String.t(), derives: "validate(string, max_len=20)") + + sub_field(:l2, struct()) do + field(:tag, String.t(), derives: "validate(string, max_len=30)") + + sub_field(:l3, struct()) do + field(:tag, String.t(), derives: "validate(string, max_len=40)") + end + end + end + end + end + + # ---------------------------------------------------------------- + # 9. Mixed-entity module — every entity type, inline form + # ---------------------------------------------------------------- + defmodule MixedAll do + use GuardedStruct + + guardedstruct do + field(:plain, String.t(), derives: "validate(string)") + dynamic_field(:extras, derives: "validate(map)") + virtual_field(:totp, String.t(), derives: "validate(string, min_len=3)") + + sub_field(:nested, struct(), derives: "validate(map)") do + field(:label, String.t(), derives: "validate(string, max_len=10)") + end + + conditional_field(:variant, any()) do + field(:variant, String.t(), + hint: "string", + validator: {Validators, :is_string} + ) + + sub_field(:variant, struct(), + hint: "map", + validator: {Validators, :is_map} + ) do + field(:value, String.t(), derives: "validate(string)") + end + end + end + end +end diff --git a/test/support/fixtures/mixed_decorator_inline.ex b/test/support/fixtures/mixed_decorator_inline.ex new file mode 100644 index 0000000..8edf1db --- /dev/null +++ b/test/support/fixtures/mixed_decorator_inline.ex @@ -0,0 +1,120 @@ +defmodule GuardedStructFixtures.MixedDecoratorInline do + @moduledoc """ + Fixtures combining the two `derives:` syntactic forms (decorator + `@derives "..."` and inline `derives: "..."`) in various arrangements + to prove they coexist and produce equivalent results. + + Covers: + * Decorator on field A + inline on field B at the same level + * Decorator on outer sub_field + inline on inner field + * Inline on outer sub_field + decorator on inner field + * Both forms present on the SAME field (inline wins) + * Adjacent virtual_field decorator + inline (each enforced independently) + """ + + defmodule Validators do + @moduledoc false + def is_string(field, v) when is_binary(v), do: {:ok, field, v} + def is_string(field, _), do: {:error, field, "not a string"} + + def is_map(field, v) when is_map(v) and not is_struct(v), do: {:ok, field, v} + def is_map(field, _), do: {:error, field, "not a map"} + end + + # ---------------------------------------------------------------- + # 1. Two siblings — decorator on one, inline on the other + # ---------------------------------------------------------------- + defmodule SiblingMix do + use GuardedStruct + + guardedstruct do + @derives "validate(string, max_len=5)" + field(:short_name, String.t()) + + field(:long_name, String.t(), derives: "validate(string, max_len=50)") + end + end + + # ---------------------------------------------------------------- + # 2. Decorator on outer sub_field + inline on inner field + # ---------------------------------------------------------------- + defmodule OuterDecoratorInnerInline do + use GuardedStruct + + guardedstruct do + @derives "validate(map)" + sub_field(:profile, struct()) do + field(:nickname, String.t(), derives: "validate(string, max_len=20)") + end + end + end + + # ---------------------------------------------------------------- + # 3. Inline on outer sub_field + decorator on inner field + # ---------------------------------------------------------------- + defmodule OuterInlineInnerDecorator do + use GuardedStruct + + guardedstruct do + sub_field(:profile, struct(), derives: "validate(map)") do + @derives "validate(string, max_len=20)" + field(:nickname, String.t()) + end + end + end + + # ---------------------------------------------------------------- + # 4. BOTH on the same field — inline wins (existing precedence rule) + # ---------------------------------------------------------------- + defmodule BothOnSameField do + use GuardedStruct + + guardedstruct do + @derives "validate(string, max_len=5)" + field(:name, String.t(), derives: "validate(string, max_len=100)") + end + end + + # ---------------------------------------------------------------- + # 5. Adjacent virtual_field — decorator on one, inline on the other. + # Confirms decorator one-shot semantics + independent enforcement. + # ---------------------------------------------------------------- + defmodule VirtualMix do + use GuardedStruct + + guardedstruct do + field(:keep, String.t(), enforce: true) + + @derives "validate(string, min_len=4)" + virtual_field(:totp_a, String.t()) + + virtual_field(:totp_b, String.t(), derives: "validate(string, min_len=6)") + end + + def main_validator(%{totp_a: a, totp_b: b} = attrs) + when is_binary(a) and is_binary(b), + do: {:ok, attrs} + + def main_validator(_attrs), + do: {:error, [%{field: :virtual, action: :missing, message: "totp_a and totp_b required"}]} + end + + # ---------------------------------------------------------------- + # 6. Mixed conditional — decorator on conditional + inline on branch field + # ---------------------------------------------------------------- + defmodule ConditionalMix do + use GuardedStruct + + guardedstruct do + @derives "validate(map)" + conditional_field(:detail, any()) do + sub_field(:detail, struct(), + hint: "minimal", + validator: {Validators, :is_map} + ) do + field(:tag, String.t(), derives: "validate(string, max_len=8)") + end + end + end + end +end diff --git a/test/support/fixtures/records.ex b/test/support/fixtures/records.ex new file mode 100644 index 0000000..9744f8c --- /dev/null +++ b/test/support/fixtures/records.ex @@ -0,0 +1,30 @@ +defmodule GuardedStructFixtures.Records do + @moduledoc """ + Erlang Records via `validate(record=Tag)`. + + Exercises: + * `validate(record)` — any tagged-tuple shape + * `validate(record=Tag)` — specific tag + + Real-world use: bridging Elixir code that wraps Erlang OTP returns + (e.g. `:mnesia` rows, `:gen_event` notifications) into typed structs. + """ + + require Record + Record.defrecord(:user, :user, name: nil, age: nil) + Record.defrecord(:address, :address, street: nil, city: nil, zip: nil) + + defmodule UserEvent do + use GuardedStruct + + guardedstruct do + field(:event_kind, atom(), + enforce: true, + derives: "validate(enum=Atom[created::updated::deleted])" + ) + + field(:user, :tuple, enforce: true, derives: "validate(record=user)") + field(:trace, :tuple, derives: "validate(record)") + end + end +end diff --git a/test/support/fixtures/showcase.ex b/test/support/fixtures/showcase.ex new file mode 100644 index 0000000..3eb7560 --- /dev/null +++ b/test/support/fixtures/showcase.ex @@ -0,0 +1,138 @@ +defmodule GuardedStructFixtures.Showcase do + @moduledoc """ + The "everything-at-once" showcase: an `EnterpriseAccount` that exercises + most of 0.1.0's new surface in a single coherent schema. + + Combines: + * `json: true` — JSON-encodable for API + * `@derives` decorator — clean DSL + * `virtual_field` — `:invitation_token` validated but not persisted + * `auto:` — `:id` minted at build time, `:created_at` timestamped + * `from:` — `:owner_email` pulled from the embedded `:owner` sub_field + * `dynamic_field` — `:settings` is an open map + * `sub_field` with `structs: true` — list of `Member`s + * Nested `conditional_field` — `:plan` is either a string preset OR a + detailed map, and the detailed map's `:overrides` is itself a + conditional (map OR list of overrides) + * `main_validator/1` auto-discovery — enforces invitation token length + * `Diff.diff/2` / `Validate.partial/2` work over this shape + """ + + alias GuardedStructFixtures.CustomDerives.MyDerives + _ = MyDerives + + defmodule Validators do + @moduledoc false + def is_string(field, v) when is_binary(v), do: {:ok, field, v} + def is_string(field, _), do: {:error, field, "not a string"} + + def is_map(field, v) when is_map(v) and not is_struct(v), do: {:ok, field, v} + def is_map(field, _), do: {:error, field, "not a map"} + + def is_list(field, v) when is_list(v), do: {:ok, field, v} + def is_list(field, _), do: {:error, field, "not a list"} + end + + defmodule Member do + use GuardedStruct + + guardedstruct do + @derives "validate(uuid)" + field(:id, String.t(), enforce: true) + + @derives "sanitize(trim, downcase) validate(string, email_r)" + field(:email, String.t(), enforce: true) + + @derives "validate(enum=String[owner::admin::member::viewer])" + field(:role, String.t(), default: "member") + end + end + + defmodule EnterpriseAccount do + use GuardedStruct + + guardedstruct json: true do + field(:id, String.t(), auto: {GuardedStructTest.Support.UUID, :generate}) + + @derives "sanitize(trim) validate(string, not_empty, max_len=100)" + field(:name, String.t(), enforce: true) + + sub_field(:owner, struct(), enforce: true) do + @derives "validate(uuid)" + field(:id, String.t(), enforce: true) + + @derives "sanitize(trim, downcase) validate(string, email_r)" + field(:email, String.t(), enforce: true) + end + + # Pulled from owner.email + field(:owner_email, String.t(), from: "root::owner::email") + + sub_field(:members, list(struct()), structs: true) do + @derives "validate(uuid)" + field(:id, String.t(), enforce: true) + + @derives "sanitize(trim, downcase) validate(string, email_r)" + field(:email, String.t(), enforce: true) + + @derives "validate(enum=String[owner::admin::member::viewer])" + field(:role, String.t(), default: "member") + end + + # Plan is either a preset string OR a sub_field with a detailed shape + # whose `:notes` field is itself a string-or-list conditional. + # This exercises the headline 0.1.0 fix: conditional_field nested + # inside a sub_field that's a branch of a conditional_field. + conditional_field(:plan, any()) do + field(:plan, String.t(), + hint: "preset", + validator: {Validators, :is_string}, + derives: "validate(enum=String[free::pro::enterprise])" + ) + + sub_field(:plan, struct(), + hint: "detailed", + validator: {Validators, :is_map} + ) do + field(:tier, String.t(), + enforce: true, + derives: "validate(enum=String[pro::enterprise::custom])" + ) + + field(:seat_count, integer(), derives: "validate(integer)") + + # Inner conditional: notes is a string OR a list of strings. + conditional_field(:notes, any()) do + field(:notes, String.t(), + hint: "single", + validator: {Validators, :is_string}, + derives: "validate(string, max_len=500)" + ) + + field(:notes, list(), + hint: "many", + validator: {Validators, :is_list}, + derives: "validate(list)" + ) + end + end + end + + dynamic_field(:settings) + + virtual_field(:invitation_token, String.t(), derives: "validate(string, min_len=16)") + end + + def main_validator(%{invitation_token: t} = attrs) when is_binary(t) do + # Token is required to be present-and-valid AT BUILD TIME, then dropped. + if String.length(t) >= 16, do: {:ok, attrs}, else: bad_token() + end + + def main_validator(_), do: bad_token() + + defp bad_token, + do: + {:error, + [%{field: :invitation_token, action: :missing, message: "invitation_token required"}]} + end +end diff --git a/test/support/test_auth_struct.ex b/test/support/test_auth_struct.ex new file mode 100644 index 0000000..59433ff --- /dev/null +++ b/test/support/test_auth_struct.ex @@ -0,0 +1,52 @@ +defmodule GuardedStructTest.Support.TestAuthStruct do + @moduledoc """ + Shared test fixture used by `validator_derive_test.exs` and `global_test.exs`. + + Lives in `test/support/` (compiled before any test file) to avoid the + test-file-ordering / cross-test-load issue that surfaces on Elixir 1.17 + / OTP 27 on CI when one test file's inner module is referenced from + another. + """ + + use GuardedStruct + + guardedstruct do + field(:action, String.t(), derives: "validate(not_empty)") + + sub_field(:path, struct(), main_validator: {__MODULE__, :main_validator}) do + field(:role, String.t(), validator: {__MODULE__, :validator}) + field(:custom_path, String.t(), derives: "validate(not_empty)") + + sub_field(:rel, struct()) do + field(:social, String.t(), derives: "validate(not_empty)") + end + end + + field(:changed, String.t(), + derives: "validate(not_empty)", + validator: {__MODULE__, :test_validator} + ) + end + + def test_validator(:changed, value) do + if is_binary(value), + do: {:ok, :changed, value <> "::Changed"}, + else: {:error, :changed, "No, never"} + end + + def validator(:role, value) do + if is_binary(value), do: {:ok, :role, value}, else: {:error, :role, "No, never"} + end + + def validator(field, value) do + {:ok, field, value} + end + + def main_validator(value) do + if Map.get(value, :changed) == 555_555 or Map.get(value, :action) == 25 do + {:error, %{message: "there is an Error", field: :global, action: :main_validator}} + else + {:ok, value} + end + end +end diff --git a/test/support/validators.ex b/test/support/validators.ex new file mode 100644 index 0000000..3679cc9 --- /dev/null +++ b/test/support/validators.ex @@ -0,0 +1,30 @@ +# Shared validator helpers referenced by conditional-field fixtures. +# Moved here (from `test/test_helper.exs`) so they compile BEFORE the +# fixture files in `test/support/fixtures/extracted/`, which reference +# them via `validator: {ConditionalFieldValidatorTestValidators, :fn}` +# tuples. Without this, the `VerifyValidatorMFA` verifier fires during +# fixture compilation (test_helper.exs hasn't run yet at that point). + +defmodule ConditionalFieldValidatorTestValidators do + def is_string_data(field, value) do + if is_binary(value), do: {:ok, field, value}, else: {:error, field, "It is not string"} + end + + def is_map_data(field, value) do + if is_map(value), do: {:ok, field, value}, else: {:error, field, "It is not map"} + end + + def is_list_data(field, value) do + if is_list(value), do: {:ok, field, value}, else: {:error, field, "It is not list"} + end + + def is_flat_list_data(field, value) do + if is_list(value), + do: {:ok, field, List.flatten(value)}, + else: {:error, field, "It is not list"} + end + + def is_int_data(field, value) do + if is_integer(value), do: {:ok, field, value}, else: {:error, field, "It is not integer"} + end +end diff --git a/test/telemetry_test.exs b/test/telemetry_test.exs new file mode 100644 index 0000000..cb75f49 --- /dev/null +++ b/test/telemetry_test.exs @@ -0,0 +1,86 @@ +defmodule GuardedStructTest.TelemetryTest do + use ExUnit.Case, async: false + + alias GuardedStructTest.Fixtures.Telemetry.{Sample, WithBoom, WithNested} + + def __telemetry_forward__(event, measurements, metadata, %{pid: pid}) do + send(pid, {:telemetry, event, measurements, metadata}) + end + + setup do + handler_id = "test-handler-#{:erlang.unique_integer([:positive])}" + test_pid = self() + + :telemetry.attach_many( + handler_id, + [ + [:guarded_struct, :builder, :start], + [:guarded_struct, :builder, :stop], + [:guarded_struct, :builder, :exception] + ], + &__MODULE__.__telemetry_forward__/4, + %{pid: test_pid} + ) + + on_exit(fn -> :telemetry.detach(handler_id) end) + + {:ok, handler_id: handler_id} + end + + test "emits :start before the build runs" do + Sample.builder(%{name: "Alice"}) + + assert_receive {:telemetry, [:guarded_struct, :builder, :start], measurements, metadata} + assert is_integer(measurements.system_time) + assert metadata.module == Sample + end + + test "emits :stop with duration and result on success" do + Sample.builder(%{name: "Alice", age: 30}) + + assert_receive {:telemetry, [:guarded_struct, :builder, :stop], measurements, metadata} + assert is_integer(measurements.duration) + assert measurements.duration >= 0 + assert metadata.module == Sample + assert metadata.result == :ok + end + + test "emits :stop with error_count on validation failure" do + Sample.builder(%{age: -5}) + + assert_receive {:telemetry, [:guarded_struct, :builder, :stop], _, metadata} + assert metadata.result == :error + assert is_integer(metadata.error_count) + assert metadata.error_count >= 1 + end + + test "emits :exception when builder raises" do + assert_raise WithBoom.Error, fn -> + WithBoom.builder(%{}, true) + end + + # build/3 raises through, but the exception event should fire on the + # FAILED-BUILD path (when error?: true → handle_error raises) + assert_received {:telemetry, [:guarded_struct, :builder, :start], _, _} + end + + test "nested sub_field builds do NOT emit (only top-level public entry)" do + WithNested.builder(%{name: "x", auth: %{role: "admin"}}) + + # Drain all received telemetry messages and count :start events. + starts = + Stream.repeatedly(fn -> + receive do + {:telemetry, [:guarded_struct, :builder, :start], _, _} -> :start + _ -> :other + after + 50 -> :timeout + end + end) + |> Enum.take_while(&(&1 != :timeout)) + |> Enum.count(&(&1 == :start)) + + # Exactly one :start, even though sub_field(:auth) builds internally. + assert starts == 1 + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index a54a98b..f6c5493 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -4,26 +4,6 @@ defmodule User do defstruct name: "Shahryar" end -defmodule ConditionalFieldValidatorTestValidators do - def is_string_data(field, value) do - if is_binary(value), do: {:ok, field, value}, else: {:error, field, "It is not string"} - end - - def is_map_data(field, value) do - if is_map(value), do: {:ok, field, value}, else: {:error, field, "It is not map"} - end - - def is_list_data(field, value) do - if is_list(value), do: {:ok, field, value}, else: {:error, field, "It is not list"} - end - - def is_flat_list_data(field, value) do - if is_list(value), - do: {:ok, field, List.flatten(value)}, - else: {:error, field, "It is not list"} - end - - def is_int_data(field, value) do - if is_integer(value), do: {:ok, field, value}, else: {:error, field, "It is not integer"} - end -end +# `ConditionalFieldValidatorTestValidators` moved to +# `test/support/validators.ex` so fixture modules in +# `test/support/fixtures/extracted/` can compile against it. diff --git a/test/validate_test.exs b/test/validate_test.exs new file mode 100644 index 0000000..007006e --- /dev/null +++ b/test/validate_test.exs @@ -0,0 +1,213 @@ +defmodule GuardedStructTest.ValidateTest do + use ExUnit.Case, async: true + + alias GuardedStruct.Validate + alias GuardedStructTest.Fixtures.Validate.{Person, WithAuth} + + describe "Validate.run/2 — op string against value" do + test "valid string passes a derive op-string" do + assert {:ok, "alice@example.com"} = + Validate.run("validate(string, email_r)", "alice@example.com") + end + + test "sanitize + validate together" do + assert {:ok, "hi"} = Validate.run("sanitize(trim) validate(string)", " hi ") + end + + test "downcasing sanitizer works" do + assert {:ok, "alice"} = + Validate.run("sanitize(trim, downcase) validate(string)", " ALICE ") + end + + test "type mismatch returns error tuple shape" do + {:error, errs} = Validate.run("validate(integer)", "not-int") + assert Enum.any?(errs, &match?(%{field: :__value__, action: :integer}, &1)) + end + + test "min_len failure" do + {:error, errs} = Validate.run("validate(integer, min_len=0)", -5) + assert Enum.any?(errs, &(&1[:action] == :min_len)) + end + + test "max_len with strings" do + {:error, errs} = Validate.run("validate(string, max_len=3)", "hello") + assert Enum.any?(errs, &(&1[:action] == :max_len)) + end + + test "uuid format pass" do + assert {:ok, "11111111-2222-3333-4444-555555555555"} = + Validate.run("validate(uuid)", "11111111-2222-3333-4444-555555555555") + end + + test "uuid format fail" do + {:error, _} = Validate.run("validate(uuid)", "not-a-uuid") + end + + test "enum pass" do + assert {:ok, "admin"} = + Validate.run("validate(enum=String[admin::user::guest])", "admin") + end + + test "enum fail" do + {:error, _} = Validate.run("validate(enum=String[admin::user])", "invalid") + end + + test "empty derive string returns the value untouched" do + assert {:ok, "x"} = Validate.run("", "x") + end + end + + describe "Validate.field/3,4 — strict mode (default)" do + test "valid value passes a self-contained field" do + assert {:ok, "Alice"} = Validate.field(Person, :name, "Alice") + end + + test "trims and validates with derive" do + assert {:ok, "Alice"} = Validate.field(Person, :name, " Alice ") + end + + test "invalid email returns error" do + {:error, errs} = Validate.field(Person, :email, "not-an-email") + assert Enum.any?(errs, &(&1[:action] == :email_r)) + end + + test "integer type validation" do + {:error, errs} = Validate.field(Person, :age, "thirty") + assert Enum.any?(errs, &(&1[:action] == :integer)) + end + + test "enum field" do + assert {:ok, "admin"} = Validate.field(Person, :role, "admin") + {:error, _} = Validate.field(Person, :role, "owner") + end + + test "field with cross-field on: dep but no context → :dependent_keys error" do + {:error, errs} = Validate.field(Person, :parent_email, "p@x.com") + assert Enum.any?(errs, &(&1[:action] == :dependent_keys)) + end + + test "unknown field returns clear error" do + {:error, [err]} = Validate.field(Person, :nonexistent, "x") + assert err.action == :unknown_field + assert err.message =~ "is not defined" + end + + test "per-field MFA validator runs and reports its own error" do + {:error, errs} = Validate.field(Person, :nickname, "ab") + assert Enum.any?(errs, &(&1[:action] == :validator)) + end + + test "per-field MFA validator passes" do + assert {:ok, "alice"} = Validate.field(Person, :nickname, "alice") + end + end + + describe "Validate.field/4 — context for cross-field deps" do + test "providing the dep field in context makes on: resolve" do + assert {:ok, "p@x.com"} = + Validate.field(Person, :parent_email, "p@x.com", + context: %{account_type: "personal"} + ) + end + + test "context missing the dep still errors" do + {:error, errs} = + Validate.field(Person, :parent_email, "p@x.com", context: %{age: 30}) + + assert Enum.any?(errs, &(&1[:action] == :dependent_keys)) + end + + test "context resolution accepts string keys too" do + assert {:ok, _} = + Validate.field(Person, :parent_email, "p@x.com", + context: %{account_type: "business"} + ) + end + end + + describe "Validate.field/4 — :isolated mode" do + test "skips on: dep entirely" do + assert {:ok, "p@x.com"} = + Validate.field(Person, :parent_email, "p@x.com", mode: :isolated) + end + + test "still runs derive validation" do + {:error, errs} = + Validate.field(Person, :parent_email, "not-an-email", mode: :isolated) + + assert Enum.any?(errs, &(&1[:action] == :email_r)) + end + + test "still runs validator MFA" do + {:error, _} = Validate.field(Person, :nickname, "ab", mode: :isolated) + end + end + + describe "Validate.partial/2 — subset of fields" do + test "valid subset returns the validated map" do + assert {:ok, %{name: "Alice", email: "alice@example.com"}} = + Validate.partial(Person, %{name: "Alice", email: "alice@example.com"}) + end + + test "missing fields are silently skipped (no enforce_keys check)" do + assert {:ok, %{age: 30}} = Validate.partial(Person, %{age: 30}) + end + + test "errors on the fields PRESENT — not on missing ones" do + {:error, errs} = Validate.partial(Person, %{name: "OK", email: "bad"}) + assert Enum.any?(errs, &(&1[:field] == :email)) + refute Enum.any?(errs, &(&1[:field] == :name)) + end + + test "cross-field deps resolve from the same input" do + assert {:ok, _} = + Validate.partial(Person, %{ + account_type: "personal", + parent_email: "p@x.com" + }) + end + + test "cross-field dep absent from input → error" do + {:error, errs} = Validate.partial(Person, %{parent_email: "p@x.com"}) + assert Enum.any?(errs, &(&1[:action] == :dependent_keys)) + end + + test "rejects non-map input" do + {:error, _} = Validate.partial(Person, "not a map") + end + + test "accepts string-key input and atomises" do + assert {:ok, _} = + Validate.partial(Person, %{"name" => "Bob", "age" => 22}) + end + + test "aggregates multiple errors" do + {:error, errs} = + Validate.partial(Person, %{name: "x", age: -10, email: "bad"}) + + assert length(errs) >= 2 + end + + test "empty input returns empty map" do + assert {:ok, %{}} = Validate.partial(Person, %{}) + end + end + + describe "Validate against a sub_field-bearing module" do + test "field/3 against the parent's plain field" do + assert {:ok, "Bob"} = Validate.field(WithAuth, :name, "Bob") + end + + test "field/3 against a sub_field returns the sub_field's value (treated as opaque)" do + result = Validate.field(WithAuth, :auth, %{role: "admin"}) + assert match?({:ok, _}, result) + end + end + + describe "Validate.run integrates with sanitize_derive Application env" do + test "domain enum pre-evaluation still works" do + assert {:ok, %{x: 1}} = + Validate.run("validate(enum=Map[%{x: 1}::%{x: 2}])", %{x: 1}) + end + end +end diff --git a/test/validator_derive_test.exs b/test/validator_derive_test.exs index f6a3ae2..edb386b 100644 --- a/test/validator_derive_test.exs +++ b/test/validator_derive_test.exs @@ -1,75 +1,36 @@ defmodule GuardedStructTest.ValidatorDeriveTest do use ExUnit.Case, async: true - ############# (▰˘◡˘▰) ValidatorDeriveTest GuardedStructTest Data (▰˘◡˘▰) ############## - defmodule TestAuthStruct do - use GuardedStruct - - guardedstruct do - field(:action, String.t(), derive: "validate(not_empty)") - - sub_field(:path, struct(), main_validator: {TestAuthStruct, :main_validator}) do - field(:role, String.t(), validator: {TestAuthStruct, :validator}) - field(:custom_path, String.t(), derive: "validate(not_empty)") - - sub_field(:rel, struct()) do - field(:social, String.t(), derive: "validate(not_empty)") - end - end - - field(:changed, String.t(), - derive: "validate(not_empty)", - validator: {__MODULE__, :test_validator} - ) - end - - def test_validator(:changed, value) do - if is_binary(value), - do: {:ok, :changed, value <> "::Changed"}, - else: {:error, :changed, "No, never"} - end - - def validator(:role, value) do - if is_binary(value), do: {:ok, :role, value}, else: {:error, :role, "No, never"} - end - - def validator(field, value) do - {:ok, field, value} - end + # TestAuthStruct lives in test/support/ as a shared fixture — used by + # this file AND test/global_test.exs. + alias GuardedStructTest.Support.TestAuthStruct - def main_validator(value) do - if Map.get(value, :changed) == 555_555 or Map.get(value, :action) == 25 do - {:error, %{message: "there is an Error", field: :global, action: :main_validator}} - else - {:ok, value} - end - end - end + ############# (▰˘◡˘▰) ValidatorDeriveTest GuardedStructTest Data (▰˘◡˘▰) ############## defmodule TestUserAuthStruct do use GuardedStruct guardedstruct do - field(:name, String.t(), derive: "validate(not_empty)") + field(:name, String.t(), derives: "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)") + field(:github, String.t(), enforce: true, derives: "validate(url)") + field(:nickname, String.t(), derives: "validate(not_empty)") end - field(:auth_path1, struct(), struct: TestAuthStruct, derive: "validate(map, not_empty)") - field(:auth_path2, struct(), structs: TestAuthStruct, derive: "validate(list, not_empty)") + field(:auth_path1, struct(), struct: TestAuthStruct, derives: "validate(map, not_empty)") + field(:auth_path2, struct(), structs: TestAuthStruct, derives: "validate(list, not_empty)") field(:auth_path3, struct(), structs: TestAuthStruct, - derive: "validate(list, not_empty)", + derives: "validate(list, not_empty)", validator: {__MODULE__, :test_validator} ) - sub_field(:profile1, list(struct()), structs: true, derive: "validate(list, not_empty)") do - field(:github, String.t(), enforce: true, derive: "validate(url)") - field(:nickname, String.t(), derive: "validate(not_empty)") + sub_field(:profile1, list(struct()), structs: true, derives: "validate(list, not_empty)") do + field(:github, String.t(), enforce: true, derives: "validate(url)") + field(:nickname, String.t(), derives: "validate(not_empty)") end end @@ -197,13 +158,13 @@ defmodule GuardedStructTest.ValidatorDeriveTest do assert TestStructAnotherMainValidatorBuilder.builder(%{name: "mishka", title: "org"}) end - test "use builder to Sanitize - derive: sanitize(trim, lowercase)" do + test "use builder to Sanitize - derive: sanitize(trim, downcase)" do defmodule TestStructWithSanitizeDerive do use GuardedStruct guardedstruct do - field(:name, String.t(), enforce: true, derive: "sanitize(trim, upcase)") - field(:title, String.t(), derive: "sanitize(capitalize)") + field(:name, String.t(), enforce: true, derives: "sanitize(trim, upcase)") + field(:title, String.t(), derives: "sanitize(capitalize)") end end @@ -217,8 +178,8 @@ defmodule GuardedStructTest.ValidatorDeriveTest do use GuardedStruct guardedstruct do - field(:name, String.t(), enforce: true, derive: "validate(not_empty)") - field(:title, String.t(), derive: "validate(not_empty, time)") + field(:name, String.t(), enforce: true, derives: "validate(not_empty)") + field(:title, String.t(), derives: "validate(not_empty, time)") end end @@ -233,8 +194,8 @@ defmodule GuardedStructTest.ValidatorDeriveTest do use GuardedStruct guardedstruct do - field(:name, String.t(), enforce: true, derive: "validate(not_empty)") - field(:title, String.t(), derive: "validate(not_empty)") + field(:name, String.t(), enforce: true, derives: "validate(not_empty)") + field(:title, String.t(), derives: "validate(not_empty)") end end @@ -249,10 +210,10 @@ defmodule GuardedStructTest.ValidatorDeriveTest do guardedstruct do field(:name, String.t(), enforce: true, - derive: "sanitize(trim, upcase) validate(not_empty)" + derives: "sanitize(trim, upcase) validate(not_empty)" ) - field(:title, String.t(), derive: "validate(not_empty)") + field(:title, String.t(), derives: "validate(not_empty)") end end @@ -272,10 +233,10 @@ defmodule GuardedStructTest.ValidatorDeriveTest do guardedstruct do field(:name, String.t(), enforce: true, - derive: "sanitize(trim, upcase) validate(not_empty)" + derives: "sanitize(trim, upcase) validate(not_empty)" ) - field(:title, String.t(), derive: "sanitize(trim, capitalize) validate(not_empty)") + field(:title, String.t(), derives: "sanitize(trim, capitalize) validate(not_empty)") end def validator(:name, value) do @@ -310,10 +271,10 @@ defmodule GuardedStructTest.ValidatorDeriveTest do guardedstruct do field(:name, String.t(), enforce: true, - derive: "sanitize(trim, upcase) validate(not_empty)" + derives: "sanitize(trim, upcase) validate(not_empty)" ) - field(:title, String.t(), derive: "sanitize(trim, capitalize) validate(not_empty)") + field(:title, String.t(), derives: "sanitize(trim, capitalize) validate(not_empty)") end def main_validator(value) do @@ -338,11 +299,11 @@ defmodule GuardedStructTest.ValidatorDeriveTest do guardedstruct do field(:name, String.t(), enforce: true, - derive: "sanitize(trim, upcase) validate(not_empty)" + derives: "sanitize(trim, upcase) validate(not_empty)" ) - field(:title, String.t(), derive: "sanitize(trim, capitalize) validate(not_empty)") - field(:nickname, String.t(), derive: "validate(not_empty, time)") + field(:title, String.t(), derives: "sanitize(trim, capitalize) validate(not_empty)") + field(:nickname, String.t(), derives: "validate(not_empty, time)") end def validator(:name, value) do @@ -456,7 +417,7 @@ defmodule GuardedStructTest.ValidatorDeriveTest do TestAuthStruct.builder(%{changed: 1}) {:ok, - %GuardedStructTest.ValidatorDeriveTest.TestAuthStruct{ + %GuardedStructTest.Support.TestAuthStruct{ changed: "https://github.com/mishka-group::Changed", path: nil, action: nil diff --git a/test/verify_no_struct_cycles_test.exs b/test/verify_no_struct_cycles_test.exs new file mode 100644 index 0000000..059d279 --- /dev/null +++ b/test/verify_no_struct_cycles_test.exs @@ -0,0 +1,104 @@ +defmodule GuardedStructTest.VerifyNoStructCyclesTest do + use ExUnit.Case, async: true + + alias GuardedStruct.Verifiers.VerifyNoStructCycles + alias GuardedStruct.Dsl.{Field, SubField} + + defmodule InnerOK do + use GuardedStruct + + guardedstruct do + field :name, String.t() + end + end + + defmodule OuterOK do + use GuardedStruct + + guardedstruct do + field :name, String.t() + field :inner, struct(), struct: InnerOK + end + end + + defp dsl_state(module, entities) do + Spark.Dsl.Transformer.persist( + %{[:guardedstruct] => %{entities: entities, opts: []}}, + :module, + module + ) + end + + defp self_ref_state(module) do + dsl_state(module, [ + %Field{name: :name, type: nil}, + %Field{name: :child, type: nil, struct: module} + ]) + end + + test "self-referential struct: raises with the cycle message" do + state = self_ref_state(InnerOK) + + assert_raise Spark.Error.DslError, ~r/module reference cycle detected/, fn -> + VerifyNoStructCycles.verify(state) + end + end + + test "self-referential structs: (list-of) also raises" do + state = + dsl_state(InnerOK, [ + %Field{name: :children, type: nil, structs: InnerOK} + ]) + + assert_raise Spark.Error.DslError, ~r/cycle/, fn -> + VerifyNoStructCycles.verify(state) + end + end + + test "non-cyclic chain passes" do + state = + dsl_state(OuterOK, [ + %Field{name: :name, type: nil}, + %Field{name: :inner, type: nil, struct: InnerOK} + ]) + + assert :ok = VerifyNoStructCycles.verify(state) + end + + test "module without struct/structs option passes" do + state = + dsl_state(InnerOK, [ + %Field{name: :name, type: nil} + ]) + + assert :ok = VerifyNoStructCycles.verify(state) + end + + test "struct: pointing at a non-loaded module is silently allowed" do + state = + dsl_state(InnerOK, [ + %Field{name: :foo, type: nil, struct: NotAGuardedStructModule.Made.Up} + ]) + + assert :ok = VerifyNoStructCycles.verify(state) + end + + test "recursing into a sub_field still walks struct: refs" do + state = + dsl_state(InnerOK, [ + %SubField{ + name: :auth, + type: nil, + fields: [ + %Field{name: :back, type: nil, struct: InnerOK} + ], + sub_fields: [], + conditional_fields: [] + } + ]) + + assert_raise Spark.Error.DslError, ~r/cycle/, fn -> + VerifyNoStructCycles.verify(state) + end + end +end diff --git a/test/virtual_field_test.exs b/test/virtual_field_test.exs new file mode 100644 index 0000000..a1cd42c --- /dev/null +++ b/test/virtual_field_test.exs @@ -0,0 +1,54 @@ +defmodule GuardedStructTest.VirtualFieldTest do + use ExUnit.Case, async: true + + # `virtual_field` validates input but is NOT a member of the generated + # struct. The classic use case is `password_confirm`: cross-field check + # via `main_validator`, never persisted on the user struct. + + alias GuardedStructTest.Fixtures.VirtualField.{Signup, WithDynamic} + + test "virtual fields are validated and visible to main_validator" do + assert {:ok, %Signup{} = s} = + Signup.builder(%{ + email: "u@example.com", + password: "longpassword", + password_confirm: "longpassword" + }) + + # Not on the struct. + refute Map.has_key?(s, :password_confirm) + assert s.password == "longpassword" + end + + test "virtual field validation failure surfaces" do + {:error, errs} = + Signup.builder(%{ + email: "u@example.com", + password: "longpassword", + password_confirm: "differentlongpw" + }) + + assert Enum.any?(errs, &match?(%{field: :password_confirm, action: :match}, &1)) + end + + test "virtual field NOT in keys/0" do + refute :password_confirm in Signup.keys() + assert :email in Signup.keys() + assert :password in Signup.keys() + end + + test "dynamic_field defaults to %{} and accepts any map" do + {:ok, %WithDynamic{name: "x", metadata: %{}}} = WithDynamic.builder(%{name: "x"}) + + # SECURITY: dynamic_field values are LEFT AS-IS — string keys stay as + # strings, atom keys stay as atoms, mixed stays mixed. This prevents + # atom-table-exhaustion DoS via attacker-controlled keys. See SECURITY.md. + {:ok, %WithDynamic{metadata: %{:a => 1, "b" => 2}}} = + WithDynamic.builder(%{name: "x", metadata: %{"b" => 2, a: 1}}) + end + + test "dynamic_field rejects non-map" do + {:error, errs} = WithDynamic.builder(%{name: "x", metadata: "not a map"}) + assert Enum.any?(errs, &match?(%{field: :metadata, action: :map}, &1)) + end +end