01-Resources

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:

  1. build a change-set for the create action (for the Ticket resource)
  2. 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

Ash Framework 1.5 - Video and Slides