Use GuardedStruct in an Ash resource
v0.1.4
The GuardedStruct.AshResource extension lets an Ash resource use the same guardedstruct block. Every create and update action then sanitizes and validates its input before the data layer sees it, and failures become normal Ash errors. This guide adds the extension to a resource, shows the wiring options, and lists what differs from a standalone module.
Before you begin
- An Ash 3 project with a resource and a domain. GuardedStruct does not depend on Ash, so your project supplies it.
- The derive syntax from Get started. A resource uses the same strings.
Add the extension
Add GuardedStruct.AshResource to the resource's extensions and write the guardedstruct block. The block sits beside attributes and actions. Set auto_wire true so the extension adds its change to the resource for you. You can also write the option before the block, as guardedstruct auto_wire: true do ... end.
defmodule MyApp.User do
use Ash.Resource,
domain: MyApp.Accounts,
extensions: [GuardedStruct.AshResource]
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]
end
end
attributes do
uuid_primary_key :id
attribute :email, :string, allow_nil?: false, public?: true
attribute :nickname, :string, public?: true
end
endYour data layer goes in the resource as usual. Ash owns the struct, the actions and the errors, so the extension does not generate defstruct, builder/1 or an Error module. The type you give a field is not used to build anything.
MyApp.User
|> Ash.Changeset.for_create(:create, %{email: " [email protected] "})
|> Ash.create()
# {:ok, %MyApp.User{email: "[email protected]", ...}}Wire the change by hand
Without auto_wire the extension adds nothing to your actions. Add the change yourself, in the resource's changes section.
changes do
change GuardedStruct.AshResource.Change
endWith auto_wire true Ash lists the same change in Ash.Resource.Info.changes/1, for the create and update actions.
Read the errors
The change turns each error into an Ash.Error.Changes.InvalidAttribute on the changeset. The GuardedStruct action goes into the error's vars as validation. The changeset is invalid, so Ash.create/1 returns an Ash.Error.Invalid.
changeset = Ash.Changeset.for_create(MyApp.User, :create, %{email: "not-an-email"})
changeset.valid?
# false
Enum.map(changeset.errors, &{&1.field, &1.message, &1.vars})
# [{:email, "Incorrect email in the email field.", [validation: :email_r]}]Ash casts each input to its attribute type before any change runs. A value that fails the cast gets Ash's own is invalid error first, and the change then runs without that value. On update and destroy actions the change drops required_fields errors, because an update sends only some attributes and Ash enforces allow_nil? itself.
Call the pipeline directly
The extension adds __guarded_change__/1 to the resource. It runs the same steps as builder/1 and returns a plain map, not a struct.
MyApp.User.__guarded_change__(%{email: " [email protected] ", nickname: " b "})
# {:ok, %{email: "[email protected]", nickname: "b"}}
MyApp.User.__guarded_change__(%{email: "bad"})
# {:error, [%{field: :email, action: :email_r, message: "Incorrect email in the email field."}]}The resource also gains __guarded_information__/0, __guarded_fields__/0, __guarded_field_meta__/1 and __guarded_field_name_set__/0. GuardedStruct.AshResource.Info wraps them: fields/1, fields_meta/1, field/2, field?/2, information/1 and validate/3.
Nest values as maps
A sub_field inside a resource is an embedded value, not an Ash relationship, and it takes options before its block, as sub_field :profile, :map, authorized_fields: true do ... end. The generated module is a GuardedStruct module, not an Ash resource. Through the Ash change every nested value comes back as a plain map at every depth, so it fits a :map attribute.
guardedstruct do
auto_wire true
field :email, :string, derives: "validate(email_r)"
sub_field :profile, :map do
field :name, :string, derives: "sanitize(trim)"
sub_field :address, :map do
field :city, :string, derives: "sanitize(trim)"
end
end
end
# __guarded_change__(%{email: "[email protected]", profile: %{name: "Z", address: %{city: "Paris"}}})
# {:ok, %{email: "[email protected]", profile: %{name: "Z", address: %{city: "Paris"}}}}Update atomically and in bulk
The change implements change/3, atomic/3 and batch_change/3. For an update, atomic/3 runs the pipeline in Elixir on the plain values in the changeset and returns {:atomic, map} with the sanitized values, so the action stays atomic and needs no require_atomic? false. Ash.bulk_create/3 and Ash.bulk_update/3 run the pipeline for every row.
When you set a guarded field with an Ash.Expr through Ash.Changeset.atomic_update/3, the value is unknown until the database evaluates it. atomic/3 then returns {:not_atomic, reason}, and Ash cannot run that update atomically. Give the action that takes expressions require_atomic? false, or keep the field out of the guardedstruct block.
Know the differences
auto,from,onanddomainwork as in a standalone module. Adomain:rule reads the input of the action.sub_fieldandconditional_fieldtake their options before the block, as insub_field :profile, :map, authorized_fields: true do ... end, or as setters at the top of it, as inauthorized_fields true.- The
@derivesattribute is for standalone modules. In a resource, writederives:on the field. - A derive such as
validate(string)on a:stringattribute adds little, because Ash already rejected other types.
Next steps
- DSL reference:
auto_wireand every other section option. - Handle and translate errors: the error maps behind the Ash errors.
- API reference:
AshResource.ChangeandAshResource.Info. - Page fields: the length and trimming limits that Mishka CMS sets on a page with a
guardedstructblock.