add ash_tutorial

This commit is contained in:
Chang CL
2025-09-29 19:25:33 +08:00
parent ee998fec4b
commit bb489089e2
13 changed files with 1858 additions and 0 deletions

9
ash_tutorial/README.MD Normal file
View File

@@ -0,0 +1,9 @@
# Get Started
To get started you need a running instance of [Livebook](https://livebook.dev/)
[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fgithub.com%2Fwoutdp%2Fash_tutorial%2Fblob%2Fmaster%2Foverview.livemd)
## Contributions
Anything missing? Any errors? Tutorials out of date? Contributions are welcome!

View File

@@ -0,0 +1,93 @@
# Ash: 2 - Actions
```elixir
Application.put_env(:ash, :validate_domain_resource_inclusion?, false)
Application.put_env(:ash, :validate_domain_config_inclusion?, false)
Mix.install([{:ash, "~> 3.0"}], consolidate_protocols: false)
```
## What is an Action?
<div class="flex items-center w-full flex-start justify-between rounded-xl p-4" style="background-color: #f0f5f9; color: #61758a;">
<div class="flex">
<i class="ri-arrow-left-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="resources.livemd">Resources</a>
</div>
<div class="flex">
<i class="ri-home-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="overview.livemd">Home</a>
</div>
<div class="flex">
<a class="flex mr-2" style="color: #61758a;" href="querying.livemd">Querying</a>
<i class="ri-arrow-right-fill"></i>
</div>
</div>
<!-- livebook:{"break_markdown":true} -->
Actions are much simpler: an action describes a <span title="Create, Read, Update, Delete">CRUD</span> operation that can be performed on a resource, but using whatever language makes the most sense for your given resource. For example, you may have a `publish` action on a `BlogPost` resource that changes the resource to be publicly visible. Under CRUD, this would be an `update` operation, but calling it the "publish action" more fully conveys what the operation does.
An action is defined on a resource in an `actions do ... end` block.
<!-- livebook:{"force_markdown":true} -->
```elixir
actions do
create :create do
accept [:name]
end
end
```
<!-- livebook:{"break_markdown":true} -->
Adding that to our `Resource` module from earlier,
```elixir
defmodule Tutorial.Profile do
use Ash.Resource,
domain: Tutorial
attributes do
uuid_primary_key :id
attribute :name, :string
end
actions do
create :create do
accept [:name]
end
end
end
defmodule Tutorial do
use Ash.Domain
resources do
resource Tutorial.Profile
end
end
```
We can now use `for_create/3` to create a changeset.
```elixir
Tutorial.Profile
|> Ash.Changeset.for_create(:create, %{name: "My Name"})
```
<div class="flex items-center w-full flex-start justify-between rounded-xl p-4" style="background-color: #f0f5f9; color: #61758a;">
<div class="flex">
<i class="ri-arrow-left-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="./resources.livemd">Resources</a>
</div>
<div class="flex">
<i class="ri-home-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="./overview.livemd">Home</a>
</div>
<div class="flex">
<a class="flex mr-2" style="color: #61758a;" href="./querying.livemd">Querying</a>
<i class="ri-arrow-right-fill"></i>
</div>
</div>

View File

@@ -0,0 +1,210 @@
# Ash: 8 - Aggregates
```elixir
Application.put_env(:ash, :validate_domain_resource_inclusion?, false)
Application.put_env(:ash, :validate_domain_config_inclusion?, false)
Mix.install([{:ash, "~> 3.0"}], consolidate_protocols: false)
```
## Aggregates
<div class="flex items-center w-full flex-start justify-between rounded-xl p-4" style="background-color: #f0f5f9; color: #61758a;">
<div class="flex">
<i class="ri-arrow-left-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="code_interfaces.livemd">Code Interfaces</a>
</div>
<div class="flex">
<i class="ri-home-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="overview.livemd">Home</a>
</div>
<div class="flex">
<a class="flex mr-2" style="color: #61758a;" href="calculations.livemd">Calculations</a>
<i class="ri-arrow-right-fill"></i>
</div>
</div>
### In this tutorial you will add an Aggregate on a Resource
[Aggregates](https://hexdocs.pm/ash/aggregates.html) in Ash allow for retrieving summary information over groups of related data. Aggregates can be either _count_, _first_, _sum_ or _list_.
Implement a count aggregate on the 'open tickets' a representative is assigned to.
First explore the comments in the code below, to make this work properly you have to add the inverse relationship of belongs_to inside the Representative resource.
To add an aggregate define an `aggregates do .. end` block inside the representative.
Inside the `aggregates` block, define a `count :count_of_open_tickets, :tickets do .. end` block.
Then inside this block, define a filter like so: `filter expr(status == :open)`
Also, notice what happens when you don't define a filter.
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
aggregates do
count :count_of_open_tickets, :tickets do
filter expr(status == :open)
end
end
```
</div>
</details>
### Enter your solution
```elixir
defmodule Tutorial.Support.Ticket do
use Ash.Resource,
domain: Tutorial.Support,
data_layer: Ash.DataLayer.Ets
actions do
defaults [:read]
# On creation set the representative by providing the id
create :open do
accept [:subject, :description, :representative_id]
end
update :close do
accept []
change set_attribute(:status, :closed)
end
update :assign do
accept [:representative_id]
end
end
attributes do
uuid_primary_key(:id)
attribute :subject, :string, allow_nil?: false
attribute :description, :string, allow_nil?: true
attribute :status, :atom do
constraints one_of: [:open, :closed]
default :open
allow_nil? false
end
create_timestamp :created_at
update_timestamp :updated_at
end
relationships do
belongs_to :representative, Tutorial.Support.Representative
end
code_interface do
define :assign, args: [:representative_id]
# <- added representative_id
define :open, args: [:subject, :description, :representative_id]
define :close, args: []
end
end
defmodule Tutorial.Support.Representative do
use Ash.Resource,
domain: Tutorial.Support,
data_layer: Ash.DataLayer.Ets
actions do
defaults [:read]
create :create do
accept [:name]
end
end
attributes do
uuid_primary_key :id
attribute :name, :string
end
# Added the inverse relationship of belongs_to in the ticket.
# This way we can reference :tickets inside the aggregates.
relationships do
has_many :tickets, Tutorial.Support.Ticket
end
code_interface do
define :create, args: [:name]
end
# <- Add the aggregates here
end
```
```elixir
defmodule Tutorial.Support do
use Ash.Domain
resources do
resource Tutorial.Support.Ticket
resource Tutorial.Support.Representative
end
end
```
## Query the Aggregate
Use a [Bulk Create](https://hexdocs.pm/ash/create-actions.html#bulk-creates) to create 4 tickets, 3 of which are assigned to Joe.
```elixir
# Create a representative
joe = Tutorial.Support.Representative.create!("Joe Armstrong")
# Bulk create 4 tickets, 3 of which are assigned to Joe
[
%{subject: "I can't see my eyes!"},
%{subject: "I can't find my hand!", representative_id: joe.id},
%{subject: "My fridge is flying away!", representative_id: joe.id},
%{subject: "My bed is invisible!", representative_id: joe.id}
]
|> Ash.bulk_create(Tutorial.Support.Ticket, :open, return_records?: true)
```
Close the last ticket assigned to Joe.
```elixir
require Ash.Query
# Retrieve the last ticket
[last_ticket] =
Tutorial.Support.Ticket
|> Ash.Query.filter(representative_id == ^joe.id)
|> Ash.Query.sort(created_at: :desc)
|> Ash.Query.limit(1)
|> Ash.read!()
# Close the last ticket
Tutorial.Support.Ticket.close!(last_ticket)
```
```elixir
joe = Ash.load!(joe, [:count_of_open_tickets])
joe.count_of_open_tickets
```
The result should be __2__, as you opened 4 tickets, 3 were assigned to Joe, but the last assigned ticket was closed.
<!-- livebook:{"break_markdown":true} -->
<div class="flex items-center w-full flex-start justify-between rounded-xl p-4" style="background-color: #f0f5f9; color: #61758a;">
<div class="flex">
<i class="ri-arrow-left-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="code_interfaces.livemd">Code Interfaces</a>
</div>
<div class="flex">
<i class="ri-home-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="overview.livemd">Home</a>
</div>
<div class="flex">
<a class="flex mr-2" style="color: #61758a;" href="calculations.livemd">Calculations</a>
<i class="ri-arrow-right-fill"></i>
</div>
</div>

View File

@@ -0,0 +1,309 @@
# Ash: 4 - Attributes
```elixir
Application.put_env(:ash, :validate_domain_resource_inclusion?, false)
Application.put_env(:ash, :validate_domain_config_inclusion?, false)
Mix.install([{:ash, "~> 3.0"}], consolidate_protocols: false)
```
## Attributes
<div class="flex items-center w-full flex-start justify-between rounded-xl p-4" style="background-color: #f0f5f9; color: #61758a;">
<div class="flex">
<i class="ri-arrow-left-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="querying.livemd">Querying</a>
</div>
<div class="flex">
<i class="ri-home-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="overview.livemd">Home</a>
</div>
<div class="flex">
<a class="flex mr-2" style="color: #61758a;" href="customizing_actions.livemd">Customizing Actions</a>
<i class="ri-arrow-right-fill"></i>
</div>
</div>
### In this tutorial you will add and configure Attributes to a Ticket resource
The Ticket resource will represent a helpdesk ticket.
It will have 3 main attributes.
* A `subject` or title
* A `description`
* A `status` which can be either `:open` or `:closed`
It will also have 2 attributes for keeping track of when the ticket was *created*, and when it was *last updated*.
#### Subject
You need to make sure the `subject` is always set, it's not possible to create a ticket without a subject. You can do this by setting `allow_nil?` to `false`. Like so: `attribute :subject, :string, allow_nil?: false`
#### Description
The `:description` is simple, it's a `:string` and it is allowed to be empty.
#### Status
`:status` is more complicated.
* It is of the type `:atom`
* It can only be the value `:open` or `:closed`
* By default it is `:open`
* And it can't be `nil`
Attributes are set in a `do end` block like so:
<!-- livebook:{"force_markdown":true} -->
```elixir
attribute :status, :atom do
# ...
allow_nil? false
end
```
To set a constraint of values, you can use the `constraints` option, like so:
`constraints [one_of: [:open, :closed]]`
To set the default value, you use `default :open`.
#### Keeping track of Created and Updated
Ash provides [create_timestamp](https://hexdocs.pm/ash/dsl-ash-resource.html#attributes-create_timestamp) and [update_timestamp](https://hexdocs.pm/ash/dsl-ash-resource.html#attributes-update_timestamp) to keep track of when the Resource was first created, and when it was last updated.
Add the following to the attributes block:
<!-- livebook:{"force_markdown":true} -->
```elixir
create_timestamp :created_at
update_timestamp :updated_at
```
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
defmodule Tutorial.Support.Ticket do
use Ash.Resource,
domain: Tutorial.Support,
data_layer: Ash.DataLayer.Ets
actions do
defaults [:read]
create :create do
accept [:subject, :description, :status]
end
end
attributes do
uuid_primary_key :id
attribute :subject, :string, allow_nil?: false
attribute :description, :string
# status is either `open` or `closed`.
attribute :status, :atom do
# Constraints allow you to provide extra rules for the value.
# The available constraints depend on the type
# See the documentation for each type to know what constraints are available
# Since atoms are generally only used when we know all of the values
# it provides a `one_of` constraint, that only allows those values
constraints [one_of: [:open, :closed]]
default :open
allow_nil? false
end
create_timestamp :created_at
update_timestamp :updated_at
end
end
defmodule Tutorial.Support do
use Ash.Domain
resources do
resource Tutorial.Support.Ticket
end
end
```
</div>
</details>
### Enter your solution
```elixir
defmodule Tutorial.Support.Ticket do
use Ash.Resource,
domain: Tutorial.Support,
data_layer: Ash.DataLayer.Ets
actions do
defaults [:read]
create :create do
accept [:subject, :description, :status]
end
end
attributes do
uuid_primary_key :id
# Add the attributes here -->
# - Subject
# - Description
# - Status
# - Create Timestamp
# - Update Timestamp
end
end
defmodule Tutorial.Support do
use Ash.Domain
resources do
resource Tutorial.Support.Ticket
end
end
```
## Creating a Ticket
Create a `Ticket` without any attributes.
Remember, when creating a resource use a changeset (`Ash.Changeset.for_create/3`), which gets passed to `Ash.create!/1`.
The output should look something like this:
```
** (Ash.Error.Invalid) Input Invalid
* attribute subject is required
```
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
Tutorial.Support.Ticket
|> Ash.Changeset.for_create(:create, %{})
|> Ash.create!()
```
</div>
</details>
```elixir
```
Now create a Ticket with a `subject`
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
Tutorial.Support.Ticket
|> Ash.Changeset.for_create(:create, %{subject: "This is the subject"})
|> Ash.create!()
```
</div>
</details>
```elixir
```
Now create a ticket with the `status` set to `:closed`
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
Tutorial.Support.Ticket
|> Ash.Changeset.for_create(:create, %{subject: "This is the subject", status: :closed})
|> Ash.create!()
```
</div>
</details>
```elixir
```
Now try creating a ticket with the status set to `:pending`. This should give an error because `:pending` is not a valid `status`.
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
Tutorial.Support.Ticket
|> Ash.Changeset.for_create(:create, %{subject: "This is the subject", status: :pending})
|> Ash.create!()
```
</div>
</details>
```elixir
```
## Latest created Ticket
Since you added a creation date, you can now query on the latest created ticket.
First, sort using the `Ash.Query.sort/2` function by `Ash.Query.sort(created_at: :desc)`
Then limit the amount of records with the `Ash.Query.limit/2` function by doing `Ash.Query.limit(1)`
Finally call `Ash.read_one!()` on the query.
*Hint: Use a pipeline*
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
Tutorial.Support.Ticket
|> Ash.Query.sort(created_at: :desc)
|> Ash.Query.limit(1)
|> Ash.read_one!()
```
</div>
</details>
```elixir
```
<div class="flex items-center w-full flex-start justify-between rounded-xl p-4" style="background-color: #f0f5f9; color: #61758a;">
<div class="flex">
<i class="ri-arrow-left-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="querying.livemd">Querying</a>
</div>
<div class="flex">
<i class="ri-home-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="overview.livemd">Home</a>
</div>
<div class="flex">
<a class="flex mr-2" style="color: #61758a;" href="customizing_actions.livemd">Customizing Actions</a>
<i class="ri-arrow-right-fill"></i>
</div>
</div>

View File

@@ -0,0 +1,113 @@
# Ash: 9 - Calculations
```elixir
Application.put_env(:ash, :validate_domain_resource_inclusion?, false)
Application.put_env(:ash, :validate_domain_config_inclusion?, false)
Mix.install([{:ash, "~> 3.0"}], consolidate_protocols: false)
```
## Calculations
<div class="flex items-center w-full flex-start justify-between rounded-xl p-4" style="background-color: #f0f5f9; color: #61758a;">
<div class="flex">
<i class="ri-arrow-left-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="aggregates.livemd">Aggregates</a>
</div>
<div class="flex">
<i class="ri-home-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="overview.livemd">Home</a>
</div>
<div class="flex hidden">
<a class="flex mr-2" style="color: #61758a;" href="domain.livemd">Domain</a>
<i class="ri-arrow-right-fill"></i>
</div>
</div>
[Calculations](https://hexdocs.pm/ash/calculations.html) in Ash allow for displaying complex values as a top level value of a resource.
Create a `full_name` calculation on the User resource.
You already have the `first_name` and `last_name` defined, you can get the `full_name` by concatenating them with a space in the middle.
First, add a `calculations do ... end` block.
Then inside this block, add `calculate :full_name, :string, expr(first_name <> " " <> last_name)`
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
calculations do
calculate :full_name, :string, expr(first_name <> " " <> last_name)
end
```
</div>
</details>
### Enter your solution
```elixir
defmodule Tutorial.Accounts.User do
use Ash.Resource,
domain: Tutorial.Accounts,
data_layer: Ash.DataLayer.Ets
actions do
defaults([:read])
create :create do
accept([:first_name, :last_name])
end
end
attributes do
uuid_primary_key(:id)
attribute(:first_name, :string, allow_nil?: false)
attribute(:last_name, :string, allow_nil?: false)
end
# <--- Add the calculations here
end
```
```elixir
defmodule Tutorial.Accounts do
use Ash.Domain
resources do
resource(Tutorial.Accounts.User)
end
end
```
## Create a User
Create a User and load in the `full_name`
```elixir
Tutorial.Accounts.User
|> Ash.Changeset.for_create(:create, %{first_name: "Joe", last_name: "Armstrong"})
|> Ash.create!()
|> Ash.load!([:full_name])
```
This is the most basic example of a calculation. Note that calculations allow you to do any arbitrary operation on the Resource. More on this inside the [documentation](https://hexdocs.pm/ash/calculations.html).
<!-- livebook:{"break_markdown":true} -->
<div class="flex items-center w-full flex-start justify-between rounded-xl p-4" style="background-color: #f0f5f9; color: #61758a;">
<div class="flex">
<i class="ri-arrow-left-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="aggregates.livemd">Aggregates</a>
</div>
<div class="flex">
<i class="ri-home-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="overview.livemd">Home</a>
</div>
<div class="flex hidden">
<a class="flex mr-2" style="color: #61758a;" href="domain.livemd">Domain</a>
<i class="ri-arrow-right-fill"></i>
</div>
</div>

View File

@@ -0,0 +1,230 @@
# Ash: 7 - Code Interfaces
```elixir
Application.put_env(:ash, :validate_domain_resource_inclusion?, false)
Application.put_env(:ash, :validate_domain_config_inclusion?, false)
Mix.install([{:ash, "~> 3.0"}], consolidate_protocols: false)
```
## Code Interfaces
<div class="flex items-center w-full flex-start justify-between rounded-xl p-4" style="background-color: #f0f5f9; color: #61758a;">
<div class="flex">
<i class="ri-arrow-left-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="relationships.livemd">Relationships</a>
</div>
<div class="flex">
<i class="ri-home-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="overview.livemd">Home</a>
</div>
<div class="flex">
<a class="flex mr-2" style="color: #61758a;" href="aggregates.livemd">Aggregates</a>
<i class="ri-arrow-right-fill"></i>
</div>
</div>
### In this tutorial you will add a Code Interface
In previous tutorials you always used `Changesets` directly to CRUD on resources.
While this is a perfectly valid way of handling CRUD operations, Ash provides [Code Interfaces](https://hexdocs.pm/ash/code-interfaces.html) to make this more ergonomic for you.
The code interface can be defined on the resources *or* the domain. In this example, we will define it on the domain.
You will add 3 code interfaces for:
* Creating a representative
* Opening a ticket
* Assigning a representative
For each resource you want to add a code interface to, inside that resource definition on the domain, you will place "definitions".
For example:
<!-- livebook:{"force_markdown":true} -->
```elixir
resource MyApp.Tweet do
define :create_tweet, args: [:content]
end
```
<!-- livebook:{"break_markdown":true} -->
Then add the 3 interfaces by defining the following inside the `Ticket` resource's block:
* `define :assign_ticket, args: [:representative_id], action: :assign`
* `define :open_ticket, args: [:subject, :description], action: :open`
And the following inside the `Representative` resource's block.
* `define :create_representative, args: [:name], action: :create`
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
resource Tutorial.Support.Ticket do
define :assign_ticket, args: [:representative_id], action: :assign
define :open_ticket, args: [:subject, :description], action: :open
end
resource Tutorial.Support.Representative do
define :create_representative, args: [:name], action: :create
end
```
</div>
</details>
### Enter your solution
```elixir
defmodule Tutorial.Support.Ticket do
use Ash.Resource,
domain: Tutorial.Support,
data_layer: Ash.DataLayer.Ets
actions do
defaults [:read]
create :open do
accept [:subject, :description]
end
update :close do
change set_attribute(:status, :closed)
end
update :assign do
accept [:representative_id]
end
end
attributes do
uuid_primary_key :id
attribute :subject, :string, allow_nil?: false
attribute :description, :string, allow_nil?: true
attribute :status, :atom do
constraints one_of: [:open, :closed]
default :open
allow_nil? false
end
create_timestamp :created_at
update_timestamp :updated_at
end
relationships do
belongs_to :representative, Tutorial.Support.Representative
end
end
defmodule Tutorial.Support.Representative do
use Ash.Resource,
domain: Tutorial.Support,
data_layer: Ash.DataLayer.Ets
actions do
defaults [:read]
create :create do
accept [:name]
end
end
attributes do
uuid_primary_key :id
attribute :name, :string
end
end
defmodule Tutorial.Support do
use Ash.Domain
resources do
resource Tutorial.Support.Ticket do
# <-- Add ticket code interface here
end
resource Tutorial.Support.Representative do
# <-- Add representative code interface here
end
end
end
```
## Using the Code Interfaces
Create a Representative, but instead of using a changeset, use the code interface you created.
You can use a code interface by calling `Tutorial.Support.create_representative!/1` with the desired name.
Set the name to `Joe Armstrong` and store it in a `joe` variable.
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
joe = Tutorial.Support.create_representative!("Joe Armstrong")
```
</div>
</details>
```elixir
```
Create a Ticket with the created code interface.
Call `Tutorial.Support.open_ticket!/2` with a `subject` and `description` and store the result in a `ticket` variable.
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
ticket = Tutorial.Support.open_ticket!("I can't find my hand!", "I think someone stole it.")
```
</div>
</details>
```elixir
```
Assign the Representative `joe` to the Ticket using the code Interface.
Use `Tutorial.Support.assign_ticket!/2`.
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
Tutorial.Support.assign_ticket!(ticket, joe.id)
```
</div>
</details>
```elixir
```
<div class="flex items-center w-full flex-start justify-between rounded-xl p-4" style="background-color: #f0f5f9; color: #61758a;">
<div class="flex">
<i class="ri-arrow-left-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="relationships.livemd">Managing Relationships</a>
</div>
<div class="flex">
<i class="ri-home-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="overview.livemd">Home</a>
</div>
<div class="flex">
<a class="flex mr-2" style="color: #61758a;" href="aggregates.livemd">Aggregates</a>
<i class="ri-arrow-right-fill"></i>
</div>
</div>

View File

@@ -0,0 +1,277 @@
# Ash: 5 - Customizing Actions
```elixir
Application.put_env(:ash, :validate_domain_resource_inclusion?, false)
Application.put_env(:ash, :validate_domain_config_inclusion?, false)
Mix.install([{:ash, "~> 3.0"}], consolidate_protocols: false)
```
## Customizing Actions
<div class="flex items-center w-full flex-start justify-between rounded-xl p-4" style="background-color: #f0f5f9; color: #61758a;">
<div class="flex">
<i class="ri-arrow-left-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="attributes.livemd">Attributes</a>
</div>
<div class="flex">
<i class="ri-home-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="overview.livemd">Home</a>
</div>
<div class="flex">
<a class="flex mr-2" style="color: #61758a;" href="relationships.livemd">Relationships</a>
<i class="ri-arrow-right-fill"></i>
</div>
</div>
### In this tutorial you will add custom Actions on the Ticket resource
Create 2 custom actions, `:open` and `:close`.
Custom actions allow you to attach *semantics* to actions.
* Instead of *Creating* a ticket, you can *Open* a ticket.
* Instead of *Updating* the status on a ticket you can *Close* it.
In addition, you can customize the behaviour of these actions. For example, closing a ticket only sets the status to `:closed`.
It also allows you to define what attributes can be set when calling that action. For example for the `:open` action, you can only allow the `:subject` and `:description` to be set.
It does not make sense to set the `:status` in this case as it should always be `:open`. For the closing action you won't allow any attributes to be set.
Custom actions go inside the `actions do ... end` block.
To define the `:open` action, open a `do end` block like so:
<!-- livebook:{"force_markdown":true} -->
```elixir
create :open do
end
```
Same for the `:close` action, but instead of `create`, use `update`.
You then can define the accepted attributes like so:
`accept [:subject, :description]`
Or in the case of the `:close` action:
`accept []`
Then for the `:close` action define a `change`:
`change set_attribute(:status, :closed)`
That's it, you defined your first custom actions.
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
defmodule Tutorial.Support.Ticket do
use Ash.Resource,
domain: Tutorial.Support,
data_layer: Ash.DataLayer.Ets
actions do
defaults [:read]
create :open do
# By default you can provide all public attributes to an action
# This action should only accept the subject
accept [:subject, :description]
end
update :close do
# We don't want to accept any input here
accept []
change set_attribute(:status, :closed)
# A custom change could be added like so:
#
# change MyCustomChange
# change {MyCustomChange, opt: :val}
end
end
attributes do
uuid_primary_key :id
attribute :subject, :string, allow_nil?: false
attribute :description, :string
attribute :status, :atom do
constraints [one_of: [:open, :closed]]
default :open
allow_nil? false
end
create_timestamp :created_at
update_timestamp :updated_at
end
end
defmodule Tutorial.Support do
use Ash.Domain
resources do
resource Tutorial.Support.Ticket
end
end
```
</div>
</details>
### Enter your solution
```elixir
defmodule Tutorial.Support.Ticket do
use Ash.Resource,
domain: Tutorial.Support,
data_layer: Ash.DataLayer.Ets
actions do
defaults [:read]
# <-- Add the :open and :close action
end
attributes do
uuid_primary_key(:id)
attribute :subject, :string, allow_nil?: false
attribute :description, :string
attribute :status, :atom do
constraints one_of: [:open, :closed]
default :open
allow_nil? false
end
create_timestamp :created_at
update_timestamp :updated_at
end
end
defmodule Tutorial.Support do
use Ash.Domain
resources do
resource Tutorial.Support.Ticket
end
end
```
## Open a Ticket
Open a ticket.
Remember, when creating a resource, use a changeset (`Ash.Changeset.for_create/3`), which gets passed to `Ash.create!/1`. But in this case use the `:open` argument instead of `:create`.
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
Tutorial.Support.Ticket
|> Ash.Changeset.for_create(:open, %{subject: "My Subject"})
|> Ash.create!()
```
</div>
</details>
Try setting the `:status` to `:closed` and see if it works.
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
Tutorial.Support.Ticket
|> Ash.Changeset.for_create(:open, %{subject: "My Subject", status: :closed})
|> Ash.create!()
```
</div>
</details>
The output when trying to set `:status` should look something like this:
```
** (Ash.Error.Invalid) Input Invalid
* Invalid value provided for status: cannot be changed.
```
This is because you set the accepted attributes to `:subject` and `:description` only.
**Enter your solution**
```elixir
```
Create a Ticket and store it in the `ticket` variable.
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
ticket =
Tutorial.Support.Ticket
|> Ash.Changeset.for_create(:open, %{subject: "My Subject"})
|> Ash.create!()
```
</div>
</details>
**Enter your solution**
```elixir
```
## Close a Ticket
Close the `ticket` you created in the previous section.
Remember to use `Ash.Changeset.for_update/2` with the `:close` action.
To update use `Ash.update!/1`.
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
ticket
|> Ash.Changeset.for_update(:close)
|> Ash.update!()
```
</div>
</details>
```elixir
```
<div class="flex items-center w-full flex-start justify-between rounded-xl p-4" style="background-color: #f0f5f9; color: #61758a;">
<div class="flex">
<i class="ri-arrow-left-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="attributes.livemd">Attributes</a>
</div>
<div class="flex">
<i class="ri-home-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="overview.livemd">Home</a>
</div>
<div class="flex">
<a class="flex mr-2" style="color: #61758a;" href="relationships.livemd">Relationships</a>
<i class="ri-arrow-right-fill"></i>
</div>
</div>

View File

@@ -0,0 +1,37 @@
# Ash: 0 - Overview
```elixir
Application.put_env(:ash, :validate_domain_resource_inclusion?, false)
Application.put_env(:ash, :validate_domain_config_inclusion?, false)
Mix.install([{:ash, "~> 3.0"}], consolidate_protocols: false)
```
## Overview
In this tutorial you will create Ash Resources, query on them and use ETS as your data storage. You won't be building an overarching application, instead you'll build Resources that introduce you to Ash concepts.
Each tutorial builds on top of the previous one, it is therefore recommended to start the tutorial **sequentially from the beginning** as each section introduces new concepts that will later be used.
The best way to follow the tutorial is reading the instructions in the tutorial, coming up with your own solution, and then **verifying with the proposed solution**. It is also recommended to **not copy paste** code.
The goal of this tutorial is to get you familiar with the basic concepts of Ash, and give you a good sense of what's possible. It serves as a primer for the [Ash documentation](https://hexdocs.pm/ash/get-started.html). Feel free to consult this documentation alongside the tutorial to learn more. That being said, the documenation shouldn't be necessary to complete the tutorial.
Have fun!
<!-- livebook:{"break_markdown":true} -->
1. [Resources](resources.livemd)
2. [Actions](actions.livemd)
3. [Querying](querying.livemd)
4. [Attributes](attributes.livemd)
5. [Customizing Actions](customizing_actions.livemd)
6. [Relationships](relationships.livemd)
7. [Code Interfaces](code_interfaces.livemd)
8. [Aggregates](aggregates.livemd)
9. [Calculations](calculations.livemd)
## Contributing?
_Anything missing? Any errors? Tutorials out of date? [Contributions are welcome!](https://github.com/ash-project/ash_tutorial)_
_When contributing, please follow the guidelines in [diataxis](https://diataxis.fr/tutorials/)._

View File

@@ -0,0 +1,271 @@
# Ash: 3 - Querying
```elixir
Application.put_env(:ash, :validate_domain_resource_inclusion?, false)
Application.put_env(:ash, :validate_domain_config_inclusion?, false)
Mix.install([{:ash, "~> 3.0"}], consolidate_protocols: false)
```
## But First, Data Persistence
<div class="flex items-center w-full flex-start justify-between rounded-xl p-4" style="background-color: #f0f5f9; color: #61758a;">
<div class="flex">
<i class="ri-arrow-left-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="actions.livemd">Actions</a>
</div>
<div class="flex">
<i class="ri-home-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="overview.livemd">Home</a>
</div>
<div class="flex">
<a class="flex mr-2" style="color: #61758a;" href="attributes.livemd">Attributes</a>
<i class="ri-arrow-right-fill"></i>
</div>
</div>
<!-- livebook:{"break_markdown":true} -->
So far we have just created changesets, but we have not actually created any instances of a resource, also known as records. Before we can play around with querying, we need to add a data layer. It is much more common to use [Postgres](https://github.com/ash-project/ash_postgres) in production, but we will use [ETS data layer](https://hexdocs.pm/ash/dsl-ash-datalayer-ets.html#ets) for the remainder of this tutorial to keep dependencies to a minimum.
<!-- livebook:{"force_markdown":true} -->
```elixir
use Ash.Resource,
domain: YourDomain,
data_layer: Ash.DataLayer.Ets
```
## In this tutorial you will do basic Query and CRUD operations
But first you need to enable all basic **CRUD** operations.
Do this by adding default `:read` and `:destroy` actions, as well as `:create` and `:update` actions that accept `[:name]`.
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
defmodule Tutorial.Profile do
use Ash.Resource,
domain: Tutorial,
data_layer: Ash.DataLayer.Ets
actions do
defaults [:read, :destroy]
create :create do
accept [:name]
end
update :update do
accept [:name]
end
end
attributes do
uuid_primary_key :id
attribute :name, :string
end
end
defmodule Tutorial do
use Ash.Domain
resources do
resource Tutorial.Profile
end
end
```
</div>
</details>
### Enter your solution
```elixir
defmodule Tutorial.Profile do
use Ash.Resource,
domain: Tutorial,
data_layer: Ash.DataLayer.Ets
actions do
# <--- Create your actions here
end
attributes do
uuid_primary_key :id
attribute :name, :string
end
end
defmodule Tutorial do
use Ash.Domain
resources do
resource Tutorial.Profile
end
end
```
## Creating
Add 2 Profiles to the database.
* One with the name "Joe Armstrong"
* One with your name
You can create a `Profile` by:
* Creating a Changeset with `Ash.Changeset.for_create(Tutorial.Profile, :create, %{name: "Your Name"})`,
* Then giving the changeset to `Ash.create!()`.
*Hint: Use a pipeline*
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
Tutorial.Profile
|> Ash.Changeset.for_create(:create, %{name: "The Name"})
|> Ash.create!()
```
</div>
</details>
**Enter your solution**
```elixir
```
## Reading
Now, read all the generated Profiles.
Call `Ash.read!/1` with the `Tutorial.Profile` module.
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
Tutorial.Profile
|> Ash.read!()
```
</div>
</details>
**Enter your solution**
```elixir
```
Now fetch the "Joe Armstrong" Profile.
You can achieve this by introducing a filter.
First you'll need to `require Ash.Query`
Then call `Ash.Query.filter(name == "Joe Armstrong")` with the `Tutorial.Profile`.
Put the result into the `joe` variable. `Ash.read!/1` returns a list, so make sure to extract the single returned value out of that list.
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
require Ash.Query
[joe] =
Tutorial.Profile
|> Ash.Query.filter(name == "Joe Armstrong")
|> Ash.read!()
```
</div>
</details>
**Enter your solution**
```elixir
```
You'll use the `joe` variable in the next sections.
## Updating
Now change `Joe Armstrong`'s name to `Neil Armstrong`.
You can do this by providing the `Ash.Changeset.for_update/3` with:
* the resource you want to change, in this case `joe`
* the `:update` atom
* a map of the values you want to change, in this case `%{name: "Neil Armstrong"}`
Then apply the changeset by calling `Ash.update!/1` with the changeset.
*Hint: Using a pipeline might be a good idea.*
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
joe
|> Ash.Changeset.for_update(:update, %{name: "Neil Armstrong"})
|> Ash.update!()
```
</div>
</details>
**Enter your solution**
```elixir
```
## Destroying
Finally, remove `joe` from the database.
Do this using the `Ash.destroy!/1` function.
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
Ash.destroy!(joe)
```
</div>
</details>
**Enter your solution**
```elixir
```
<div class="flex items-center w-full flex-start justify-between rounded-xl p-4" style="background-color: #f0f5f9; color: #61758a;">
<div class="flex">
<i class="ri-arrow-left-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="actions.livemd">Actions</a>
</div>
<div class="flex">
<i class="ri-home-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="overview.livemd">Home</a>
</div>
<div class="flex">
<a class="flex mr-2" style="color: #61758a;" href="attributes.livemd">Attributes</a>
<i class="ri-arrow-right-fill"></i>
</div>
</div>

View File

@@ -0,0 +1,163 @@
# Ash: 6 - Relationships
```elixir
Application.put_env(:ash, :validate_domain_resource_inclusion?, false)
Application.put_env(:ash, :validate_domain_config_inclusion?, false)
Mix.install([{:ash, "~> 3.0"}], consolidate_protocols: false)
```
## Relationships
<div class="flex items-center w-full flex-start justify-between rounded-xl p-4" style="background-color: #f0f5f9; color: #61758a;">
<div class="flex">
<i class="ri-arrow-left-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="customizing_actions.livemd">Customizing Actions</a>
</div>
<div class="flex">
<i class="ri-home-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="overview.livemd">Home</a>
</div>
<div class="flex">
<a class="flex mr-2" style="color: #61758a;" href="code_interfaces.livemd">Code Interfaces</a>
<i class="ri-arrow-right-fill"></i>
</div>
</div>
### In this tutorial you will create a relationship between a Ticket and Representative resource
Create a `relationships do .. end` block in the Ticket resource.
Inside the `relationships` block, define a `belongs_to` representative like so:
`belongs_to :representative, Tutorial.Support.Representative`
<details class="rounded-lg my-4" style="background-color: #96ef86; color: #040604;">
<summary class="cursor-pointer font-bold p-4"></i>Show Solution</summary>
<div class="p-4">
```elixir
defmodule Tutorial.Support.Ticket do
# ...
relationships do
# belongs_to means that the destination attribute is unique, meaning only one related record could exist.
# We assume that the destination attribute is `representative_id` based
# on the name of this relationship and that the source attribute is `representative_id`.
# We create `representative_id` automatically.
belongs_to :representative, Tutorial.Support.Representative
end
# ...
```
</div>
</details>
### Enter your solution
```elixir
defmodule Tutorial.Support.Ticket do
use Ash.Resource,
domain: Tutorial.Support,
data_layer: Ash.DataLayer.Ets
actions do
defaults [:read]
create :create do
accept [:subject, :description, :status]
end
end
attributes do
uuid_primary_key :id
attribute :subject, :string, allow_nil?: false
attribute :description, :string
attribute :status, :atom do
constraints one_of: [:open, :closed]
default :open
allow_nil? false
end
create_timestamp :created_at
update_timestamp :updated_at
end
# <-- Add the relationship here
end
defmodule Tutorial.Support.Representative do
use Ash.Resource,
domain: Tutorial.Support,
data_layer: Ash.DataLayer.Ets
actions do
defaults [:read]
create :create do
accept [:name]
end
end
attributes do
uuid_primary_key(:id)
attribute(:name, :string)
end
end
```
```elixir
defmodule Tutorial.Support do
use Ash.Domain
resources do
resource Tutorial.Support.Ticket
resource Tutorial.Support.Representative
end
end
```
## Creating a Ticket record with a representative Relationship
First, create the representative Joe.
```elixir
# Creates a Representative
joe =
Tutorial.Support.Representative
|> Ash.Changeset.for_create(:create, %{name: "Joe Armstrong"})
|> Ash.create!()
```
Next up, create a ticket and assign the representative Joe to the ticket.
```elixir
# Creates a Ticket with the representative
Tutorial.Support.Ticket
|> Ash.Changeset.for_create(:create, %{subject: "My spoon is too big!", representative_id: joe.id})
|> Ash.create!()
# `load!/2` loads in the representative. Try removing this line to see what changes
|> Ash.load!([:representative])
```
As you can see it didn't quite work. The `representative_id` is not accepted by the create action on `Tutorial.Support.Ticket`. If you add `:representative_id` to that list,
and try again you should now see that the representative was correctly assigned.
<!-- livebook:{"break_markdown":true} -->
<div class="flex items-center w-full flex-start justify-between rounded-xl p-4" style="background-color: #f0f5f9; color: #61758a;">
<div class="flex">
<i class="ri-arrow-left-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="customizing_actions.livemd">Customizing Actions</a>
</div>
<div class="flex">
<i class="ri-home-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="overview.livemd">Home</a>
</div>
<div class="flex">
<a class="flex mr-2" style="color: #61758a;" href="code_interfaces.livemd">Code Interfaces</a>
<i class="ri-arrow-right-fill"></i>
</div>
</div>

View File

@@ -0,0 +1,79 @@
# Ash: 1 - Resources
```elixir
Application.put_env(:ash, :validate_domain_resource_inclusion?, false)
Application.put_env(:ash, :validate_domain_config_inclusion?, false)
Mix.install([{:ash, "~> 3.0"}], consolidate_protocols: false)
```
## What is a resource?
<div class="flex items-center w-full flex-start justify-between rounded-xl p-4" style="background-color: #f0f5f9; color: #61758a;">
<div class="flex">
<i class="ri-home-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="overview.livemd">Home</a>
</div>
<div class="flex">
<a class="flex mr-2" style="color: #61758a;" href="actions.livemd">Actions</a>
<i class="ri-arrow-right-fill"></i>
</div>
</div>
<!-- livebook:{"break_markdown":true} -->
The fundamental building blocks of Ash are resources, actions, and domains. The resource is Ash's central concept, so we need to define it well for anything else to make sense, so let's turn to the [Ash Glossary](https://hexdocs.pm/ash/glossary.html#resource).
> The central concept in Ash, a resource is a domain model object in your system, the nouns that your app revolves around. Resources contain definitions of the data they hold in the form of attributes, but also define actions that can be taken on that data and actors that are allowed to run them.
>
> It is not a strict requirement that resources contain data - they can be used purely to create a standard interface for performing tasks - but in practice, most resources will be used to manage data.
A domain simply groups together related resources. Some things can be defined in a domain instead of a resource. We will do this when we get to code interfaces later on.
## Example Code
Here we have a minimal resource called `Profile` that has, aside from its primary key, a single string attribute `name`.
```elixir
defmodule Tutorial.Profile do
use Ash.Resource,
domain: Tutorial
attributes do
uuid_primary_key :id
attribute :name, :string
end
end
```
To be able to do anything with this resource, we also need a domain.
```elixir
defmodule Tutorial do
use Ash.Domain
resources do
resource Tutorial.Profile
end
end
```
Now we can create a changeset for the resource.
```elixir
Tutorial.Profile
|> Ash.Changeset.new()
|> Ash.Changeset.change_attributes(%{name: "John Doe"})
```
Changesets are used to create and update data in Ash. (**Note:** without a data layer, you won't be able to persist creations and changes. This will be shown in a later tutorial.)
<div class="flex items-center w-full flex-start justify-between rounded-xl p-4" style="background-color: #f0f5f9; color: #61758a;">
<div class="flex">
<i class="ri-home-fill"></i>
<a class="flex ml-2" style="color: #61758a;" href="overview.livemd">Home</a>
</div>
<div class="flex">
<a class="flex mr-2" style="color: #61758a;" href="actions.livemd">Actions</a>
<i class="ri-arrow-right-fill"></i>
</div>
</div>

59
elixir_koan.livemd Normal file
View File

@@ -0,0 +1,59 @@
# elixir_koan
## Section
```elixir
Integer.parse("2A", 16)
```
```elixir
Integer.parse("5 years")
```
```elixir
Integer.parse("1.2")
```
```elixir
Float.parse("34.5")
```
```elixir
Float.parse("1.5 million dollars")
```
```elixir
Float.ceil(34.25)
```
```elixir
Float.ceil(34.25, 1)
```
```elixir
Float.floor(99.99)
```
```elixir
Float.floor(12.345,2)
```
```elixir
Float.round(5.5)
```
```elixir
Float.round(5.4)
```
```elixir
Float.round(8.94, 1)
```
```elixir
Float.round(-5.5674, 3)
```
```elixir
tuple_size({:a, :b, :c})
```

View File

@@ -24,3 +24,11 @@ Kernel./(10, 3)
```elixir ```elixir
Kernel.*(10, 3) Kernel.*(10, 3)
``` ```
```elixir
:simple != :easy
```
```elixir
:hello == :world
```