IN PROGRESS- Probably lots of errors
In order to build relationships we will need more than one resource. So let’s build the support ’ticket’ and exchange ‘comments’ resource. We will continue using ETS as the data layer (for now). I won’t explain the actions
and attributes
since that has already been covered in the Resources Article.
Relationships declare the relationship between ‘resources’ we have:
belongs_to
- associated with ONE resource, if the remote resource is deleted, this resource will need to be deleted (first). For example ONE ticket is associated with ONE user who created the ticket. If the user who created the ticket is deleted then theticket
will need to be removed too.has_many
- A user can create MULTIPLE ticket, again, if the remote resource is deleted, this remote resource will need to be deleted (first).has_one
- not commonly used, but its the inverse of belongs_to
Has Many
Now let’s see how we define our has_many
relationships within Ash. The ‘User’ may have has authored
MANY ‘comments’. A ‘User’ (customer or employee) may have created
MANY ’tickets’. A ‘User’ (employees) may have been assigned
MANY ‘Tickets’. Here we see we need to use the
# lib/support/resources/user.ex
# ...
relationships do
has_many :authored_comments, Support.Comment do
destination_attribute :author_id
end
# with overloaded references we need to declare the remote key
has_many :created_tickets, Support.Ticket do
destination_attribute :reporter_id
end
has_many :assigned_tickets, Support.Ticket do
destination_attribute :technician_id
end
end
# ...
We also want to be able to find the possible MANY comments associated with a ticket.
# lib/support/resources/ticket.ex
# ...
relationships do
has_many :comments, Support.Comment do
destination_attribute :ticket_id
end
belongs_to :reporter, Support.User
belongs_to :technician, Support.User
end
# ...
Be sure to update the registry!
# lib/support/registry.ex
defmodule Support.Registry do
use Ash.Registry,
extensions: [
Ash.Registry.ResourceValidations
]
entries do
entry Support.Comment
entry Support.Ticket
entry Support.User
end
end
TEST
iex -S mix phx.server
# Create a Customer and a technician
customer = (
Support.User
|> Ash.Changeset.for_create(
:new_customer, %{first_name: "Ratna", last_name: "Sönam", email: "ratna@example.com"}
)
|> Support.AshApi.create!()
)
technician = (
Support.User
|> Ash.Changeset.for_create(
:new_employee, %{first_name: "Nyima", last_name: "Sönam", email: "nyima@example.com",
department_name: "Tech Support"}
)
|> Support.AshApi.create!()
)
# Customer reports a ticket - reporter_id remains blank! :(
ticket1 = (
Support.Ticket
|> Ash.Changeset.for_create(
:create, %{subject: "No Power", description: "nothing happens", reporter_id: customer.id}
)
|> Support.AshApi.create!()
)
NOTICE:
- we added a reporter_id we created a ticket without a reporter - we don’t want that, so we will add a validation.
- we need a
relationship
changeset to add relationships - a normal changeset only updatesattributes
To require a relationship we can rewrite our relationships to:
# lib/support/resources/ticket.ex
# ...
relationships do
has_many :comments, Support.Comment do
destination_attribute :ticket_id
end
belongs_to :reporter, Support.User
belongs_to :technician, Support.User
end
# ...
TEST
iex -S mix phx.server
# or
recompile()
# Create a Customer and a technician
customer = (
Support.User
|> Ash.Changeset.for_create(
:new_customer, %{first_name: "Ratna", last_name: "Sönam", email: "ratna@example.com"}
)
|> Support.AshApi.create!()
)
technician = (
Support.User
|> Ash.Changeset.for_create(
:new_employee, %{first_name: "Nyima", last_name: "Sönam", email: "nyima@example.com",
department_name: "Tech Support"}
)
|> Support.AshApi.create!()
)
# Customer reports a ticket - reporter_id remains blank! :(
ticket1 = (
Support.Ticket
|> Ash.Changeset.for_create(
:create, %{subject: "No Power", description: "nothing happens", reporter_id: customer.id}
)
|> Support.AshApi.create!()
)
# NOW WE SHOULD GET!
** (Ash.Error.Invalid) Input Invalid
* relationship reporter is required
Has Many
comment1 = (
Support.Comment
|> Ash.Changeset.for_create(
:create, %{message: "I plugged it in", author_id: customer.id, ticket_id: ticket1.id}
)
|> Support.AshApi.update!()
)
many_to_many ?
Custom Actions using Relationships
Now we need to create additional ‘actions’ for ticket to manage the relationships:
# lib/support/resources/ticket.ex
actions do
# Add a set of simple actions. You'll customize these later.
defaults [:create, :read, :update, :destroy]
create :new do
accept [:subject]
argument :reporter_id, :uuid do
allow_nil? false # This action requires reporter_id
end
change manage_relationship(:reporter_id, :reporter, type: :append_and_remove)
end
update :assign do
# No attributes should be accepted
accept []
# We accept a representative's id as input here
argument :representative_id, :uuid do
# This action requires representative_id
allow_nil? false
end
# We use a change here to replace the related representative
change manage_relationship(:representative_id, :representative, type: :append_and_remove)
end
update :start do
# No attributes should be accepted
accept []
# We accept a representative's id as input here
argument :representative_id, :uuid do
# This action requires representative_id
allow_nil? false
end
# We use a change here to replace the related representative
change manage_relationship(:representative_id, :representative, type: :append_and_remove)
change set_attribute(:status, :open)
end
update :close do
# We don't want to accept any input here
accept []
change set_attribute(:status, :closed)
end
# ...
We can learn more about managing ash relationships at: https://www.ash-hq.org/docs/module/ash/2.4.1/ash-resource-change-builtins#function-manage_relationship-3
Testing Relationships:
iex -S mix
#
recompile()
# Create a reporter
reporter = (
Support.User
|> Ash.Changeset.for_create(:create, %{name: "Nyima Dog"})
|> Support.create!()
)
# Open a ticket
ticket1 = (
Support.Ticket
|> Ash.Changeset.for_create(:new, %{subject: "I can't find my hand!", reporter_id: reporter.id})
|> Support.create!()
)
# Create a representative
representative_joe = (
Support.User
|> Ash.Changeset.for_create(:create, %{name: "Joe"})
|> Support.create!()
)
representative_jose = (
Support.User
|> Ash.Changeset.for_create(:create, %{name: "Jose"})
|> Support.create!()
)
# Assign that representative
ticket2 = (
ticket
|> Ash.Changeset.for_update(:assign, %{representative_id: representative_joe.id})
|> Support.update!()
)
# Start working on the Ticket
ticket = (
ticket
|> Ash.Changeset.for_update(:start, %{representative_id: representative_jose.id})
|> Support.update!()
)
# close the ticket
ticket = (
ticket
|> Ash.Changeset.for_update(:close)
|> Support.update!()
)
I’ve been curious about the Elixir Ash Framework and had some time. It looks like it helps create an application framework and has many pre-built common solutions. Authorization, Queries, Application Structure, etc.
As usual, I struggle with API documentation, and I love tutorials. So I followed the instructions at: https://www.ash-hq.org/docs/guides/ash/2.4.1/tutorials/get-started.md#module-docs and integrated it with the slightly outdated Ash 1.x slide notes from a 2020 ElixirConf talk by Zach Daniel called Introduction to the Ash Framework
Here is what I had to do (learn and adjust) to get up and running.
Testing Ash (Tickets)
Let’s see if what we built actually works.
We will create a ’ticket’ resource and then query for it.
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
Support.Ticket
|> Ash.Changeset.for_create(:create)
|> Support.create!()
Which hopefully returns something like:
#Support.Ticket<
__meta__: #Ecto.Schema.Metadata<:built, "">,
id: "bcc9729b-7fa2-4e7c-af45-3293be3394ee",
subject: nil,
aggregates: %{},
calculations: %{},
__order__: nil,
...
>
Notice that the subject is nil (we aren’t yet using validations) and our primary key is a uuid.
Attribute Constraints
Let’s say we don’t want to allow blank Subjects and we want to require a restricted list of statuses.
To prevent blanks in a field we can change the attribute subject to look like:
# lib/support/resources/ticket.ex
# ...
attributes do
attribute :subject, :string do
allow_nil? false
end
# ...
end
# ...
Now lets add a set of restricted statuses (new, open, closed):
# lib/support/resources/ticket.ex
# ...
attributes do
attribute :status, :atom do
constraints [one_of: [:new, :open, :closed]]
default :new
allow_nil? false
end
# ...
end
# ...
Now the ticket
file should look like:
# lib/support/resources/ticket.ex
defmodule Support.Ticket do
# This turns this module into a resource
use Ash.Resource
actions do
# A set of simple actions.
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
# Add a string type attribute called `:subject`
attribute :subject, :string do
allow_nil? false # Don't allow `nil` values
end
# status is either `open` or `closed`. We can add more statuses later
attribute :status, :atom do
# restricted list of statuses
constraints [one_of: [:new, :open, :closed]]
default :new # default value when none is given
allow_nil? false # Don't allow `nil` values
end
end
end
Custom Action
We want to create a custom new
ticket action - that will only accept one attribute subject
- which is also now required (per default all attributes will be accepted).
# lib/support/resources/ticket.ex
# ...
actions do
# ...
create :new do
# By default all attributes are accepted by an action
accept [:subject] # This action should only accept the subject
end
# ...
end
# ...
We also want a close
action:
# lib/support/resources/ticket.ex
# ...
actions do
# ...
update :close do
accept [] # accept no input here
change set_attribute(:status, :closed)
end
# ...
end
# ...
Now the Ticket file should look like:
# lib/support/resources/ticket.ex
defmodule Support.Ticket do
# This turns this module into a resource
use Ash.Resource
actions do
# Add a set of simple actions. You'll customize these later.
defaults [:create, :read, :update, :destroy]
create :new do
accept [:subject] # only accept the subject - no other fields
end
update :close do
accept [] # don't accept attributes
change set_attribute(:status, :closed) # build the change set
end
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
# Add a string type attribute called `:subject`
attribute :subject, :string do
allow_nil? false # Don't allow `nil` values
end
# status is either `open` or `closed`. We can add more statuses later
attribute :status, :atom do
# restricted list of statuses
constraints [one_of: [:new, :open, :closed]]
default :new # default value when none is given
allow_nil? false # Don't allow `nil` values
end
end
end
To test this change we need to adjust our change set - if we don’t we should get an error:
iex -S mix
# just in case iex is already open
recompile()
Support.Ticket
|> Ash.Changeset.for_create(:new)
|> Support.create!()
# we should get the error:
** (Ash.Error.Invalid) Input Invalid
* attribute subject is required
# ...
To properly create a Ticket now – the following should work:
iex -S mix
# just in case iex is already open
recompile()
ticket = (
Support.Ticket
|> Ash.Changeset.for_create(:new, %{subject: "My mouse won't click!"})
|> Support.create!()
)
we should get something like (notice we have a subject and it’s status is pending
):
#Support.Ticket<
__meta__: #Ecto.Schema.Metadata<:loaded, "tickets">,
id: "b792b4f1-2167-4aa8-b654-4aef4938ba9a",
subject: "My mouse won't click!",
status: :new,
# ...
Now let’s test the close
action on our new ticket
:
ticket
|> Ash.Changeset.for_update(:close)
|> Support.update!()
Now our ticket should look like:
#Support.Ticket<
__meta__: #Ecto.Schema.Metadata<:loaded, "tickets">,
id: "ccd5af37-7cad-40c5-badd-71d5c67d50a5",
subject: "My mouse won't click!",
status: :closed,
# ...
Since we have no storage at the moment we can’t query our records.
Try:
Support.AshApi.read!(Support.Ticket)
You should get an error - that says: ’there is no data to be read for that resource’
Let’s enable queries - we need to configure a data layer for these resources
Ash Queries
Simple Data Layer
We can add a default Simple
data layer with the macro: require Ash.Query
, so if we create some tickets using the script:
# Ash.Query is a macro, so it must be required
require Ash.Query
tickets =
for i <- 0..5 do
ticket =
Support.Ticket
|> Ash.Changeset.for_create(:open, %{subject: "Issue #{i}"})
|> Support.create!()
if rem(i, 2) == 0 do
ticket
|> Ash.Changeset.for_update(:close)
|> Support.update!()
else
ticket
end
end
Now we should be able to query and filter our tickets:
# Show the tickets where the subject contains "2"
Support.Ticket
|> Ash.Query.filter(contains(subject, "2"))
|> Ash.DataLayer.Simple.set_data(tickets)
|> Support.AshApi.read!()
# Show the tickets that are closed and their subject does not contain "4"
Support.Ticket
|> Ash.Query.filter(status == :closed and not(contains(subject, "4")))
|> Ash.DataLayer.Simple.set_data(tickets)
|> Support.AshApi.read!()
Notice the power of the filter
command, try adjusting.
To learn more visit:
Ash Persistence (ETS)
ETS is an in-memory (OTP based) way to persist data (we will work with PostgreSQL later). Once we have persisted data we can explore relationships.
To add ETS to the Data Layer we need to change the line use Ash.Resource
to:
# lib/support/resources/ticket.ex
# ...
use Ash.Resource,
data_layer: Ash.DataLayer.Ets
# ...
Now the file should look like:
# lib/support/resources/ticket.ex
defmodule Support.Ticket do
# This turns this module into a resource
use Ash.Resource,
data_layer: Ash.DataLayer.Ets
actions do
# Add a set of simple actions. You'll customize these later.
defaults [:create, :read, :update, :destroy]
create :new do
accept [:subject] # only accept the subject - no other fields
end
update :close do
accept [] # don't accept attributes
change set_attribute(:status, :closed) # build the change set
end
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
# Add a string type attribute called `:subject`
attribute :subject, :string do
allow_nil? false # Don't allow `nil` values
end
# status is either `open` or `closed`. We can add more statuses later
attribute :status, :atom do
# restricted list of statuses
constraints [one_of: [:new, :open, :closed]]
default :new # default value when none is given
allow_nil? false # Don't allow `nil` values
end
end
end
Ash Queries - Persistent Data Layer
iex -S mix
# and or
recompile()
# Actions (create & persist our records)
for i <- 0..5 do
ticket =
Support.Ticket
|> Ash.Changeset.for_create(:new, %{subject: "Issue #{i}"})
|> Support.create!()
if rem(i, 2) == 0 do
ticket
|> Ash.Changeset.for_update(:close)
|> Support.update!()
end
end
# QUERY Data
# enable Ash Query (notice we no longer need to include the data layer in the query!)
require Ash.Query
# use `read` to list all users
{:ok, users} = Support.read(Support.User)
{:ok, tickets}= Support.read(Support.Ticket)
# use 'get' to get one record when you know the id
ticket_last = List.last(tickets)
Support.get(Support.Ticket, ticket_last.id)
# Show the tickets where the subject contains "2"
Support.Ticket
|> Ash.Query.filter(contains(subject, "2"))
|> Support.AshApi.read!()
# Show the tickets that are closed and their subject does not contain "4"
Support.Ticket
|> Ash.Query.filter(status == :closed and not(contains(subject, "4")))
|> Support.AshApi.read!()
Ash Relationships
We will now create a User
that can create or be assigned a ticket (using ETS as the data layer).
# lib/support/resources/user.ex
defmodule Support.User do
# This turns this module into a resource
use Ash.Resource,
data_layer: Ash.DataLayer.Ets
actions do
defaults [:create, :read, :update, :destroy]
end
attributes do
uuid_primary_key :id
attribute :name, :string
end
relationships do
has_many :assigned_tickets, Support.Ticket do
destination_attribute :representative_id
end
has_many :reported_tickets, Support.Ticket do
destination_attribute :reporter_id
end
end
end
The has_many
means that the destination attribute is not unique, meaning many related records could exist.
We also need to register our new user
resource by adding:
# lib/support/registry.ex
entries do
# ...
entry Support.User
end
So now our registry should look like:
# lib/support/registry.ex
defmodule Support.Registry do
use Ash.Registry,
extensions: [
Ash.Registry.ResourceValidations
]
entries do
entry Support.Ticket
entry Support.User
end
end
Now we need to add the relationship to Tickets too:
# lib/support/resources/ticket.ex
# ...
relationships do
belongs_to :reporter, Support.User
belongs_to :representative, Support.User
end
# ...
We use belong_to
meaning that the destination attribute is unique, meaning only one related record could exist.
Now we need to create additional ‘actions’ for ticket to manage the relationships:
# lib/support/resources/ticket.ex
actions do
# Add a set of simple actions. You'll customize these later.
defaults [:create, :read, :update, :destroy]
create :new do
accept [:subject]
argument :reporter_id, :uuid do
allow_nil? false # This action requires reporter_id
end
change manage_relationship(:reporter_id, :reporter, type: :append_and_remove)
end
update :assign do
# No attributes should be accepted
accept []
# We accept a representative's id as input here
argument :representative_id, :uuid do
# This action requires representative_id
allow_nil? false
end
# We use a change here to replace the related representative
change manage_relationship(:representative_id, :representative, type: :append_and_remove)
end
update :start do
# No attributes should be accepted
accept []
# We accept a representative's id as input here
argument :representative_id, :uuid do
# This action requires representative_id
allow_nil? false
end
# We use a change here to replace the related representative
change manage_relationship(:representative_id, :representative, type: :append_and_remove)
change set_attribute(:status, :open)
end
update :close do
# We don't want to accept any input here
accept []
change set_attribute(:status, :closed)
end
# ...
We can learn more about managing ash relationships at: https://www.ash-hq.org/docs/module/ash/2.4.1/ash-resource-change-builtins#function-manage_relationship-3
Testing Relationships:
iex -S mix
#
recompile()
# Create a reporter
reporter = (
Support.User
|> Ash.Changeset.for_create(:create, %{name: "Nyima Dog"})
|> Support.create!()
)
# Open a ticket
ticket = (
Support.Ticket
|> Ash.Changeset.for_create(:new, %{subject: "I can't find my hand!", reporter_id: reporter.id})
|> Support.create!()
)
# Create a representative
representative_joe = (
Support.User
|> Ash.Changeset.for_create(:create, %{name: "Joe"})
|> Support.create!()
)
representative_jose = (
Support.User
|> Ash.Changeset.for_create(:create, %{name: "Jose"})
|> Support.create!()
)
# Assign that representative
ticket = (
ticket
|> Ash.Changeset.for_update(:assign, %{representative_id: representative_joe.id})
|> Support.update!()
)
# Start working on the Ticket
ticket = (
ticket
|> Ash.Changeset.for_update(:start, %{representative_id: representative_jose.id})
|> Support.update!()
)
# close the ticket
ticket = (
ticket
|> Ash.Changeset.for_update(:close)
|> Support.update!()
)