# Ash integration — `GuardedStruct.AshResource`
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.
```elixir
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
end
```
## What 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)
```elixir
guardedstruct auto_wire: true do
field :email, :string, derives: "..."
end
```
The `AutoWireAshChange` transformer adds the change automatically. No
`changes do ... end` block needed.
### Manual
```elixir
changes do
change GuardedStruct.AshResource.Change
end
```
## Atomic mode
`Change.atomic/3` returns `{:atomic, sanitized_map}` for plain literal inputs,
so update actions stay atomic without `require_atomic? false`. Implementation:
1. Read `changeset.attributes` and `changeset.atomics`.
2. Detect any `Ash.Expr` value on a key our pipeline owns
(`__guarded_field_name_set__/0`).
3. **Owned + `Ash.Expr`** → `{:not_atomic, reason}` — Ash falls back to imperative.
4. **Owned + literal** → run the pipeline, return `{:atomic, sanitized}`.
5. **Non-owned key** → leave it alone (passes through to Ash's normal handling).
```elixir
# 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/3` with `strategy: :atomic` — uses `atomic/3`.
* `Ash.bulk_update/3` with `strategy: :stream` — uses `change/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`.