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
endNow 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 itUnder 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
Errorexception 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 structThis 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 — atomic-safe by default
GuardedStruct.AshResource.Change implements atomic/3 and returns
{:atomic, sanitized_map} to Ash, so update / destroy actions stay
atomic without setting require_atomic? false. The pipeline
(sanitize / validate / derive / auto: MFAs / Derive.Extension)
runs in Elixir on the plain literal inputs, and the resulting UPDATE
is a single SQL statement.
The only case that falls back to imperative mode is when the caller
passes an Ash.Expr via Ash.Changeset.atomic_update/3 — atomic/3
returns {:not_atomic, reason} since we can't sanitize a value we
won't see until the SQL evaluates.
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— theAsh.Resource.Changemodule that bridges__guarded_change__/1into 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]