A Spark DSL extension that plugs the GuardedStruct pipeline into an
Ash.Resource. Same guardedstruct do … end syntax, but the resource owns its
own struct, attributes, and actions — we contribute only the sanitize / validate
pipeline.
defmodule MyApp.User do
use Ash.Resource,
domain: MyApp.Domain,
extensions: [GuardedStruct.AshResource]
guardedstruct do
auto_wire true
field :email, :string,
derives: "sanitize(trim, downcase) validate(string, not_empty, email_r)"
end
attributes do
uuid_primary_key :id
attribute :email, :string, allow_nil?: false, public?: true
end
actions do
defaults [:read, :destroy, :create]
update :update, accept: [:email]
end
endWhat the extension adds
The resource gains:
__guarded_change__/1—(attrs) -> {:ok, transformed} | {:error, [error_map]}.__guarded_information__/0,__guarded_fields__/0,__guarded_field_meta__/1,__guarded_field_name_set__/0(compile-time-baked MapSet).
The extension does not generate defstruct, builder/2, or an Error
module — Ash owns those concerns.
Wiring GuardedStruct.AshResource.Change
Change bridges __guarded_change__/1 into Ash's changeset pipeline.
Auto-wire (recommended)
guardedstruct auto_wire: true do
field :email, :string, derives: "..."
endThe AutoWireAshChange transformer adds the change automatically. No
changes do ... end block needed.
Manual
changes do
change GuardedStruct.AshResource.Change
endAtomic mode
Change.atomic/3 returns {:atomic, sanitized_map} for plain literal inputs,
so update actions stay atomic without require_atomic? false. Implementation:
- Read
changeset.attributesandchangeset.atomics. - Detect any
Ash.Exprvalue on a key our pipeline owns (__guarded_field_name_set__/0). - Owned +
Ash.Expr→{:not_atomic, reason}— Ash falls back to imperative. - Owned + literal → run the pipeline, return
{:atomic, sanitized}. - Non-owned key → leave it alone (passes through to Ash's normal handling).
# Plain literal — stays atomic
user
|> Ash.Changeset.for_update(:update, %{email: " New@X.IO "})
|> Ash.update()
# => updates with email = "new@x.io" via a single SQL statement
# Ash.Expr on an owned field — bails to imperative
user
|> Ash.Changeset.for_update(:update_imperative) # action with require_atomic? false
|> Ash.Changeset.atomic_update(:login_count, expr(login_count + 1))
|> Ash.update()If the user passes expr(...) on an owned field via the default
(require_atomic? true) action, Ash itself raises MustBeAtomic after seeing
our {:not_atomic, _}. Add an update :update_imperative do require_atomic? false end
companion action for that path, or move the field outside guardedstruct.
Bulk operations
batch_change/3 and atomic/3 both work, so:
Ash.bulk_create/3— runs the pipeline per row.Ash.bulk_update/3withstrategy: :atomic— usesatomic/3.Ash.bulk_update/3withstrategy: :stream— useschange/3.
All three produce identical sanitized results.
Auto-map cascade
Inside the Ash extension, __guarded_change__/1 returns plain maps at every
depth for nested sub_field values — never structs. This matches Ash's :map
attribute type so output drops directly into changeset.attributes without
conversion. Implemented via a process-local flag set at the top of validate/3.
Error shape
Errors from __guarded_change__/1 follow the canonical
%{field, action, message} shape. Change converts each into an
Ash.Error.Changes.InvalidAttribute exception via add_error/2.
each=[<type-op>] is dead on typed array attributes
Ash.create/1 runs attribute casting before changes. When an attribute
is typed (e.g. {:array, :string}, {:array, :integer}), Ash casts every
element through the inner type at the attribute layer. Elements that fail
cast surface as Ash.Error.Changes.InvalidAttribute with path: [<index>],
and the bad value is stripped from changeset.attributes before
GuardedStruct.AshResource.Change runs.
So a derive like
field :allowed_origins, {:array, :string},
derives: "validate(each=[string])"never produces a GS-level error — Ash has already rejected (or removed) anything non-string before our pipeline sees it. The op compiles, looks meaningful, but contributes zero safety.
Use each=[…] for content checks Ash can't express:
derives: "validate(each=[hostname])" # ✓ runs through Ash
derives: "validate(each=[regex=^[a-z]+$])" # ✓ runs through Ash
derives: "validate(each=[slug])" # ✓ runs through Ash
derives: "validate(each=[custom=[Mod, :ok?]])" # ✓ runs through AshNot for type checks Ash already does at the attribute layer. The same
applies to other Ash typed scalars — validate(string) on a :string
attribute, validate(integer) on an :integer attribute, etc. — they're
redundant under Ash because the cast catches type mismatches first.
Action validate-before-change order
Ash's action lifecycle runs the directives declared inside the action in this order:
- attribute cast
- attribute constraint validations (
min_length,min,one_of, …) - action-level
validatedirectives (validate present([…]), customAsh.Resource.Validationmodules) - action-level + global
changedirectives (this is whereGuardedStruct.AshResource.Changelives)
That means a custom Ash.Resource.Validation you've added on the same
field sees the raw user input, not the sanitized version:
create :create do
validate {MyApp.DomainValidator, []} # runs FIRST — sees " HTTPS://X.COM "
# ...
end
guardedstruct auto_wire: true do
field :host, :string,
derives: "sanitize(trim, downcase) validate(hostname)" # runs AFTER
endDomainValidator will reject " HTTPS://X.COM " for the leading space
even though our sanitize would have cleaned it up. There is no opt-in
setting on guardedstruct to flip this — Ash treats validates as guards
that decide whether to run the action, by design.
Two ways to get sanitize-before-validate semantics:
Option A — fold the format check into GS (single layer). Drop the
custom Ash.Resource.Validation and re-express the rule as a GS validate
op so it runs in the same pass as the sanitize:
create :create do
# validate {MyApp.DomainValidator, []} ← removed
end
guardedstruct auto_wire: true do
field :host, :string,
derives: "sanitize(trim, downcase) validate(hostname)"
endOption B — keep both, accept the order. Useful when the Ash-level validation produces a helpful error message you want to keep and the field is normally clean by the time it reaches the action. In that case the GS sanitize layer mainly helps on edges the validator allows through (e.g. de-duplication of valid items inside an array).