Ash’s Structure
As we explained in the introduction minimally as requires 3 parts: Resource, Registry and API.
We will make these now starting with a ‘user’ resource:
mkdir -p lib/support/resources
touch lib/support/resources/user.ex
touch lib/support/registry.ex
touch lib/support/ash_api.ex
In the following sections we will the contents and usage of each file and extend their functionality as we go.
User Resource
A resource minimally needs actions (things to do) and attributes (information associated with the resource)
# lib/support/resources/user.ex
defmodule Support.User do
use Ash.Resource
actions do
defaults [:create, :read, :update, :destroy]
end
# Attributes are the simple pieces of data that exist on your resource
attributes do
# Add an autogenerated UUID primary key called `:id`.
uuid_primary_key :id
attribute :email, :string
attribute :first_name, :string
attribute :middle_name, :string
attribute :last_name, :string
attribute :admin, :boolean
attribute :account_type, :atom # will limit to :employee, :customer
attribute :department_name, :string
create_timestamp :inserted_at
update_timestamp :updated_at
end
end
The current actions
(:create, :read, :update, :destroy) enable the basic CRUD operations used by the data layer. We will expand and refine the actions soon.
The attributes
are of course the associated information needed by the resource. Notice there we are using several data types (:string, :boolean, :atom and :dates). We will refine these attributes shortly and clearly define limits and contraints and validations on these data. See the types docs for the full list available.
Since we are new to Ash, let’s be sure we haven’t made any mistakes, let’s start phoenix and be sure there are no compile errors:
iex -S mix phx.server
Ash Registry
We need to register our resources with Ash (and any resource extensions - we will do this later). We can do this with a registry file:
# lib/support/registry.ex
defmodule Support.Registry do
use Ash.Registry
entries do
entry Support.User
end
end
Notice that the resource is registered in the entries
.
Again, let’s test all is still well:
iex -S mix phx.server
Ash API
This file defines defines what APIs are associated with which resources. We will build this out as we go too.
# lib/support/ash_api.ex
defmodule Support.AshApi do
use Ash.Api
resources do
registry Support.Registry
end
end
Again, let’s test all is still well:
iex -S mix phx.server
Usage
Let’s see if what we built actually works.
Let’s be sure we can create a ‘user’.
To do this we will need to:
- build a change-set for the create action (for the Ticket resource)
- give it the
create!
instruction
To do this we will test within iex:
iex -S mix phx.server
Support.User
|> Ash.Changeset.for_create()
|> Support.AshApi.create!()
So to explain this:
- we start with the desired resource
- we build a changeset
for_create
a new user (in this case we aren’t providing any data, yet) - finally, we invoke the
create
AshApi (within our Support app)
Which hopefully returns something like:
#Support.User<
__meta__: #Ecto.Schema.Metadata<:built, "">,
id: "936ec1c0-cbde-4ba2-8726-e8288c081b1f",
first_name: nil,
middle_name: nil,
last_name: nil,
admin: nil,
email: nil,
department_name: nil,
account_type: nil,
inserted_at: ~U[2022-11-04 13:30:38.109136Z],
updated_at: ~U[2022-11-04 13:30:38.109136Z],
aggregates: %{},
calculations: %{},
__order__: nil,
...
>
This tests our create
action, let’s test our attributes
too.
iex -S mix phx.server
Support.User
|> Ash.Changeset.for_create(
:create, %{first_name: "Nyima", last_name: "Sönam", admin: true, email: "nyima@example.com", account_type: :dog}
)
|> Support.AshApi.create!()
Ideally, we now see that our attributes are filled with data we provided in the changeset.
iex -S mix phx.server
#Support.User<
__meta__: #Ecto.Schema.Metadata<:built, "">,
id: "ac5b9358-a8f6-42ba-8922-36880b834004",
first_name: "Nyima",
middle_name: nil,
last_name: "Sönam",
admin: true,
email: "nyima@example.com",
department_name: nil,
account_type: :dog,
inserted_at: ~U[2022-11-04 13:39:06.531902Z],
updated_at: ~U[2022-11-04 13:39:06.531902Z],
aggregates: %{},
calculations: %{},
__order__: nil,
...
>
Attribute (input) Constraints
Of course we probably want some control of the attributes. We want to ensure some fields receive data or limits on this data - for example, we want limit the account types to :employee or :customer, the admin field by default should be false, and we definately need a first and last name, but not a middle name.
So let’s add some added info to our attributes:
- prevent nil attributes we use:
allow_nil? false
- to ensure a default value we use:
default :value
- to ensure a string is at least a few characters we use:
constraints min_length: integer
- to ensure we restrict the allowed values we can use:
constraints [one_of: [:value1, :value2]]
In our case, we can now update our user resource to:
# lib/support/resources/user.ex
defmodule Support.User do
use Ash.Resource
actions do
defaults [:create, :read, :update, :destroy]
end
# Attributes are the simple pieces of data that exist on your resource
attributes do
uuid_primary_key :id
attribute :email, :string do
allow_nil? false
constraints [
match: ~r/^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+.[a-zA-Z0-9-.]+$/
]
end
attribute :first_name, :string do
constraints min_length: 1
allow_nil? false
end
attribute :middle_name, :string do
constraints min_length: 1
end
attribute :last_name, :string do
constraints min_length: 1
allow_nil? false
end
attribute :admin, :boolean do
allow_nil? false
default false
end
attribute :account_type, :atom do
constraints [
one_of: [:employee, :customer]
]
end
attribute :department_name, :string
create_timestamp :inserted_at
update_timestamp :updated_at
end
end
Each type
has its own constraints
String Type for example I was delighted that by default leading and trailing spaces are trimmed with trim?
Atom Type - conveniently, have allow a constrain to a list of atom using the: :one_of
constraint.
TESTEN
iex -S mix phx.server
Support.User
|> Ash.Changeset.for_create(
:create, %{first_name: "Nyima", last_name: "Sönam", admin: true, email: "nyima@example.com", account_type: :dog}
)
|> Support.AshApi.create!()
We should now get an error that :dog is not a valid account type, lets fix this:
iex -S mix phx.server
Support.User
|> Ash.Changeset.for_create(
:create, %{first_name: "Nyima", last_name: "Sönam", admin: true, email: "nyima@example.com", account_type: :customer}
)
|> Support.AshApi.create!()
Support.User
|> Ash.Changeset.for_create(
:create, %{first_name: "Nyima", last_name: "Sönam", email: "nyima@example.com", account_type: :employee}
)
|> Support.AshApi.create!()
Hmm - we can still create a customer as admin and employees without an employee title. We can also create multiple accounts with the same email address, but at the moment we are not persisting our data, so we can’t yet control for that.
Validations
For data integrity may need to check relationships between attributes - for this we use validations:
- all employees have a department name
- all customers have NO department name
- all admins are also employees
Validation Documentation is found here:
In oder to use validations we need to add the validation extensions to our Resources. We do this in the file lib/support/registry.ex
by adding Ash.Registry.ResourceValidations
– so now the registry file should look like:
# lib/support/registry.ex
defmodule Support.Registry do
use Ash.Registry,
extensions: [
Ash.Registry.ResourceValidations
]
entries do
entry Support.User
end
end
Validation Documentation, lists a few examples. We will use the following validations (and explain and test them in detail below).
# lib/support/resources/user.ex
validations do
validate absent([:department_name]), where: attribute_equals(:account_type, :customer)
validate present([:department_name]), where: attribute_equals(:account_type, :employee)
validate attribute_equals(:account_type, :employee), where: attribute_equals(:admin, true), on: [:create, :update]
end
Validate Attribute Equals
To ensures an attribute must have a give value under the given requirements. In our case, we want to ensure the account_type
is :employee
when we have an ‘admin’ account. Thus we will use the attribute_equals
function when we have an ’employee’ account.
# lib/support/resources/user.ex
validations do
validate attribute_equals(:account_type, :employee), where: attribute_equals(:admin, true)
end
iex -S mix phx.server
admin = (
Support.User
|> Ash.Changeset.for_create(
:create, %{first_name: "Ratna", last_name: "Sönam", email: "ratna@example.com", admin: true}
)
|> Support.AshApi.create!()
)
# we should get this error
** (Ash.Error.Invalid) Input Invalid
* Invalid value provided for account_type: must equal employee.
I don’t find this message quite clear enough, so let’s add a custom error message with:
# lib/support/resources/user.ex
validations do
validate attribute_equals(:account_type, :employee),
where: attribute_equals(:admin, true),
message: "Accounts with admin privileges must be employee accounts"
end
iex -S mix phx.server
admin = (
Support.User
|> Ash.Changeset.for_create(
:create, %{first_name: "Ratna", last_name: "Sönam", email: "ratna@example.com", admin: true}
)
|> Support.AshApi.create!()
)
# Now we get this new error - which is hopefully a little clearer
** (Ash.Error.Invalid) Input Invalid
* Invalid value provided for account_type: Accounts with admin privileges must be employee accounts.
# now let's verify a properly created admin account should still works
admin = (
Support.User
|> Ash.Changeset.for_create(
:create, %{first_name: "Nyima", last_name: "Sönam", email: "ratna@example.com",
department_name: "Office Actor", account_type: :employee, admin: true}
)
|> Support.AshApi.create!()
)
Validate Attribute Absent
Ensures an Attribute must be absent under the given requirements. In our case, we want to ensure the department_name
is empty when we have a customer account. So we will use the absent
function. To restrict this to just customer accounts we will add a where:
option. We could also list specific actions we want to use this with the on:
option – for example on: [:create, :update, :new_customer]
actions. The absent function takes a list of attributes - in our case we only want to check one.
# lib/support/resources/user.ex
validations do
validate absent([:department_name]), where: attribute_equals(:account_type, :customer),
on: [:create, :update, :new_customer]
end
Lets try this out - we will create several user and then query for them.
iex -S mix phx.server
# test department_name is absent
customer = (
Support.User
|> Ash.Changeset.for_create(
:create, %{first_name: "Ratna", last_name: "Sönam", email: "ratna@example.com",
department_name: "Office Actor", account_type: :customer}
)
|> Support.AshApi.create!()
)
# now we should expect the following error
** (Ash.Error.Invalid) Input Invalid
* department_name: must be absent.
# but this should still work
customer = (
Support.User
|> Ash.Changeset.for_create(
:create, %{first_name: "Ratna", last_name: "Sönam", email: "ratna@example.com"}
)
|> Support.AshApi.create!()
)
Validate Attribute Present
Ensures an Attribute must be absent under the given requirements. In our case, we want to ensure the department_name
is present when we have an employee account. Thus we will use the present
function when we have an ’employee’ account. The present function takes a list of attributes - in our case we only want to check one attribute.
# lib/support/resources/user.ex
validations do
validate present([:department_name]), where: attribute_equals(:account_type, :employee)
end
Lets test this and see what the error looks like.
iex -S mix phx.server
# using just :create to be we can accept all attributes
employee = (
Support.User
|> Ash.Changeset.for_create(
:create, %{first_name: "Nyima", last_name: "Sönam", email: "ratna@example.com",
account_type: :employee}
)
|> Support.AshApi.create!()
)
# we should get this error
** (Ash.Error.Invalid) Input Invalid
* Invalid value provided for department_name: must be present.
# now with department name on an employee account we should get a valid record
employee = (
Support.User
|> Ash.Changeset.for_create(
:create, %{first_name: "Nyima", last_name: "Sönam", email: "ratna@example.com",
account_type: :employee, department_name: "Office Actor"}
)
|> Support.AshApi.create!()
)
Custom Actions
CRUD is nice, but it is often nice to create an API that focuses on your business logic. Custom Actions allow us to do that.
We want to add a custom user create
actions for ‘customers’, ’employees’ and ‘admins’. Thus we will focus on Create Actions
NOTE: at the moment we have no data layer (persistence), so we can only create data. To verify this try:
Try:
iex -S mix phx.server
Support.AshApi.read!(Support.User)
# You should get an error - that says: 'there is no data to be read for that resource'
Let’s start by just restricting what data can be submitted for various types of accounts. This will force the use of defaults - thus ensuring correct creation.
# lib/support/resources/user.ex
# ...
actions do
# By default all attributes are accepted by an action
defaults [:create, :read, :update, :destroy]
# By default all attributes are accepted by an action
create :new_customer do
# only allows the listed attributes
accept [:email, :first_name, :middle_name, :last_name]
end
create :new_employee do
# only allows the listed attributes
accept [:email, :first_name, :middle_name, :last_name, :department_name, :account_type]
end
create :new_admin do
# only allows the listed attributes
accept [:email, :first_name, :middle_name, :last_name, :department_name, :account_type, :admin]
end
end
# ...
To create a customer now – the following should should help create a customer correctly:
iex -S mix phx.server
# just in case iex is already open
recompile()
# the custom action should prevent customers from becoming admins or employees
customer = (
Support.User
|> Ash.Changeset.for_create(:new_customer, %{first_name: "Nyima", last_name: "Sönam", email: "nyima@example.com", admin: true})
|> Support.AshApi.create!()
)
# and get the error:
** (Ash.Error.Invalid) Input Invalid
* Invalid value provided for admin: cannot be changed.
# should work
customer = (
Support.User
|> Ash.Changeset.for_create(:new_customer, %{first_name: "Nyima", last_name: "Sönam", email: "nyima@example.com"})
|> Support.AshApi.create!()
)
Resources
Documentation
- https://www.ash-hq.org/
- https://hexdocs.pm/ash/get-started.html
- https://www.ash-hq.org/docs/guides/ash/2.4.1/tutorials/get-started.md
- https://github.com/phoenixframework/phoenix/blob/master/installer/README.md
Ash Framework 1.5 - Video and Slides