Validations
In Ash there are two areas where validations are used:
-
Attributes Those are used to validate the input data before it is saved to the database.
-
Actions Those are used to validate the input data before an action is executed.
Attributes Validations
Validations are used when constraints are not powerful enough.
Let me show you how to use validations with an online shop product
example.
Setting Up a Fresh Ash App
The fastest way to get started with Ash is Igniter. One command scaffolds the project, pulls Ash (and whatever else you ask for), and wires up the config.
$ mix archive.install hex igniter_new
$ mix igniter.new app --install ash --yes
$ cd app
For a Phoenix application with Ash and PostgreSQL support:
$ mix igniter.new app --with phx.new --install ash,ash_postgres --yes
$ cd app
Igniter creates the App.Repo wrapper (when ash_postgres is
included), the application’s supervision tree, a sensible
.formatter.exs, and the Postgres config for every environment. You
still need to add the config :app, :ash_domains, [App.Shop] line to
config/config.exs once you define your first domain, which we do in
each chapter that uses this include.
Interactive web installer
You can also use the interactive web installer at https://ash-hq.org/#get-started to get a custom Igniter command tailored to your needs.
Manual setup alternative
If you want to see every file Igniter would create, walk through
the Ash Setup Guide. For a purely
manual route, mix new --sup app, add {:ash, "~> 3.0"} to
mix.exs, run mix deps.get, and configure .formatter.exs with
import_deps: [:ash]. Everything else in this chapter then matches
the Igniter flow.
Please create the following files:
defmodule App.Shop.Product do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
domain: App.Shop,
otp_app: :app
attributes do
uuid_primary_key :id
attribute :name, :string do
allow_nil? false
public? true
end
attribute :price, :decimal do
allow_nil? false
public? true
end
attribute :use_by_date, :date do
allow_nil? false
public? true
end
end
actions do
default_accept [:name, :price, :use_by_date]
defaults [:create, :read, :update, :destroy]
end
end
defmodule App.Shop do
use Ash.Domain, otp_app: :app
resources do
resource App.Shop.Product do
define :create_product, action: :create
define :list_products, action: :read
define :get_product_by_id, action: :read, get_by: :id
define :get_product_by_name, action: :read, get_by: :name
define :update_product, action: :update
define :destroy_product, action: :destroy
end
end
end
Custom Validations
We only add products to our shop that are not expired. For that we add
a custom validation for the use_by_date attribute. It must not be in
the past. We need to write a custom validation for that.
defmodule App.Validations.InTheFutureOrToday do
use Ash.Resource.Validation
def validate(changeset, opts, _context) do
case Ash.Changeset.fetch_argument_or_change(changeset, opts[:field]) do
:error ->
# in this case, they aren't changing the field
:ok
{:ok, value} ->
case Date.compare(Date.utc_today(), value) do
:gt ->
{:error, field: opts[:field], message: "must be in the future or today"}
_ ->
:ok
end
end
end
end
Ash 3.x passes a third context argument (a
Ash.Resource.Validation.Context) to the validate/3 callback. Old
Ash 2.x tutorials show a two-argument version; adjust accordingly if
you port code from there.
|
That validation can be used like this:
defmodule App.Shop.Product do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
domain: App.Shop,
otp_app: :app
attributes do
uuid_primary_key :id
attribute :name, :string do
allow_nil? false
public? true
end
attribute :price, :decimal do
allow_nil? false
public? true
end
attribute :use_by_date, :date do
allow_nil? false
public? true
end
end
validations do
validate {App.Validations.InTheFutureOrToday, field: :use_by_date} (1)
end
actions do
default_accept [:name, :price, :use_by_date]
defaults [:create, :read, :update, :destroy]
end
end
| 1 | Here we are using the validation App.Validations.InTheFutureOrToday. |
Let’s try it out:
$ iex -S mix
iex(1)> App.Shop.create_product!(%{
name: "Apple",
price: Decimal.new("0.1"),
use_by_date: ~D[2008-11-10]
})
** (Ash.Error.Invalid) Invalid Error
* Invalid value provided for use_by_date: must be in the future or today.
Value: nil
(ash 3.24.3) lib/ash/code_interface.ex:...: Ash.CodeInterface.create_act!/4
With custom validations you can solve pretty much any validation problem.
Action Validations
Attribute validations run every time
the attribute changes. Action validations are more selective: they run
only for the action you attach them to. They are the right tool for
cross-field checks ("sale price below regular price"), multi-field
presence checks ("at least one of these must be filled in"), and any
rule that should apply to, say, :create but not :update.
Ash ships with a handful of built-in validators you call declaratively
inside the action, plus the same Ash.Resource.Validation behaviour
you already saw in the
attribute validations chapter.
Setting Up a Fresh Ash App
The fastest way to get started with Ash is Igniter. One command scaffolds the project, pulls Ash (and whatever else you ask for), and wires up the config.
$ mix archive.install hex igniter_new
$ mix igniter.new app --install ash --yes
$ cd app
For a Phoenix application with Ash and PostgreSQL support:
$ mix igniter.new app --with phx.new --install ash,ash_postgres --yes
$ cd app
Igniter creates the App.Repo wrapper (when ash_postgres is
included), the application’s supervision tree, a sensible
.formatter.exs, and the Postgres config for every environment. You
still need to add the config :app, :ash_domains, [App.Shop] line to
config/config.exs once you define your first domain, which we do in
each chapter that uses this include.
Interactive web installer
You can also use the interactive web installer at https://ash-hq.org/#get-started to get a custom Igniter command tailored to your needs.
Manual setup alternative
If you want to see every file Igniter would create, walk through
the Ash Setup Guide. For a purely
manual route, mix new --sup app, add {:ash, "~> 3.0"} to
mix.exs, run mix deps.get, and configure .formatter.exs with
import_deps: [:ash]. Everything else in this chapter then matches
the Igniter flow.
Our playground is a Product resource with a regular :price and an
optional :sale_price:
defmodule App.Shop.Product do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
domain: App.Shop,
otp_app: :app
attributes do
uuid_primary_key :id
attribute :name, :string do
allow_nil? false
public? true
end
attribute :price, :decimal do
allow_nil? false
public? true
end
attribute :sale_price, :decimal, public?: true
attribute :category, :atom, public?: true
end
actions do
default_accept [:name, :price, :sale_price, :category]
defaults [:read, :update, :destroy]
create :create do
primary? true
end
end
end
defmodule App.Shop do
use Ash.Domain, otp_app: :app
resources do
resource App.Shop.Product do
define :create_product, action: :create
define :update_product, action: :update
end
end
end
Validate only on one action with validate compare/2
compare/2 takes one field and compares it to another field or a
literal. We want the sale price, when it is present, to be strictly
less than the regular price. Add the validation inside the create
action:
create :create do
primary? true
validate compare(:sale_price, less_than: :price),
where: [present(:sale_price)], (1)
message: "must be less than price" (2)
end
| 1 | where: [present(:sale_price)] skips the check when the user
did not supply a sale price. Without it, every create without a sale
price would fail because nil < price is not true. |
| 2 | Custom message; without it, Ash’s default is
"must be less than %{less_than}". |
Try a valid and an invalid create:
$ iex -S mix
iex(1)> App.Shop.create_product!(%{
name: "Apple",
price: Decimal.new("1.00"),
sale_price: Decimal.new("0.80"),
category: :food
})
%App.Shop.Product{
id: "35757c5a-c7be-4ab9-91b8-8318c1e1d084",
name: "Apple",
price: Decimal.new("1.00"),
sale_price: Decimal.new("0.80"),
category: :food,
__meta__: #Ecto.Schema.Metadata<:loaded>
}
iex(2)> App.Shop.create_product(%{
name: "Bad",
price: Decimal.new("1.00"),
sale_price: Decimal.new("2.00"), (1)
category: :food
})
{:error,
%Ash.Error.Invalid{
errors: [
%Ash.Error.Changes.InvalidAttribute{
field: :sale_price,
message: "must be less than price",
value: Decimal.new("2.00"),
...
}
]
}}
| 1 | sale_price greater than price triggers the rejection. |
compare/2 accepts greater_than, less_than,
greater_than_or_equal_to, less_than_or_equal_to, equal_to,
not_equal_to. Each can take a literal value or another field name.
one_of for whitelists
Use one_of when an attribute’s valid values are a fixed set and
you don’t want to model them as a separate resource:
create :create do
primary? true
validate compare(:sale_price, less_than: :price),
where: [present(:sale_price)],
message: "must be less than price"
validate one_of(:category, [:food, :toy, :tool]), (1)
where: [present(:category)]
end
| 1 | If a :category is supplied, it must be one of those three atoms.
Atoms and strings are not interchangeable here: "food" would fail. |
Rejected input:
iex(3)> App.Shop.create_product(%{
name: "Bad",
price: Decimal.new("1.00"),
category: :clothing
})
{:error,
%Ash.Error.Invalid{
errors: [
%Ash.Error.Changes.InvalidAttribute{
field: :category,
message: "expected one of %\{values}",
value: :clothing,
vars: [values: "food, toy, tool"],
...
}
]
}}
present with at_least
present/2 counts how many of a list of fields have a value. The
at_least / at_most / exactly options let you require "at least
one contact method" or "exactly one of these" without writing custom
code:
validations do (1)
validate present([:price, :sale_price], at_least: 1),
on: [:create, :update] (2)
end
| 1 | validations do … end is the resource-wide block. Anything in
here runs for every action listed in on:. |
| 2 | on: [:create, :update] limits the scope. Without it, the
validation runs on :destroy too, which is usually pointless. |
Trigger it with a create that has neither price:
iex(4)> App.Shop.create_product(%{name: "Nothing"})
{:error,
%Ash.Error.Invalid{
errors: [
%Ash.Error.Changes.Required{field: :price, ...}, (1)
%Ash.Error.Changes.InvalidChanges{
fields: [:price, :sale_price],
message: "at least %\{at_least} of %\{keys} must be present",
vars: [
fields: [:price, :sale_price],
keys: "price,sale_price",
attributes: [:price, :sale_price],
at_least: 1
],
...
}
]
}}
| 1 | :price is allow_nil? false on the attribute, so you also get
the Required error. The two are complementary: allow_nil? fails
as soon as the single field is missing, present([…], at_least:)
reports the group-level rule. |
Built-ins worth knowing
The full list is at https://hexdocs.pm/ash/Ash.Resource.Validation.Builtins.html. The ones you will reach for repeatedly:
-
compare/2— cross-field numeric / date comparisons. -
one_of/2— whitelist. -
present/2— presence withat_least/at_most/exactly. -
absent/2— the opposite: "none of these may be set". -
match/2— regex. -
string_length/2— min/max length. -
attribute_equals/2andattribute_does_not_equal/2— combined withwhere:these are handy guards ("only allow:updateif:lockedis false"). -
confirm/2— two fields must match (classic for password confirmations).
Every built-in accepts where:, message:, and on: the same way.
Custom validations on an action
Reach for a full Ash.Resource.Validation module when the rule needs
Elixir logic the built-ins can’t express. The shape is the same as
for attribute validations, but you
attach it to the action:
defmodule App.Validations.NotOnWeekend do
use Ash.Resource.Validation
@impl true
def init(opts), do: {:ok, opts}
@impl true
def validate(_changeset, _opts, _context) do
case Date.day_of_week(Date.utc_today()) do
d when d >= 6 -> {:error, message: "not on weekends"}
_ -> :ok
end
end
end
create :create do
primary? true
validate {App.Validations.NotOnWeekend, []} (1)
# …other validations…
end
| 1 | The [] is the options list that init/1 receives. An empty
list is fine when the validation has no configuration to read. |
Run it on a Saturday:
iex(5)> App.Shop.create_product(%{name: "Apple", price: Decimal.new("1.00")})
{:error,
%Ash.Error.Invalid{
errors: [
%Ash.Error.Changes.InvalidChanges{
fields: [],
message: "not on weekends",
vars: [],
...
}
]
}}
Ash.Resource.Validation’s `validate/3 callback is the one
you implement. The third argument is an Ash.Resource.Validation.Context
struct (Ash 3.x signature). Old Ash 2.x tutorials show a validate/2
signature; that will no longer compile.
|