GuardedStruct.AshResource

Copy Markdown View Source

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
end

Now 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 it

Under 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 Error exception 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 struct

This 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/3atomic/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

Example: introspect a resource's guarded fields

GuardedStruct.AshResource.Info.fields(MyApp.User)
# => [:email, :nickname, :preferences]

guardedstruct

Nested DSLs

Options

NameTypeDefaultDocs
enforcebooleanfalse
opaquebooleanfalse
moduleany
errorbooleanfalse
authorized_fieldsbooleanfalse
main_validator{atom, atom}
validate_deriveatom | list(atom)
sanitize_deriveatom | list(atom)
jsonbooleanfalseWhen true, derives a JSON encoder. Uses Jason.Encoder if :jason is in the user's deps; otherwise falls back to the built-in JSON.Encoder on Elixir 1.18+. No-op if neither is available.
auto_wirebooleanfalseOnly effective inside the GuardedStruct.AshResource extension. When true, injects GuardedStruct.AshResource.Change into the resource's top-level changes section so every :create and :update action automatically runs the GuardedStruct pipeline. Equivalent to writing changes do change GuardedStruct.AshResource.Change end by hand. No-op outside the Ash extension.

guardedstruct.field

field name, type

Arguments

NameTypeDefaultDocs
nameany
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
structatom
structsatom | boolean
hintString.t
priorityboolean

guardedstruct.virtual_field

virtual_field name, type

Arguments

NameTypeDefaultDocs
nameany
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
hintString.t

guardedstruct.dynamic_field

dynamic_field name

Arguments

NameTypeDefaultDocs
nameany

Options

NameTypeDefaultDocs
typeany{:map, [], []}
enforceboolean
defaultany{:%{}, [], []}
derivesString.t"validate(map)"
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
hintString.t

guardedstruct.sub_field

sub_field name, type

Nested DSLs

Arguments

NameTypeDefaultDocs
nameatom
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
structatom
structsatom | boolean
hintString.t
priorityboolean
errorboolean
authorized_fieldsboolean
main_validator{atom, atom}

guardedstruct.sub_field.conditional_field

conditional_field name, type

Nested DSLs

Arguments

NameTypeDefaultDocs
nameatom
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
structatom
structsatom | boolean
hintString.t
priorityboolean

guardedstruct.sub_field.conditional_field.field

field name, type

Arguments

NameTypeDefaultDocs
nameany
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
structatom
structsatom | boolean
hintString.t
priorityboolean

guardedstruct.sub_field.conditional_field.sub_field

sub_field name, type

Nested DSLs

Arguments

NameTypeDefaultDocs
nameatom
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
structatom
structsatom | boolean
hintString.t
priorityboolean
errorboolean
authorized_fieldsboolean
main_validator{atom, atom}

guardedstruct.sub_field.conditional_field.sub_field.field

field name, type

Arguments

NameTypeDefaultDocs
nameany
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
structatom
structsatom | boolean
hintString.t
priorityboolean

guardedstruct.sub_field.sub_field

sub_field name, type

Nested DSLs

Arguments

NameTypeDefaultDocs
nameatom
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
structatom
structsatom | boolean
hintString.t
priorityboolean
errorboolean
authorized_fieldsboolean
main_validator{atom, atom}

guardedstruct.sub_field.sub_field.field

field name, type

Arguments

NameTypeDefaultDocs
nameany
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
structatom
structsatom | boolean
hintString.t
priorityboolean

guardedstruct.sub_field.field

field name, type

Arguments

NameTypeDefaultDocs
nameany
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
structatom
structsatom | boolean
hintString.t
priorityboolean

guardedstruct.conditional_field

conditional_field name, type

Nested DSLs

Arguments

NameTypeDefaultDocs
nameatom
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
structatom
structsatom | boolean
hintString.t
priorityboolean

guardedstruct.conditional_field.sub_field

sub_field name, type

Nested DSLs

Arguments

NameTypeDefaultDocs
nameatom
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
structatom
structsatom | boolean
hintString.t
priorityboolean
errorboolean
authorized_fieldsboolean
main_validator{atom, atom}

guardedstruct.conditional_field.sub_field.conditional_field

conditional_field name, type

Nested DSLs

Arguments

NameTypeDefaultDocs
nameatom
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
structatom
structsatom | boolean
hintString.t
priorityboolean

guardedstruct.conditional_field.sub_field.conditional_field.field

field name, type

Arguments

NameTypeDefaultDocs
nameany
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
structatom
structsatom | boolean
hintString.t
priorityboolean

guardedstruct.conditional_field.sub_field.conditional_field.sub_field

sub_field name, type

Nested DSLs

Arguments

NameTypeDefaultDocs
nameatom
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
structatom
structsatom | boolean
hintString.t
priorityboolean
errorboolean
authorized_fieldsboolean
main_validator{atom, atom}

guardedstruct.conditional_field.sub_field.conditional_field.sub_field.field

field name, type

Arguments

NameTypeDefaultDocs
nameany
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
structatom
structsatom | boolean
hintString.t
priorityboolean

guardedstruct.conditional_field.sub_field.sub_field

sub_field name, type

Nested DSLs

Arguments

NameTypeDefaultDocs
nameatom
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
structatom
structsatom | boolean
hintString.t
priorityboolean
errorboolean
authorized_fieldsboolean
main_validator{atom, atom}

guardedstruct.conditional_field.sub_field.sub_field.field

field name, type

Arguments

NameTypeDefaultDocs
nameany
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
structatom
structsatom | boolean
hintString.t
priorityboolean

guardedstruct.conditional_field.sub_field.field

field name, type

Arguments

NameTypeDefaultDocs
nameany
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
structatom
structsatom | boolean
hintString.t
priorityboolean

guardedstruct.conditional_field.conditional_field

conditional_field name, type

Nested DSLs

Arguments

NameTypeDefaultDocs
nameatom
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
structatom
structsatom | boolean
hintString.t
priorityboolean

guardedstruct.conditional_field.conditional_field.field

field name, type

Arguments

NameTypeDefaultDocs
nameany
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
structatom
structsatom | boolean
hintString.t
priorityboolean

guardedstruct.conditional_field.conditional_field.sub_field

sub_field name, type

Nested DSLs

Arguments

NameTypeDefaultDocs
nameatom
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
structatom
structsatom | boolean
hintString.t
priorityboolean
errorboolean
authorized_fieldsboolean
main_validator{atom, atom}

guardedstruct.conditional_field.conditional_field.sub_field.field

field name, type

Arguments

NameTypeDefaultDocs
nameany
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
structatom
structsatom | boolean
hintString.t
priorityboolean

guardedstruct.conditional_field.field

field name, type

Arguments

NameTypeDefaultDocs
nameany
typeany

Options

NameTypeDefaultDocs
enforceboolean
defaultany
derivesString.t
deriveString.t
validator{atom, atom}
auto{atom, atom} | {atom, atom, any}
fromString.t
onString.t
domainString.t
structatom
structsatom | boolean
hintString.t
priorityboolean