Resource
Ash resources are used to model data and define actions which are used to manipulate that data. In the Ash world we often compare the resources with nouns and the actions with verbs.
In Ash 3.0, resources are grouped into domains - context boundaries where related resources are defined together. This helps organize your application and makes it easier to understand the relationships between resources.
To-Do-List Example
To dive into resources we use a simple to-do-list application as an example. As a preparation for this, use Igniter to create a new application:
$ mix archive.install hex igniter_new
$ mix igniter.new app --install ash
$ cd app
Alternatively, you can follow the Ash Setup Guide for other setup options.
We want to create a task resource which has a content attribute
and an id attribute as a primary key. We also want to include the
actions create, read, update and delete. Ash provides those
actions for free but we have to include them into the resource.
| In Ash 3.x, resources live inside a domain. The domain is a context boundary for a set of related resources and a place to hang shared functionality (code interfaces, policies, pub/sub). |
Configure the Domain
First, we register our domain in config/config.exs so Ash knows about
it at compile time (otherwise Ash prints a warning on boot):
import Config
config :app, :ash_domains, [App.ToDoList]
Now we create the ToDoList domain module which contains the
resource Task.
defmodule App.ToDoList do
use Ash.Domain, otp_app: :app
resources do
resource App.ToDoList.Task
end
end
For the resource(s) we create a new directory:
$ mkdir -p lib/app/to_do_list
Configure the Resource
The resource defines attributes which are the fields of the
resource. In our case we have two attributes: id and content. The
id attribute is special: it is the primary key of the resource, and
the uuid_primary_key macro handles type, default, and indexing for us.
The content attribute is a plain string.
defmodule App.ToDoList.Task do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
domain: App.ToDoList,
otp_app: :app
attributes do
uuid_primary_key :id
attribute :content, :string, public?: true (1)
end
end
| 1 | Ash 3.x attributes are private by default. Mark :content as
public?: true so that actions can accept it as input and so that
JSON/GraphQL layers can expose it. Forgetting this flag is the single
most common Ash beginner trap, the error message is helpful but
nothing prints the first time you try it. |
In this example we use the Ash.DataLayer.Ets as a database layer. ETS (Erlang Term Storage) is an in-memory data store which is built into your Erlang system. For our training purpose this is ideal because we don’t have to install and configure a database.
ETS does not save any data to disk! With every restart of iex
you have to re-create the example data. For production applications,
you should use AshPostgres or another persistent data layer.
|
The resulting directory structure should look like this:
$ tree lib
lib
├── app
│ ├── to_do_list
│ │ └── task.ex
│ └── to_do_list.ex
└── app.ex
3 directories, 3 files
We now have a resource but because we haven’t defined any actions we can’t do anything with it yet. Let’s change that.
Create
To create a resource, we need to add the create action to the
resource. In Ash 3.0, we also add a code interface to our domain: a
short define :name, action: :some_action line that generates a plain
Elixir function (for example App.ToDoList.create_task/1) so you can
call the action directly, instead of building a changeset by hand.
First, let’s add the create action to our resource:
defmodule App.ToDoList.Task do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
domain: App.ToDoList,
otp_app: :app
attributes do
uuid_primary_key :id
attribute :content, :string, public?: true (1)
end
actions do
default_accept [:content] (2)
defaults [:create]
end
end
| 1 | Mark the attribute as public so that the create action can accept it. In Ash 3.x, attributes are private by default. |
| 2 | default_accept [:content] declares which attributes the default
actions accept as input. Without it, create would reject the
content field. |
Then, we add a code interface definition to our domain:
defmodule App.ToDoList do
use Ash.Domain, otp_app: :app
resources do
resource App.ToDoList.Task do
define :create_task, action: :create
end
end
end
This creates a App.ToDoList.create_task/1-2 function that we can use to create tasks.
Fire up the IEx (Elixir’s Interactive Shell) to create your first task:
$ iex -S mix
Compiling 2 files (.ex)
Erlang/OTP 28 [erts-16.2.2] [...]
Interactive Elixir (1.20.0-rc.4) [...]
iex(1)> App.ToDoList.create_task!(%{content: "Mow the lawn"})
%App.ToDoList.Task{
id: "8e868c09-c0d0-4362-8270-09272acab769",
content: "Mow the lawn",
__meta__: #Ecto.Schema.Metadata<:loaded>
}
iex(2)>
Ash 3.x logs a [debug] Creating App.ToDoList.Task: Setting %{…}
line before each write by default. It is harmless and the book omits it
to keep the output compact. If you want to suppress it yourself, call
Logger.configure(level: :info) at the top of your IEx session, or add
config :logger, level: :info to config/dev.exs.
|
The function App.ToDoList.create_task!/1-2 raises an error if
something goes wrong (e.g. a validation error). Alternatively you can
use App.ToDoList.create_task/1-2 which returns a tuple with the
status and the resource.
iex(2)> App.ToDoList.create_task(%{content: "Mow the lawn"})
{:ok,
%App.ToDoList.Task{
id: "a8430505-ef7e-4f64-bc2c-2a6db216d8ea",
content: "Mow the lawn",
__meta__: #Ecto.Schema.Metadata<:loaded>
}}
iex(3)>
|
You can still create a task the long way with the following code:
The |
Read
Writing is one thing, but it only pays off if we can read the data
back. Let us add a read action to the resource, and matching code
interfaces on the domain.
defmodule App.ToDoList.Task do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
domain: App.ToDoList,
otp_app: :app
attributes do
uuid_primary_key :id
attribute :content, :string, public?: true
end
actions do
default_accept [:content]
defaults [:create, :read] (1)
end
end
| 1 | Add :read to the list of default actions. |
defmodule App.ToDoList do
use Ash.Domain, otp_app: :app
resources do
resource App.ToDoList.Task do
define :create_task, action: :create
define :list_tasks, action: :read (1)
define :get_task, action: :read, get_by: [:id] (2)
end
end
end
| 1 | list_tasks returns every task. |
| 2 | get_task is a read action that looks up a single task by its
primary key. The get_by: [:id] option tells Ash to expose a bang and
non-bang function that takes the id directly. |
Index: list all tasks
list_tasks/0 (and its raising sibling list_tasks!/0) returns every
task in the store:
$ iex -S mix
Erlang/OTP 28 [erts-16.2.2] [...]
Interactive Elixir (1.20.0-rc.4) [...]
iex(1)> App.ToDoList.create_task!(%{content: "Mow the lawn"})
%App.ToDoList.Task{
id: "881c6c08-223c-41b1-9d61-2d3a40e478bd",
content: "Mow the lawn",
__meta__: #Ecto.Schema.Metadata<:loaded>
}
iex(2)> App.ToDoList.create_task!(%{content: "Buy milk"})
%App.ToDoList.Task{
id: "22b11587-20fe-40d2-830e-50f8930c13c9",
content: "Buy milk",
__meta__: #Ecto.Schema.Metadata<:loaded>
}
iex(3)> App.ToDoList.list_tasks!() |> Enum.map(& &1.content)
["Buy milk", "Mow the lawn"]
iex(4)> App.ToDoList.list_tasks()
{:ok,
[
%App.ToDoList.Task{
id: "881c6c08-223c-41b1-9d61-2d3a40e478bd",
content: "Mow the lawn",
__meta__: #Ecto.Schema.Metadata<:loaded>
},
%App.ToDoList.Task{
id: "22b11587-20fe-40d2-830e-50f8930c13c9",
content: "Buy milk",
__meta__: #Ecto.Schema.Metadata<:loaded>
}
]}
ETS does not guarantee row order, so the two tasks can come back
in either order. In production, use sort in a read action (or
Enum.sort_by/2) when ordering matters.
|
An empty store simply returns an empty list:
iex(1)> App.ToDoList.list_tasks!()
[]
iex(2)> App.ToDoList.list_tasks()
{:ok, []}
Show: fetch a single task by id
get_task/1 and get_task!/1 look up a task by its primary key:
iex(1)> {:ok, task} = App.ToDoList.create_task(%{content: "Mow the lawn"})
{:ok, %App.ToDoList.Task{id: "a5648b48-4eb3-443d-aba7-fafbbfedc564",
content: "Mow the lawn", __meta__: #Ecto.Schema.Metadata<:loaded>}}
iex(2)> App.ToDoList.get_task(task.id)
{:ok,
%App.ToDoList.Task{
id: "a5648b48-4eb3-443d-aba7-fafbbfedc564",
content: "Mow the lawn",
__meta__: #Ecto.Schema.Metadata<:loaded>
}}
iex(3)> App.ToDoList.get_task!(task.id)
%App.ToDoList.Task{
id: "a5648b48-4eb3-443d-aba7-fafbbfedc564",
content: "Mow the lawn",
__meta__: #Ecto.Schema.Metadata<:loaded>
}
When the id does not exist, the non-bang version returns an error tuple and the bang version raises:
iex(1)> App.ToDoList.get_task("not-in-the-db")
{:error,
%Ash.Error.Invalid{
errors: [
%Ash.Error.Query.NotFound{
primary_key: nil,
resource: App.ToDoList.Task,
...
}
]
}}
iex(2)> App.ToDoList.get_task!("not-in-the-db")
** (Ash.Error.Invalid) Invalid Error
[...]
* record not found
[...]
Ash 3.x wraps errors in Ash.Error.Invalid and exposes the
individual errors in the errors: list. The bang version raises
Ash.Error.Invalid rather than the inner error class; pattern match on
the inner errors if you need to branch on the cause.
# Update
|
By now the pattern should feel familiar: add an action to the resource, expose a code interface on the domain, call the generated function.
defmodule App.ToDoList.Task do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
domain: App.ToDoList,
otp_app: :app
attributes do
uuid_primary_key :id
attribute :content, :string, public?: true
end
actions do
default_accept [:content]
defaults [:create, :read, :update] (1)
end
end
| 1 | Added :update to the defaults. |
defmodule App.ToDoList do
use Ash.Domain, otp_app: :app
resources do
resource App.ToDoList.Task do
define :create_task, action: :create
define :list_tasks, action: :read
define :get_task, action: :read, get_by: [:id]
define :update_task, action: :update (1)
end
end
end
| 1 | Exposes update_task/2 (and update_task!/2). |
Let’s try it in IEx:
$ iex -S mix
Erlang/OTP 28 [erts-16.2.2] [...]
Interactive Elixir (1.20.0-rc.4) [...]
iex(1)> {:ok, task} = App.ToDoList.create_task(%{content: "Mow the lawn"})
{:ok,
%App.ToDoList.Task{
id: "d4c8cb9a-10b7-45f4-bece-dcea0fd16e5f",
content: "Mow the lawn",
__meta__: #Ecto.Schema.Metadata<:loaded>
}}
iex(2)> App.ToDoList.update_task(task, %{content: "Play golf"})
{:ok,
%App.ToDoList.Task{
id: "d4c8cb9a-10b7-45f4-bece-dcea0fd16e5f",
content: "Play golf",
__meta__: #Ecto.Schema.Metadata<:loaded>
}}
iex(3)> App.ToDoList.update_task!(task, %{content: "Buy milk"})
%App.ToDoList.Task{
id: "d4c8cb9a-10b7-45f4-bece-dcea0fd16e5f",
content: "Buy milk",
__meta__: #Ecto.Schema.Metadata<:loaded>
}
The first argument is the record you are updating (so Ash knows which row to change), the second is the map of new values. # Destroy (delete)
Last of the four CRUD actions is :destroy. Same pattern as the
others:
defmodule App.ToDoList.Task do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
domain: App.ToDoList,
otp_app: :app
attributes do
uuid_primary_key :id
attribute :content, :string, public?: true
end
actions do
default_accept [:content]
defaults [:create, :read, :update, :destroy] (1)
end
end
| 1 | Added :destroy to the defaults. |
defmodule App.ToDoList do
use Ash.Domain, otp_app: :app
resources do
resource App.ToDoList.Task do
define :create_task, action: :create
define :list_tasks, action: :read
define :get_task, action: :read, get_by: [:id]
define :update_task, action: :update
define :destroy_task, action: :destroy (1)
end
end
end
| 1 | Exposes destroy_task/1 and destroy_task!/1. |
Let’s try it out:
$ iex -S mix
Erlang/OTP 28 [erts-16.2.2] [...]
Interactive Elixir (1.20.0-rc.4) [...]
iex(1)> {:ok, task} = App.ToDoList.create_task(%{content: "Mow the lawn"})
{:ok,
%App.ToDoList.Task{
id: "5bd2b15e-fd29-4d3f-9356-cbfe06ea7eee",
content: "Mow the lawn",
__meta__: #Ecto.Schema.Metadata<:loaded>
}}
iex(2)> App.ToDoList.destroy_task(task)
:ok
iex(3)> App.ToDoList.get_task(task.id) (1)
{:error,
%Ash.Error.Invalid{
errors: [
%Ash.Error.Query.NotFound{
primary_key: nil,
resource: App.ToDoList.Task,
...
}
]
}}
iex(4)>
| 1 | The task has been destroyed, so a get_task lookup by the old id
returns an error tuple. See read for
details on Ash 3.x’s Ash.Error.Invalid wrapper. |
Defaults
Attributes can declare a default value, plus a few constraints that
keep bad data out. Let us add two new attributes to our task: a
priority integer (optional, 1-3) and an is_done boolean (required,
defaults to false).
defmodule App.ToDoList.Task do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
domain: App.ToDoList,
otp_app: :app
attributes do
uuid_primary_key :id
attribute :content, :string do
allow_nil? false (1)
public? true
constraints min_length: 1, max_length: 255
end
attribute :priority, :integer do
allow_nil? true
public? true
constraints min: 1, max: 3
end
attribute :is_done, :boolean do
allow_nil? false
public? true
default false (2)
end
end
actions do
default_accept [:content, :priority, :is_done]
defaults [:create]
end
end
| 1 | allow_nil? false makes the database reject nil and the
changeset report "is required". |
| 2 | default false fills the attribute in automatically if the caller
does not supply it. |
Now we can create a new task without providing a value for is_done:
iex> App.ToDoList.create_task(%{content: "Mow the lawn"})
{:ok,
%App.ToDoList.Task{
id: "07d5b3f1-b960-4390-8980-5e731251d7af",
content: "Mow the lawn",
priority: nil,
is_done: false,
__meta__: #Ecto.Schema.Metadata<:loaded>
}}
Limiting the accept list
default_accept is also the place to refuse input on specific
attributes. Say we want users to set :content and :priority, but
not flip :is_done through a create or update (we would update that
through a dedicated action such as :complete):
actions do
default_accept [:content, :priority] (1)
defaults [:create]
end
| 1 | :is_done is intentionally omitted. |
If someone tries anyway, Ash returns a clear error:
$ iex -S mix
Compiling 2 files (.ex)
Erlang/OTP 28 [erts-16.2.2] [...]
Interactive Elixir (1.20.0-rc.4) [...]
iex(1)> App.ToDoList.create_task(%{content: "Mow the lawn", is_done: true})
{:error,
%Ash.Error.Invalid{
errors: [
%Ash.Error.Invalid.NoSuchInput{
resource: App.ToDoList.Task,
action: :create,
input: :is_done,
inputs: MapSet.new([:priority, :content, "content", "priority"]),
did_you_mean: [],
class: :invalid,
...
}
],
...
}}
iex(2)>