Constraints
Contraints can be used to validate input data. This can be a bit misleading for newbies because in addition validations are a thing too. Contraints work for attributes and arguments.
Different datatypes have different constraints.
You can use :allow_empty? for string but not for integer.
|
Need more information about contraints? Have a look at the official Ash documentation at Constraints.
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. We start from a Product resource
without any constraints:
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, public?: true
attribute :description, :string, public?: true
attribute :price, :decimal, public?: true
attribute :stock_quantity, :integer, public?: true
end
actions do
default_accept [:name, :description, :price, :stock_quantity]
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
allow_nil? (Required Attributes)
The simplest validation is a check that an attribute is not nil.
This is done with the allow_nil?/1 option. We want to be sure that
name, price and stock_quantity are always set. Please adjust the
attributes block in lib/app/shop/product.ex:
[...]
attributes do
uuid_primary_key :id
attribute :name, :string do
allow_nil? false
public? true
end
attribute :description, :string, public?: true
attribute :price, :decimal do
allow_nil? false
public? true
end
attribute :stock_quantity, :integer do
allow_nil? false
public? true
end
end
[...]
Now let’s try to create a product without a name:
$ iex -S mix
iex(1)> App.Shop.create_product!(%{price: Decimal.new("10"),
stock_quantity: 3})
** (Ash.Error.Invalid) Invalid Error
* attribute name is required
(ash 3.24.3) lib/ash/code_interface.ex:...: Ash.CodeInterface.create_act!/4
Perfect. The validation works.
|
In a written tutorial I prefer to use the But while programming I prefer to use the non-! version of the create function:
|
allow_empty?
Sometimes we want to allow empty strings.
[...]
attribute :description, :string do
allow_nil? false (1)
public? true
constraints allow_empty?: true (2)
end
[...]
| 1 | The description attribute is not allowed to be nil. |
| 2 | But it is allowed to be an empty string. |
Now let’s try to create a product. First with a nil description and then with an empty description:
$ iex -S mix
iex(1)> App.Shop.create_product!(%{name: "Banana", price: Decimal.new("0.1"), stock_quantity: 5})
** (Ash.Error.Invalid) Invalid Error
* attribute description is required
(ash 3.24.3) lib/ash/code_interface.ex:...: Ash.CodeInterface.create_act!/4
iex(2)> App.Shop.create_product!(%{name: "Banana", description: "", price: Decimal.new("0.1"), stock_quantity: 5})
%App.Shop.Product{
id: "8334b52f-1ba0-4adb-b790-705f8e9e1291",
name: "Banana",
description: "",
price: Decimal.new("0.1"),
stock_quantity: 5,
__meta__: #Ecto.Schema.Metadata<:loaded>
}
Perfect. The validation works.
min, max, min_length and max_length
Sometimes we want to make sure that an attribute has a minimal or
maximal length. Let’s add a minimal length of 3 characters and a maximal length
of 255 characters for the name attribute. And while we are at it
let us add a maximum of 512 characters for the description attribute.
But what about the numbers? We want to make sure that the price is
always greater than 0 and the stock_quantity is always greater than
or equal to 0. For that we can use the constraints option with
the min and max keys.
[...]
attributes do
uuid_primary_key :id
attribute :name, :string do
allow_nil? false
public? true
constraints min_length: 3, max_length: 255
end
attribute :description, :string do
public? true
constraints max_length: 512
end
attribute :price, :decimal do
allow_nil? false
public? true
constraints min: Decimal.new("0.01")
end
attribute :stock_quantity, :integer do
allow_nil? false
public? true
constraints min: 0
end
end
[...]
Testing the validation:
iex(1)> App.Shop.create_product!(%{name: "Y",
price: Decimal.new("0"),
stock_quantity: -1})
** (Ash.Error.Invalid) Invalid Error
* Invalid value provided for stock_quantity: must be greater than or equal to 0. (1)
Value: -1
* Invalid value provided for price: must be greater than or equal to 0.01. (2)
Value: #Decimal(0)
* Invalid value provided for name: length must be greater than or equal to 3. (3)
Value: "Y"
| 1 | stock_quantity has to be greater than or equal to 0. |
| 2 | price has to be greater than or equal to 0.01. |
| 3 | name has to be at least 3 characters long. |
Pattern Matching
Assuming that we only want to have characters and the - in the name of
a product we can use match in the constraints to check if the name matches
a regular expression. Let’s add this to the name attribute:
[...]
attribute :name, :string do
allow_nil? false
public? true
constraints min_length: 3,
max_length: 255,
match: ~r/^[a-zA-Z-]*$/
end
[...]
Let’s test it:
$ iex -S mix
iex(1)> App.Shop.create_product!(%{name: "Banana2023",
description: "",
price: Decimal.new("0.1"),
stock_quantity: 20}) (1)
** (Ash.Error.Invalid) Invalid Error
* Invalid value provided for name: must match the pattern "~r/^[a-zA-Z-]*$/".
Value: "Banana2023"
iex(2)> App.Shop.create_product!(%{name: "Banana",
price: Decimal.new("0.1"),
stock_quantity: 20})
%App.Shop.Product{
id: "c29444dc-7da2-4849-b251-b851a745112a",
name: "Banana",
description: nil,
price: Decimal.new("0.1"),
stock_quantity: 20,
__meta__: #Ecto.Schema.Metadata<:loaded>
}
| 1 | The name "Banana2023" does not match the pattern. |
Trim
What happens if you add a couple of spaces at the end of a name? Let’s try it:
$ iex -S mix
iex(1)> App.Shop.create_product!(%{name: "Banana ",
price: Decimal.new("0.1"),
stock_quantity: 12})
%App.Shop.Product{
id: "5b9b53f4-6109-4757-a8b7-9aaf1acda1f3",
name: "Banana",
...
}
Those spaces get trimmed automatically. This is the default behavior and normally what you want because humans and autofill browsers sometimes add spaces at the end of a form field on a webpage.
In case you want to keep those spaces you can use trim?: false:
[...]
attribute :name, :string do
allow_nil? false
public? true
constraints min_length: 3,
max_length: 255,
match: ~r/^[a-zA-Z- ]*$/,
trim?: false
end
[...]
|
I did sneak in a space in the regular expression. Because otherwise the
validation for |
iex(4)> App.Shop.create_product!(%{name: "Banana ",
price: Decimal.new("0.1"),
stock_quantity: 12})
%App.Shop.Product{
id: "b1793ac1-4bfb-4f4f-9b3a-42a64c30378b",
name: "Banana ",
description: nil,
price: Decimal.new("0.1"),
stock_quantity: 12,
__meta__: #Ecto.Schema.Metadata<:loaded>
}
Argument Constraints
Attribute constraints validate data that
lives on a record. Argument constraints validate input that only exists
during an action: quantities passed to a :restock, reasons on a
:cancel, email addresses on a :invite. The reader pays the exact
same DSL tax as attribute constraints: constraints min: …, match: …
on the argument, and Ash refuses the call before your change logic runs.
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.
Start from a minimal Product with a stock_quantity column:
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 :stock_quantity, :integer do
public? true
default 0
end
end
actions do
default_accept [:name, :price]
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 :restock_product, action: :restock (1)
end
end
end
| 1 | We add the code interface line before the action, even though
the action does not exist yet. Ash will fail compilation if the
:restock action is missing, so we will add it next. |
Adding a custom action with constrained arguments
Extend the actions block with a :restock update action. The
action takes three arguments, each with its own constraints:
actions do
default_accept [:name, :price]
defaults [:create, :read, :update, :destroy]
update :restock do
accept [] (1)
argument :quantity, :integer do
allow_nil? false
constraints min: 1, max: 1_000 (2)
end
argument :reason, :string do
allow_nil? false
constraints min_length: 3,
max_length: 80,
match: ~r/^[A-Za-z0-9 .,\-]+$/ (3)
end
argument :source, :atom do
allow_nil? false
constraints one_of: [:supplier, :return, :correction] (4)
end
change atomic_update(
:stock_quantity,
expr(stock_quantity + ^arg(:quantity))
) (5)
end
end
| 1 | accept [] on the action head: the caller may not set
attributes directly, they can only pass the three arguments we
declare below. |
| 2 | min / max work the same for integer arguments as they do for
integer attributes. |
| 3 | min_length / max_length / match are the same string
constraints as on attributes. |
| 4 | one_of: […] restricts an atom argument to a known set of
values. Ash compares the accepted atoms as they are — "supplier"
(a string) would fail this check even though :supplier passes. |
| 5 | atomic_update refers to the argument via ^arg(:quantity).
The changeset still benefits from the constraint check before
this expression runs. |
Happy path
Compile, then call the new action:
$ iex -S mix
iex(1)> product = App.Shop.create_product!(%{name: "Banana", price: Decimal.new("0.10")})
%App.Shop.Product{
id: "0c067e94-27d9-4200-8162-b214e0a4e468",
name: "Banana",
price: Decimal.new("0.10"),
stock_quantity: 0,
__meta__: #Ecto.Schema.Metadata<:loaded>
}
iex(2)> App.Shop.restock_product!(product, %{
quantity: 10,
reason: "weekly delivery",
source: :supplier
})
%App.Shop.Product{
id: "0c067e94-27d9-4200-8162-b214e0a4e468",
name: "Banana",
price: Decimal.new("0.10"),
stock_quantity: 10, (1)
__meta__: #Ecto.Schema.Metadata<:loaded>
}
| 1 | The atomic_update moved the stock by the argument’s value. |
Rejected inputs
Every constraint shows up as an Ash.Error.Changes.InvalidArgument
inside Ash.Error.Invalid.errors. Send a request that breaks all
three at once and Ash returns all three errors:
iex(3)> App.Shop.restock_product(product, %{
quantity: 0,
reason: "x",
source: :theft
})
{:error,
%Ash.Error.Invalid{
errors: [
%Ash.Error.Changes.InvalidArgument{
field: :reason,
message: "length must be greater than or equal to %\{min}",
value: "x",
vars: [min: 3],
...
},
%Ash.Error.Changes.InvalidArgument{
field: :source,
message: "atom must be one of %\{atom_list}, got: %\{value}",
value: :theft,
vars: [atom_list: "supplier, return, correction", value: :theft],
...
},
%Ash.Error.Changes.InvalidArgument{
field: :quantity,
message: "must be greater than or equal to %\{min}",
value: 0,
vars: [min: 1],
...
}
]
}}
The message template interpolates the vars — rendering UI
(AshPhoenix.Form and friends) substitutes %{min} with the value
from vars, so the user sees "length must be greater than or equal
to 3". The raw template is what lives on the error struct.
|
The bang variant raises with the rendered message instead:
iex(4)> App.Shop.restock_product!(product, %{
quantity: 5,
reason: "Delivery!", (1)
source: :supplier
})
** (Ash.Error.Invalid)
Invalid Error
* Invalid value provided for reason: must match the pattern "~r/^[A-Za-z0-9 .,\-]+$/".
"Delivery!"
| 1 | The exclamation mark is not in our regex, so the string is rejected. |
When argument constraints are the right tool
Use argument constraints when:
-
The value is input to an action but is never stored on the record (a confirmation password, a "reason" field, a bulk-action filter argument).
-
You want Ash to reject the call before any
changeblock runs, so your changes never see bad data. -
You would otherwise have to write the same check as the first line of every
changefunction — constraints are declarative and show up in generated UI helpers automatically.
For everything that lives on the record after the action, reach for attribute constraints instead.