Ash Framework
The Ash Framework is currently the most powerful and productive set of tools in the Phoenix ecosystem for developers. I wrote this book to have written material for my beginner’s training (please write me an email to sw@wintermeyer-consulting.de if you are interested in a training). But sharing is caring so I decided to make most of it available online too.
The Ash Framework does offer a lot of documentation and a vibrant community which is ready to help on Discord. But Discord is not everyone’s cup of tea (some companies don’t allow the usage at all) and the official documentation is often a bit too theoretical.
This beginner’s book provides small copy-and-pastable examples. In addition I publish short tutorial videos - without sound so that you can watch them in the office too - on the @elixir-phoenix-ash YouTube channel.
I do not build one big monolithic application in this book but use smaller examples to describe features. This has the disadvantage that I have to repeat some boilerplate code in each example. But it has the big advantage that you can easily understand each example without the rest of the book. If a specific feature is of no interest for you just skip that section.
Minimal Ash 2.x Setup Guide
There are a couple of steps we need to do for every new mix project
which uses Ash. This guide will walk you through those steps. For this
book we use the very generic name app
for the application. Please
use a more meaningful name for your own applications. We keep using
App
in this guide to make it easier to mix and match seperate parts
of this book if you want to.
To use app as the default application name in this book has
one major drawback: You have to either rename old project directories or
delete them when creating a new one. Alternatively you can change the
appname with all examples to something like app2 , 'app2', 'foobar', etc.
|
Let’s start with a fresh Mix project:
$ mix new --sup app
$ cd app
We change the dependency in mix.exs
to add Ash (find the latest
Ash version number at https://hex.pm/packages/ash):
defp deps do
[
{:ash, "~> 2.14.16"} # Add this line
]
end
Run mix deps.get
to install it:
$ mix deps.get
To make sure that we get the right formatting for our code, we change
the file .formatter.exs
to include the Ash formatter:
[
# add the next line
import_deps: [:ash],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
The Elixir formatter is a great tool to keep your code consistent.
It is highly recommended to use run the command mix format before
committing your code into a repository.
|
Since this is a fresh project we need to create a config.exs
file:
$ mkdir config
$ touch config/config.exs
And add the configuration for Ash to config/config.exs
:
import Config
config :ash, :use_all_identities_in_manage_relationship?, false
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.
To-Do-List Example
To dive into resources we use a simple to-do-list application as an example. As a preperation for this please use the Minimal Ash 2.x Setup Guide to generate a new Elixir application.
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 the Ash world a resource needs to be registered to an
interal API. Please don’t think of this as an external WebAPI. Ash
uses the term API for an internal programming interface. We call
our API ToDoList .
|
Do you like video tutorials? Have a look at the 3 minute video "Ash Resource - Create, Read, Update and Destroy" in our @elixir-phoenix-ash YouTube Channel. |
Configure the internal API
We have to add the new internal API to the :ash_apis
in our
config.exs
file.
import Config
config :ash, :use_all_identities_in_manage_relationship?, false
# Add this line
config :app, :ash_apis, [App.ToDoList]
Now we create the ToDoList
API module which contains the
resource Task
.
defmodule App.ToDoList do
use Ash.Api
resources do
resource App.ToDoList.Task
end
end
For the resource(s) we create a new directory:
$ mkdir -p lib/app/to_do_list/resources
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 a special attribute because it is the primary key of the resource. We
use the uuid_primary_key
macro to define the id
attribute as a primary
key. The content
attribute is a simple string.
defmodule App.ToDoList.Task do
use Ash.Resource, data_layer: Ash.DataLayer.Ets (1)
attributes do
uuid_primary_key :id
attribute :content, :string
end
end
1 | 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 build into your Erlang system. For our training purpose this
is ideal because we don’t have to install and configure a database (e.g.
PostgreSQL). But ETS does not save any data to disk! With every
restart of iex you have to re-create the example data. |
The resulting directory structure should look like this:
$ tree lib
lib
├── app
│ ├── application.ex
│ ├── to_do_list
│ │ └── resources
│ │ └── task.ex
│ └── to_do_list.ex
└── app.ex
4 directories, 4 files
We now have a resource but because we haven’t defined any actions we
can’t do anything with it. Let’s add a create
action.
Create
To create a resource we have to add the needed create
action to the
resource. In addition we add a code_interface
section to the task
resource for some Ash magic which creates a
App.ToDoList.Task.create/1
and a App.ToDoList.Task.create!/1
function.
defmodule App.ToDoList.Task do
use Ash.Resource, data_layer: Ash.DataLayer.Ets
attributes do
uuid_primary_key :id
attribute :content, :string
end
actions do
defaults [:create]
end
code_interface do
define_for App.ToDoList
define :create
end
end
Fire up the IEx (Elixir’s Interactive Shell) to create your first task:
$ iex -S mix
Compiling 2 files (.ex)
Erlang/OTP 26 [erts-14.0.2] [...]
Interactive Elixir (1.15.5) [...]
iex(1)> App.ToDoList.Task.create!(%{content: "Mow the lawn"})
#App.ToDoList.Task<
__meta__: #Ecto.Schema.Metadata<:built, "">,
id: "8e868c09-c0d0-4362-8270-09272acab769",
content: "Mow the lawn",
aggregates: %{},
calculations: %{},
...
>
iex(2)>
The function App.ToDoList.Task.create!/1
raises an error if
something goes wrong (e.g. a validation error). Alternatively you can
use App.ToDoList.Task.create/1
which returns a tuple with the status
and the resource.
iex(2)> App.ToDoList.Task.create(%{content: "Mow the lawn"})
{:ok,
#App.ToDoList.Task<
__meta__: #Ecto.Schema.Metadata<:built, "">,
id: "a8430505-ef7e-4f64-bc2c-2a6db216d8ea",
content: "Mow the lawn",
aggregates: %{},
calculations: %{},
...
>}
iex(3)>
You can still create a task the long way with the following code:
|
Read
Writing is one thing but it only makes sense if you can read the written
data too. To make our life a bit easier we add a read
action and a
code_interface
define for read
:
defmodule App.ToDoList.Task do
use Ash.Resource, data_layer: Ash.DataLayer.Ets
attributes do
uuid_primary_key :id
attribute :content, :string
end
actions do
# add :read here
defaults [:create, :read]
end
code_interface do
define_for App.ToDoList
define :create
# add this line
define :read
end
end
Index
To fetch a list of all tasks in the database we can use the
App.ToDoList.Task.read!/1
(results in a list) or
App.ToDoList.Task.read/1
(results in a tuple with a status and a
list) functions. Those are automatically generated by Ash by the
code_interface
part of the task
resource.
$ iex -S mix
Compiling 2 files (.ex)
Erlang/OTP 26 [...]
Interactive Elixir (1.15.5) [...]
iex(1)> App.ToDoList.Task.create!(%{content: "Mow the lawn"})
#App.ToDoList.Task<
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "881c6c08-223c-41b1-9d61-2d3a40e478bd",
content: "Mow the lawn",
...
>
iex(2)> App.ToDoList.Task.create!(%{content: "Buy milk"})
#App.ToDoList.Task<
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "22b11587-20fe-40d2-830e-50f8930c13c9",
content: "Buy milk",
...
>
iex(3)> App.ToDoList.Task.read! |> Enum.map(& &1.content)
["Buy milk", "Mow the lawn"]
iex(4)> App.ToDoList.Task.read
{:ok,
[
#App.ToDoList.Task<
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "22b11587-20fe-40d2-830e-50f8930c13c9",
content: "Buy milk",
...
>,
#App.ToDoList.Task<
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "881c6c08-223c-41b1-9d61-2d3a40e478bd",
content: "Mow the lawn",
...
>
]}
iex(5)>
If you have an empty database this is your result for both functions:
$ iex -S mix
Erlang/OTP 26 [...]
Interactive Elixir (1.15.5) [...]
iex(1)> App.ToDoList.Task.read!
[]
iex(2)> App.ToDoList.Task.read
{:ok, []}
iex(3)>
Show
Often one wants to fetch a specific set of data by an id
. The Ash
code_interface
has an easy solution for this common scenario.
defmodule App.ToDoList.Task do
use Ash.Resource, data_layer: Ash.DataLayer.Ets
attributes do
uuid_primary_key :id
attribute :content, :string
end
actions do
defaults [:create, :read]
end
code_interface do
define_for App.ToDoList
define :create
define :read
# add this line
define :by_id, get_by: [:id], action: :read (1)
end
end
1 | This generates the functions App.ToDoList.Task.by_id/1 and
App.ToDoList.Task.by_id!/1 |
Let’s try it out:
$ iex -S mix
Erlang/OTP 26 [...]
Interactive Elixir (1.15.5) [...]
iex(1)> alias App.ToDoList.Task
App.ToDoList.Task
iex(2)> Task.read (1)
{:ok, []}
iex(3)> {:ok, task} = Task.create(%{content: "Mow the lawn"})
{:ok,
#App.ToDoList.Task<
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "a5648b48-4eb3-443d-aba7-fafbbfedc564",
content: "Mow the lawn",
...
>}
iex(4)> task.id
"a5648b48-4eb3-443d-aba7-fafbbfedc564"
iex(5)> Task.by_id("a5648b48-4eb3-443d-aba7-fafbbfedc564")
{:ok,
#App.ToDoList.Task<
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "a5648b48-4eb3-443d-aba7-fafbbfedc564",
content: "Mow the lawn",
...
>}
iex(6)> Task.by_id!("a5648b48-4eb3-443d-aba7-fafbbfedc564")
#App.ToDoList.Task<
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "a5648b48-4eb3-443d-aba7-fafbbfedc564",
content: "Mow the lawn",
...
>
1 | Just to establish that there are no tasks in the database. |
And here an example when there is no task in the database for the
given id
:
$ iex -S mix
Erlang/OTP 26 [...]
Interactive Elixir (1.15.5) [...]
iex(1)> App.ToDoList.Task.by_id("not-in-the-db")
{:error,
%Ash.Error.Query.NotFound{
primary_key: nil,
resource: App.ToDoList.Task,
changeset: nil,
query: nil,
error_context: [],
vars: [],
path: [],
stacktrace: #Stacktrace<>,
class: :invalid
}}
iex(2)> App.ToDoList.Task.by_id!("not-in-the-db")
** (Ash.Error.Query.NotFound) record not found
[...]
Update
Ash provides a simple way to update a resource and by now you can probably guess how it works:
defmodule App.ToDoList.Task do
use Ash.Resource, data_layer: Ash.DataLayer.Ets
attributes do
uuid_primary_key :id
attribute :content, :string
end
actions do
# add :update to the list
defaults [:create, :read, :update]
end
code_interface do
define_for App.ToDoList
define :create
define :read
define :by_id, get_by: [:id], action: :read
# add this line
define :update
end
end
Let’s try it out:
$ iex -S mix
Erlang/OTP 26 [...]
Interactive Elixir (1.15.5) [...]
iex(1)> alias App.ToDoList.Task
App.ToDoList.Task
iex(2)> {:ok, task} = Task.create(%{content: "Mow the lawn"})
{:ok,
#App.ToDoList.Task<
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "d4c8cb9a-10b7-45f4-bece-dcea0fd16e5f",
content: "Mow the lawn",
...
>}
iex(3)> Task.update(task, %{content: "Play golf"})
{:ok,
#App.ToDoList.Task<
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "d4c8cb9a-10b7-45f4-bece-dcea0fd16e5f",
content: "Play golf",
...
>}
iex(4)> Task.update!(task, %{content: "Buy milk"})
#App.ToDoList.Task<
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "d4c8cb9a-10b7-45f4-bece-dcea0fd16e5f",
content: "Buy milk",
...
>
Destroy (delete)
And finally we can destroy a resource. Again, this is very similar to the other actions:
defmodule App.ToDoList.Task do
use Ash.Resource, data_layer: Ash.DataLayer.Ets
attributes do
uuid_primary_key :id
attribute :content, :string
end
actions do
# add :delete to list
defaults [:create, :read, :update, :destroy]
end
code_interface do
define_for App.ToDoList
define :create
define :read
define :by_id, get_by: [:id], action: :read
define :update
# Add this line
define :destroy
end
end
Let’s try it out:
iex -S mix
Erlang/OTP 26 [...]
Interactive Elixir (1.15.5) [...]
iex(1)> {:ok, task} = App.ToDoList.Task.create(%{content: "Mow the lawn"})
{:ok,
#App.ToDoList.Task<
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "5bd2b15e-fd29-4d3f-9356-cbfe06ea7eee",
content: "Mow the lawn",
...
>}
iex(2)> App.ToDoList.Task.destroy(task)
:ok
iex(3)> App.ToDoList.Task.by_id(task.id) (1)
{:error,
%Ash.Error.Query.NotFound{
primary_key: nil,
resource: App.ToDoList.Task,
changeset: nil,
query: nil,
error_context: [],
vars: [],
path: [],
stacktrace: #Stacktrace<>,
class: :invalid
}}
iex(4)>
1 | Because the task is destroyed we can’t find it anymore. ## Validations |
Validation of user input is a key for a smooth running application.
Otherwise we end up with faulty datasets in our database. For our
example we will add one validation for content
to make sure that
content will always have a length between 1 and 255 characters. And we
add an other attribute priority
which is an integer and has to be
either nil
or between 1 and 3.
defmodule App.ToDoList.Task do
use Ash.Resource, data_layer: Ash.DataLayer.Ets
attributes do
uuid_primary_key :id
attribute :content, :string do
allow_nil? false
constraints min_length: 1, max_length: 255
end
attribute :priority, :integer do
allow_nil? true
constraints min: 1, max: 3
end
end
actions do
defaults [:create]
end
code_interface do
define_for App.ToDoList
define :create
end
end
Let’s try to create a new task with no content. I use Task.create!/1
and Task.create/1
to show the different output of each function.
$ iex -S mix
Compiling 2 files (.ex)
Erlang/OTP 26 [...]
Interactive Elixir (1.15.5) [...]
iex(1)> App.ToDoList.Task.create()
{:error,
%Ash.Error.Invalid{
errors: [
%Ash.Error.Changes.Required{
field: :content,
type: :attribute,
resource: App.ToDoList.Task,
changeset: nil,
query: nil,
error_context: [],
vars: [],
path: [],
stacktrace: #Stacktrace<>,
class: :invalid
}
],
stacktraces?: true,
changeset: #Ash.Changeset<
api: App.ToDoList,
action_type: :create,
action: :create,
attributes: %{},
relationships: %{},
errors: [
%Ash.Error.Changes.Required{
field: :content,
type: :attribute,
resource: App.ToDoList.Task,
changeset: nil,
query: nil,
error_context: [],
vars: [],
path: [],
stacktrace: #Stacktrace<>,
class: :invalid
}
],
data: #App.ToDoList.Task<
__meta__: #Ecto.Schema.Metadata<:built, "">,
id: nil,
content: nil,
priority: nil,
...
>,
valid?: false
>,
query: nil,
error_context: [nil],
vars: [],
path: [],
stacktrace: #Stacktrace<>,
class: :invalid
}}
iex(2)> App.ToDoList.Task.create!()
** (Ash.Error.Invalid) Input Invalid
* attribute content is required
(ash 2.14.16) lib/ash/api/api.ex:2169: Ash.Api.unwrap_or_raise!/3
iex(2)>
Now let’s see what happens when we try to create a task with a valid
content
but with a priority
which is not between 1 and 3.
iex(2)> App.ToDoList.Task.create!(%{content: "Mown the lawn", priority: 10})
** (Ash.Error.Invalid) Input Invalid
* Invalid value provided for priority: must be less than or equal to 3.
10
(ash 2.14.16) lib/ash/api/api.ex:2169: Ash.Api.unwrap_or_raise!/3
iex(3)>
Defaults
Attributes can have default values. Let’s add a is_done
boolean
attribute with a default of false
and a validation that doesn’t allow
nil
for this attribute:
defmodule App.ToDoList.Task do
use Ash.Resource, data_layer: Ash.DataLayer.Ets
attributes do
uuid_primary_key :id
attribute :content, :string do
allow_nil? false
constraints min_length: 1, max_length: 255
end
attribute :priority, :integer do
allow_nil? true
constraints min: 1, max: 3
end
attribute :is_done, :boolean do
allow_nil? false
default false
end
end
actions do
defaults [:create]
end
code_interface do
define_for App.ToDoList
define :create
end
end
Now we can create a new task without providing a value for is_done
:
iex> App.ToDoList.Task.create(%{content: "Mown the lawn"})
{:ok,
#App.ToDoList.Task<
__meta__: #Ecto.Schema.Metadata<:built, "">,
id: "07d5b3f1-b960-4390-8980-5e731251d7af",
content: "Mown the lawn",
priority: nil,
is_done: false,
aggregates: %{},
calculations: %{},
...
>}
default_accept
Sometimes a resource as an attribute which we don’t want to have
writeble for the user. Ash provides a functionality for this. Within
the actions
we can use default_accept
to define a whitelist of
accepted attributes.
In our example application we want to allow the user to create and
update the content
and priority
attributes but not the is_done
attribute.
defmodule App.ToDoList.Task do
use Ash.Resource, data_layer: Ash.DataLayer.Ets
# ...
actions do
default_accept [:content, :priority] # add this line
defaults [:create]
end
# ...
end
Should a user try to change the id_done
attribute in a create or
update the system will not accept it. See the "cannot be changed"
message:
$ iex -S mix
Compiling 2 files (.ex)
Erlang/OTP 26 [...]
Interactive Elixir (1.15.5) [...]
iex(1)> App.ToDoList.Task.create(%{content: "Mow the lawn", is_done: true})
{:error,
%Ash.Error.Invalid{
errors: [
%Ash.Error.Changes.InvalidAttribute{
field: :is_done,
message: "cannot be changed",
private_vars: nil,
value: true,
changeset: nil,
query: nil,
error_context: [],
vars: [],
path: [],
stacktrace: #Stacktrace<>,
class: :invalid
}
],
stacktraces?: true,
changeset: #Ash.Changeset<
api: App.ToDoList,
action_type: :create,
action: :create,
attributes: %{content: "Mow the lawn", is_done: true},
relationships: %{},
errors: [
%Ash.Error.Changes.InvalidAttribute{
field: :is_done,
message: "cannot be changed",
private_vars: nil,
value: true,
changeset: nil,
query: nil,
error_context: [],
vars: [],
path: [],
stacktrace: #Stacktrace<>,
class: :invalid
}
],
data: #App.ToDoList.Task<
__meta__: #Ecto.Schema.Metadata<:built, "">,
id: nil,
content: nil,
priority: nil,
is_done: nil,
aggregates: %{},
calculations: %{},
...
>,
valid?: false
>,
query: nil,
error_context: [nil],
vars: [],
path: [],
stacktrace: #Stacktrace<>,
class: :invalid
}}
iex(2)>
Relationships
Relationships define connections between resources. In any application this is the bread and butter of the data modeling.
Setup
We discuss relationships in the context of a simple online shop. Please
use Minimal Ash 2.x Setup Guide to generate
a new Elixir application. After that include or adapt the following files for a
Product
resource:
import Config
config :ash, :use_all_identities_in_manage_relationship?, false
config :app, :ash_apis, [App.Shop]
defmodule App.Shop do
use Ash.Api
resources do
resource App.Shop.Product
end
end
defmodule App.Shop.Product do
use Ash.Resource, data_layer: Ash.DataLayer.Ets
attributes do
uuid_primary_key :id
attribute :name, :string
attribute :price, :decimal
end
actions do
defaults [:create, :read, :update, :destroy]
end
code_interface do
define_for App.Shop
define :create
define :read
define :by_id, get_by: [:id], action: :read
define :by_name, get_by: [:name], action: :read
define :update
define :destroy
end
end
belongs_to
The belongs_to
macro defines a relationship between two resources.
Do you like video tutorials? Have a look at the video tutorial "belongs_to in 2 minutes" in our @elixir-phoenix-ash YouTube Channel. |
In our shop example, a Product
belongs to a Category
.
For that to work we need a new Category
resource:
defmodule App.Shop.Category do
use Ash.Resource, data_layer: Ash.DataLayer.Ets
attributes do
uuid_primary_key :id
attribute :name, :string
end
actions do
defaults [:create, :read, :update, :destroy]
end
code_interface do
define_for App.Shop
define :create
define :read
define :by_id, get_by: [:id], action: :read
define :by_name, get_by: [:name], action: :read
define :update
define :destroy
end
end
And we need to add it to the internal API:
defmodule App.Shop do
use Ash.Api
resources do
resource App.Shop.Product
resource App.Shop.Category
end
end
To configure the belongs_to
relationship to Category
we add one
line to the Product
resource:
defmodule App.Shop.Product do
use Ash.Resource, data_layer: Ash.DataLayer.Ets
attributes do
uuid_primary_key :id
attribute :name, :string
attribute :price, :decimal
end
relationships do (1)
belongs_to :category, App.Shop.Category do (2)
attribute_writable? true (3)
end
end
actions do
defaults [:create, :read, :update, :destroy]
end
code_interface do
define_for App.Shop
define :create
define :read
define :by_id, get_by: [:id], action: :read
define :by_name, get_by: [:name], action: :read
define :update
define :destroy
end
end
1 | The relationships macro defines relationships between resources. |
2 | The source_attribute is defined as :<relationship_name>_id of the type :uuid on the source resource and the destination_attribute is assumed to be :id. To override those defaults have a look at https://hexdocs.pm/ash/relationships.html and https://ash-hq.org/docs/dsl/ash-resource#relationships-belongs_to |
3 | By default the attribute category_id is not writable (see https://ash-hq.org/docs/dsl/ash-resource#relationships-belongs_to-attribute_writable-).
To make it writable we need to set attribute_writable? to true . Only than we can create a Product with a Category in on call. |
$ iex -S mix
Compiling 3 files (.ex)
Generated app app
Erlang/OTP 26 [...]
Interactive Elixir (1.15.5) [...]
iex(1)> alias App.Shop.Product (1)
App.Shop.Product
iex(2)> alias App.Shop.Category
App.Shop.Category
iex(3)> fruits = Category.create!(%{name: "Fruits"}) (2)
#App.Shop.Category<
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "91cb42d8-45c2-451d-8261-72ae4d94a3c6",
name: "Fruits",
...
>
iex(4)> orange = Product.create!(%{
name: "Orange",
price: 0.15,
category_id: fruits.id
}) (3)
#App.Shop.Product<
category: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "6870b44b-67ed-4186-97ed-bbfffd1fc2a0",
name: "Orange",
price: Decimal.new("0.15"),
category_id: "91cb42d8-45c2-451d-8261-72ae4d94a3c6",
...
>
iex(5)> App.Shop.load(orange, :category) (4)
{:ok,
#App.Shop.Product<
category: #App.Shop.Category<
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "91cb42d8-45c2-451d-8261-72ae4d94a3c6",
name: "Fruits",
...
>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "6870b44b-67ed-4186-97ed-bbfffd1fc2a0",
name: "Orange",
price: Decimal.new("0.15"),
category_id: "91cb42d8-45c2-451d-8261-72ae4d94a3c6",
...
>}
iex(6)> orange2 = Product.by_name!("Orange", load: [:category])
#App.Shop.Product<
category: #App.Shop.Category<
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "91cb42d8-45c2-451d-8261-72ae4d94a3c6",
name: "Fruits",
...
>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "6870b44b-67ed-4186-97ed-bbfffd1fc2a0",
name: "Orange",
price: Decimal.new("0.15"),
category_id: "91cb42d8-45c2-451d-8261-72ae4d94a3c6",
...
>
iex(7)> orange2.category
#App.Shop.Category<
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "91cb42d8-45c2-451d-8261-72ae4d94a3c6",
name: "Fruits",
...
>
iex(8)> orange2.category.name
"Fruits"
1 | Let’s save a bit of typing by creating shorter Aliases. |
2 | Create a new Category for "Fruits" and store it in the variable fruits . |
3 | Create a new Product for "Orange" which belongs to the Category "Fruits" and store it in the variable orange . |
4 | One way to get the Category of a Product if that wasn’t sideloaded initially. |
5 | Sideload the Category of the Product when fetching The `Product from the database. |
has_many
Using the belongs_to example and setup we can now add a
has_many
relationship to the Category
resource:
defmodule App.Shop.Category do
use Ash.Resource, data_layer: Ash.DataLayer.Ets
attributes do
uuid_primary_key :id
attribute :name, :string
end
relationships do
has_many :products, App.Shop.Product (1)
end
actions do
defaults [:create, :read, :update, :destroy]
end
code_interface do
define_for App.Shop
define :create
define :read
define :by_id, get_by: [:id], action: :read
define :by_name, get_by: [:name], action: :read
define :update
define :destroy
end
end
1 | The has_many macro defines a relationship between two resources. In our
example, a Category has many Products . For that to work we need a
Product resource. By default, the source_attribute is assumed to be :id
and destination_attribute defaults to <snake_cased_last_part_of_module_name>_id.
To override those defaults have a look at
https://hexdocs.pm/ash/relationships.html and https://ash-hq.org/docs/dsl/ash-resource#relationships-has_many |
Let’s play with the new relationship:
iex -S mix
Compiling 1 file (.ex)
Erlang/OTP 26 [...]
Interactive Elixir (1.15.5) [...]
iex(1)> alias App.Shop.Product
App.Shop.Product
iex(2)> alias App.Shop.Category
App.Shop.Category
iex(3)> fruits = Category.create!(%{name: "Fruits"}) (1)
#App.Shop.Category<
products: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
name: "Fruits",
...
>
iex(4)> Product.create!(%{name: "Orange", category_id: fruits.id}) (2)
#App.Shop.Product<
category: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "3ec1c834-70a8-403d-8814-3070c77b525e",
name: "Orange",
price: nil,
category_id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
...
>
iex(5)> Product.create!(%{name: "Banana", category_id: fruits.id})
#App.Shop.Product<
category: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "460d8cfa-2dad-4da0-95db-45012aa33621",
name: "Banana",
price: nil,
category_id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
...
>
iex(6)> App.Shop.load(fruits, :products) (3)
{:ok,
#App.Shop.Category<
products: [
#App.Shop.Product<
category: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "3ec1c834-70a8-403d-8814-3070c77b525e",
name: "Orange",
price: nil,
category_id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
aggregates: %{},
calculations: %{},
...
>,
#App.Shop.Product<
category: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "460d8cfa-2dad-4da0-95db-45012aa33621",
name: "Banana",
price: nil,
category_id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
aggregates: %{},
calculations: %{},
...
>
],
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
name: "Fruits",
aggregates: %{},
calculations: %{},
...
>}
iex(7)> Category.by_name!("Fruits", load: [:products]) (4)
#App.Shop.Category<
products: [
#App.Shop.Product<
category: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "3ec1c834-70a8-403d-8814-3070c77b525e",
name: "Orange",
price: nil,
category_id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
...
>,
#App.Shop.Product<
category: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "460d8cfa-2dad-4da0-95db-45012aa33621",
name: "Banana",
price: nil,
category_id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
...
>
],
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
name: "Fruits",
...
>
1 | We create a category for fruits. |
2 | We create two products and assign them to the fruits category. |
3 | We load the products for the fruits category. |
4 | We sideload all the products for the fruits category. |
has_one
I do not know if I ever used has_one in a real world application. But
for the sake of completeness, I pull out an example out of thin air for this.
|
has_one
is similar to belongs_to
except that the reference attribute is
on the destination resource, instead of the source.
Let’s assume we run special promotions in our shop (so and so many percent
rebate off). But each product can only have one promotion and each promotion
can only be used for one product. I know! It is just an example for has_one
.
defmodule App.Shop do
use Ash.Api
resources do
resource App.Shop.Product
resource App.Shop.Category
resource App.Shop.Promotion
end
end
defmodule App.Shop.Promotion do
use Ash.Resource, data_layer: Ash.DataLayer.Ets
attributes do
uuid_primary_key :id
attribute :name, :string
attribute :rebate, :integer
attribute :product_id, :uuid
end
relationships do
belongs_to :product, App.Shop.Product do
attribute_writable? true
end
end
actions do
defaults [:create, :read, :update, :destroy]
end
code_interface do
define_for App.Shop
define :create
define :read
define :by_id, get_by: [:id], action: :read
define :by_name, get_by: [:name], action: :read
define :update
define :destroy
end
end
defmodule App.Shop.Product do
use Ash.Resource, data_layer: Ash.DataLayer.Ets
attributes do
uuid_primary_key :id
attribute :name, :string
attribute :price, :decimal
end
relationships do
belongs_to :category, App.Shop.Category do
attribute_writable? true
end
has_one :promotion, App.Shop.Promotion
end
actions do
defaults [:create, :read, :update, :destroy]
end
code_interface do
define_for App.Shop
define :create
define :read
define :by_id, get_by: [:id], action: :read
define :by_name, get_by: [:name], action: :read
define :update
define :destroy
end
end
Let’s use it in the iex
console:
$ iex -S mix
Erlang/OTP 26 [...]
Interactive Elixir (1.15.5) [...]
iex(1)> alias App.Shop.Product
App.Shop.Product
iex(2)> alias App.Shop.Promotion
App.Shop.Promotion
iex(3)> orange = Product.create!(%{name: "Orange", price: 0.2})
#App.Shop.Product<
promotion: #Ash.NotLoaded<:relationship>,
category: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "c9e9b4ba-408f-4c42-b1e0-e8b3799d5b1f",
name: "Orange",
price: Decimal.new("0.2"),
category_id: nil,
...
>
iex(4)> {:ok, promotion} = Promotion.create(%{name: "15% off", rebate: 15, product_id: orange.id})
{:ok,
#App.Shop.Promotion<
product: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "68901cef-f2c5-46bb-a737-d6c248d36347",
name: "15% off",
rebate: 15,
product_id: "c9e9b4ba-408f-4c42-b1e0-e8b3799d5b1f",
...
>}
iex(5)> App.Shop.load(orange, :promotion) (1)
{:ok,
#App.Shop.Product<
promotion: #App.Shop.Promotion<
product: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "68901cef-f2c5-46bb-a737-d6c248d36347",
name: "15% off",
rebate: 15,
product_id: "c9e9b4ba-408f-4c42-b1e0-e8b3799d5b1f",
...
>,
category: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "c9e9b4ba-408f-4c42-b1e0-e8b3799d5b1f",
name: "Orange",
price: Decimal.new("0.2"),
category_id: nil,
...
>}
1 | By default the promotion is not sideloaded. We have to load it manually. |
2 | This is the way to sideload the promotion relationship. |
many_to_many
many_to_many
is a special case of has_many
where the relationship is
symmetric. That means that the relationship is defined on both sides of the
relationship. A good example is a Tag
Resource that can be assigned to
multiple Product
Resources and a Product
Resource can have multiple Tag
Resources assigned to it.
For this to work we not just need a Tag
resource but also a ProductTag
which is the join table between Tag
and Product
.
defmodule App.Shop.Tag do
use Ash.Resource, data_layer: Ash.DataLayer.Ets
attributes do
uuid_primary_key :id
attribute :name, :string
end
relationships do
many_to_many :products, App.Shop.Product do
through App.Shop.ProductTag
source_attribute_on_join_resource :tag_id
destination_attribute_on_join_resource :product_id
end
end
actions do
defaults [:read, :update, :destroy]
create :create do
primary? true
argument :products, {:array, :map}
change manage_relationship(:products, type: :append_and_remove, on_no_match: :create)
end
end
code_interface do
define_for App.Shop
define :create
define :read
define :by_id, get_by: [:id], action: :read
define :by_name, get_by: [:name], action: :read
define :update
define :destroy
end
end
defmodule App.Shop.ProductTag do
use Ash.Resource, data_layer: Ash.DataLayer.Ets
actions do
defaults [:create, :read, :update, :destroy]
end
relationships do
belongs_to :product, App.Shop.Product do
primary_key? true
allow_nil? false
end
belongs_to :tag, App.Shop.Tag do
primary_key? true
allow_nil? false
end
end
end
defmodule App.Shop.Product do
use Ash.Resource, data_layer: Ash.DataLayer.Ets
attributes do
uuid_primary_key :id
attribute :name, :string
attribute :price, :decimal
end
relationships do
many_to_many :tags, App.Shop.Tag do
through App.Shop.ProductTag
source_attribute_on_join_resource :product_id
destination_attribute_on_join_resource :tag_id
end
end
actions do
defaults [:read, :update, :destroy]
create :create do
primary? true
argument :tags, {:array, :map}
change manage_relationship(:tags, type: :append_and_remove, on_no_match: :create)
end
end
code_interface do
define_for App.Shop
define :create
define :read
define :by_id, get_by: [:id], action: :read
define :by_name, get_by: [:name], action: :read
define :update
define :destroy
end
end
defmodule App.Shop do
use Ash.Api
resources do
resource App.Shop.Product
resource App.Shop.ProductTag
resource App.Shop.Tag
end
end
Let’s use it in the iex
console:
$ iex -S mix
Compiling 3 files (.ex)
Erlang/OTP 26 [...]
Interactive Elixir (1.15.5) [...]
iex(1)> good_deal_tag = App.Shop.Tag.create!(%{name: "Good deal"}) (1)
#App.Shop.Tag<
products: #Ash.NotLoaded<:relationship>,
products_join_assoc: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "82b7e8af-69b9-4f35-b32a-0b6b2bed1d15",
name: "Good deal",
...
>
iex(2)> yellow_tag = App.Shop.Tag.create!(%{name: "Yellow"}) (2)
#App.Shop.Tag<
products: #Ash.NotLoaded<:relationship>,
products_join_assoc: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "d04aa5ef-195e-4dd8-9c5a-5c73e6f44afe",
name: "Yellow",
...
>
iex(3)> App.Shop.Product.create!(%{
name: "Banana",
tags: [good_deal_tag, yellow_tag]
}) (3)
#App.Shop.Product<
tags: [
#App.Shop.Tag<
products: #Ash.NotLoaded<:relationship>,
products_join_assoc: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "82b7e8af-69b9-4f35-b32a-0b6b2bed1d15",
name: "Good deal",
...
>,
#App.Shop.Tag<
products: #Ash.NotLoaded<:relationship>,
products_join_assoc: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "d04aa5ef-195e-4dd8-9c5a-5c73e6f44afe",
name: "Yellow",
...
>
],
tags_join_assoc: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "d3551abf-cf43-4c7f-94f1-ea7228d87cf1",
name: "Banana",
price: nil,
...
>
iex(4)> App.Shop.Product.by_name!("Banana", load: [:tags]) (4)
#App.Shop.Product<
tags: [
#App.Shop.Tag<
products: #Ash.NotLoaded<:relationship>,
products_join_assoc: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "82b7e8af-69b9-4f35-b32a-0b6b2bed1d15",
name: "Good deal",
...
>,
#App.Shop.Tag<
products: #Ash.NotLoaded<:relationship>,
products_join_assoc: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "d04aa5ef-195e-4dd8-9c5a-5c73e6f44afe",
name: "Yellow",
...
>
],
...
],
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "d3551abf-cf43-4c7f-94f1-ea7228d87cf1",
name: "Banana",
price: nil,
...
>
1 | Create a tag named "Good deal". |
2 | Create a tag named "Yellow". |
3 | Create a product named "Banana" and associate it with the two tags. |
4 | Retrieve the product by name and sideload its tags. |