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.

elixir
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
end

Your 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.

elixir
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.

elixir
changes do
  change GuardedStruct.AshResource.Change
end

With 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.

elixir
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.

elixir
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.

elixir
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, on and domain work as in a standalone module. A domain: rule reads the input of the action.
  • sub_field and conditional_field take their options before the block, as in sub_field :profile, :map, authorized_fields: true do ... end, or as setters at the top of it, as in authorized_fields true.
  • The @derives attribute is for standalone modules. In a resource, write derives: on the field.
  • A derive such as validate(string) on a :string attribute adds little, because Ash already rejected other types.

Next steps