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.

Attribute Constraints

Let me show you how to use attribute constraints 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. We start from a Product resource without any constraints:

lib/app/shop/product.ex
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
lib/app/shop.ex
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:

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 ! version of the create function. Because it is easier to show the error messages (it takes up less real estate).

But while programming I prefer to use the non-! version of the create function:

iex(6)> App.Shop.create_product(%{price: Decimal.new("10"),
                                  stock_quantity: 3})
{:error,
 %Ash.Error.Invalid{
   errors: [
     %Ash.Error.Changes.Required{
       field: :name,
       type: :attribute,
       resource: App.Shop.Product,
       ...
     }
   ],
   ...
 }}

allow_empty?

Sometimes we want to allow empty strings.

lib/app/shop/product.ex
    [...]
    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.

lib/app/shop/product.ex
  [...]
  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:

lib/app/shop/product.ex
    [...]
    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:

lib/app/shop/product.ex
    [...]
    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 "Banana " would fail.

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:

lib/app/shop/product.ex
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
lib/app/shop.ex
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:

lib/app/shop/product.ex
  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 change block runs, so your changes never see bad data.

  • You would otherwise have to write the same check as the first line of every change function — 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.