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 create or adapt the following files for a Product
resource:
import Config
config :app, :ash_apis, [App.Shop]
defmodule App.Shop do
use Ash.Api
resources do
resource App.Shop.Product (1)
end
end
1 | We always have to create an internal API and include all resources in it. |
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. In our shop example, a Product
belongs to a Category
.
+----------+ +-------------+
| Category | | Product |
+----------+ +-------------+
| id |<-----| category_id |
| name | | id |
| | | name |
| | | price |
+----------+ +-------------+
Do you like video tutorials? Have a look at "belongs_to in 2 minutes" in our @elixir-phoenix-ash YouTube Channel. |
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
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]) (5)
#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. |
Sideload a belongs_to Relationship by Default
In case you always want to sideload the Category
of the Product
without adding load: [:category]
to every call you can do the following:
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
end
actions do
defaults [:create, :update, :destroy] (1)
read :read do
primary? true (2)
prepare build(load: [:category]) (3)
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
1 | Don’t forget to remove :read from the defaults when you add a custom read action. |
2 | See https://ash-hq.org/docs/guides/ash/latest/topics/actions#primary-actions |
3 | Always sideload the Category when fetching a Product . |
Let’s test it in the iex:
iex(9)> Product.by_name("Orange")
{:ok,
#App.Shop.Product<
category: #App.Shop.Category<
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "22ab0824-18ac-4daa-9a13-defd0b8bcd73",
name: "Fruits",
...
>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "24348935-6148-4c75-9bf1-55f74ac9397a",
name: "Orange",
price: Decimal.new("0.15"),
category_id: "22ab0824-18ac-4daa-9a13-defd0b8bcd73",
...
>}
has_many
Think of has_many
as the opposite site of a belongs_to
relationship. In our shop example a Category
has many Products
.
+----------+ +-------------+
| Category | | Product |
+----------+ +-------------+
| id |----->| category_id |
| name | | id |
| | | name |
| | | price |
+----------+ +-------------+
Do you like video tutorials? Have a look at "has_many in 2m 19s" in our @elixir-phoenix-ash YouTube Channel. |
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",
...
>,
#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",
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 | Create a category for fruits. |
2 | Create two products and assign them to the fruits category. |
3 | Load the products for the fruits category. |
4 | Sideload all the products for the fruits category. |
Sideload a has_many Relationship by Default
In case you always want to sideload all products
of a category
without adding load: [:category]
to every call you can do the following:
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
end
actions do
defaults [:create, :update, :destroy] (1)
read :read do
primary? true (2)
prepare build(load: [:products]) (3)
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
1 | Don’t forget to remove :read from the defaults when you add a custom read action. |
2 | See https://ash-hq.org/docs/guides/ash/latest/topics/actions#primary-actions |
3 | Always sideload all products when fetching a Category . |
Let’s test it in the iex:
iex(17)> Category.by_name!("Fruits").products |> Enum.map(& &1.name)
["Orange", "Banana"]
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 (or none) Product
resources and a Product
resource has multiple (or none) Tag
resources assigned to it.
+---------+ +------------+ +--------+
| Product | | ProductTag | | Tag |
+---------+ +------------+ +--------+
| id |<--->| product_id | | name |
| name | | tag_id |<--->| id |
| price | | | | |
+---------+ +------------+ +--------+
Do you like video tutorials? Have a look at "many_to_many Resources in Ash Framework Tutorial (4m 22s)" in our @elixir-phoenix-ash YouTube Channel. |
Setup
We start with a clean slate. A fresh Ash app. Please move or delete an already existing Ash app if you have one under the directory name app
. Feel free to copy and paste the following lines in your terminal or do it step by step following relationship setup.
mix new --sup app && cd app
awk '/defp deps do/,/\[/ {
if ($0 ~ /\[/) {
print $0;
print "{:ash, \"~> 2.15.8\"}";
next;
}
} 1' mix.exs > mix.exs.tmp
mv mix.exs.tmp mix.exs
mix deps.get
echo '[
import_deps: [:ash],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]' > .formatter.exs
mkdir config
echo 'import Config
config :app, :ash_apis, [App.Shop]' > config/config.exs
mix format
We need the following resources for our many_to_many
example:
Tag
Product
ProductTag
(which will be the glue between the two other resources)
Please create the following files:
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 (1)
end
actions do
defaults [:read, :update, :destroy]
create :create do
primary? true
argument :products, {:array, :map} (2)
change manage_relationship(:products,
type: :append_and_remove,
on_no_match: :create
) (3)
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
1 | The configuration for the many_to_many relationship to product . |
2 | Now we can use the products: [] argument to do this: Tag.create!(%{name: "Sweet", products: [apple, cherry]}) |
3 | This uses the products: [] argument to create a Tag resource with a list of Product resources assigned to it. |
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 (1)
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} (2)
change manage_relationship(:tags,
type: :append_and_remove,
on_no_match: :create
) (3)
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
1 | The configuration for the many_to_many relationship to tag . |
2 | Now we can use the tags: [] argument to do this: Product.create!(%{name: "Banana", tags: [sweet, tropical]}) |
3 | This uses the tags: [] argument to create a Product resource with a list of Tag resources assigned to it. |
defmodule App.Shop.ProductTag do
use Ash.Resource, data_layer: Ash.DataLayer.Ets
actions do
defaults [:create, :read, :destroy] (1)
end
relationships do (2)
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
1 | No need for an :update action. Entries in the join table are immutable. You can delete but not change them. |
2 | The glue between the Product and the Tag resources. |
Finally we have to add the Tag
and ProductTag
resources to the App.Shop
API module.
defmodule App.Shop do
use Ash.Api
resources do
resource App.Shop.Product
resource App.Shop.ProductTag
resource App.Shop.Tag
end
end
Now we have a working many_to_many
relationship between Tag
and Product
.
Example in the iex
Let’s use the following data for our example.
Ash does use UUIDs. I use integer IDs in the example table because it’s easier to read for humans. |
Product: Tag:
+----+--------+ +----+----------+
| id | name | | id | Name |
+----+--------+ +----+----------+
| 1 | Apple | | 1 | Sweet |
| 2 | Banana | | 2 | Tropical |
| 3 | Cherry | | 3 | Red |
+----+--------+ +----+----------+
ProductTag:
+-----------+-------+
| product_id| tag_id|
+-----------+-------+
| 1 | 1 | (Apple is Sweet)
| 1 | 3 | (Apple is Red)
| 2 | 1 | (Banana is Sweet)
| 2 | 2 | (Banana is Tropical)
| 3 | 3 | (Cherry is Red) (1)
+-----------+-------+
1 | Not a complete list of all real world combinations. I am aware that cherries can be sweet too. 😉 |
Open the iex and create all the products with their tags.
$ iex -S mix
iex(1)> alias App.Shop.Tag
iex(2)> alias App.Shop.Product
iex(3)> sweet = Tag.create!(%{name: "Sweet"})
iex(4)> tropical = Tag.create!(%{name: "Tropical"})
iex(5)> red = Tag.create!(%{name: "Red"})
iex(6)> Product.create!(%{name: "Apple", tags: [sweet, red]})
iex(7)> Product.create!(%{name: "Banana", tags: [sweet, tropical]})
iex(8)> Product.create!(%{name: "Cherry", tags: [red]})
Now we can read all products with their tags and vice versa.
iex(9)> Product.read!(load: [:tags]) |>
...(9)> Enum.map(fn product ->
...(9)> %{
...(9)> product_name: product.name,
...(9)> tag_names: Enum.map(product.tags, & &1.name)
...(9)> }
...(9)> end)
[
%{product_name: "Banana", tag_names: ["Sweet", "Tropical"]},
%{product_name: "Apple", tag_names: ["Sweet", "Red"]},
%{product_name: "Cherry", tag_names: ["Red"]}
]
iex(10)> Tag.read!(load: [:products]) |>
...(10)> Enum.map(fn tag ->
...(10)> %{
...(10)> tag_name: tag.name,
...(10)> product_names: Enum.map(tag.products, & &1.name)
...(10)> }
...(10)> end)
[
%{tag_name: "Tropical", product_names: ["Banana"]},
%{tag_name: "Red", product_names: ["Cherry", "Apple"]},
%{tag_name: "Sweet", product_names: ["Apple", "Banana"]}
]
many_to_many sideloading by default
Be default Ash will not load the join table entries. You can change this with the :load
option in the :read
action:
# [...]
actions do
defaults [:update, :destroy] (1)
read :read do
primary? true
prepare build(load: [:tags]) (2)
end
create :create do
primary? true
argument :tags, {:array, :map}
change manage_relationship(:tags,
type: :append_and_remove,
on_no_match: :create
)
end
end
# [...]
1 | Don’t forget to remove :read here. |
2 | Always sideload the tags relationship. |
# [...]
actions do
defaults [:update, :destroy] (1)
read :read do
primary? true
prepare build(load: [:products]) (2)
end
create :create do
primary? true
argument :products, {:array, :map}
change manage_relationship(:products,
type: :append_and_remove,
on_no_match: :create
)
end
end
# [...]
1 | Don’t forget to remove :read here. |
2 | Always sideload the products relationship. |
Let’s use it in the iex
console:
$ iex -S mix
iex(1)> alias App.Shop.Tag
iex(2)> alias App.Shop.Product
iex(3)> sweet = Tag.create!(%{name: "Sweet"})
iex(4)> tropical = Tag.create!(%{name: "Tropical"})
iex(5)> red = Tag.create!(%{name: "Red"})
iex(6)> Product.create!(%{name: "Apple", tags: [sweet, red]})
iex(7)> Product.create!(%{name: "Banana", tags: [sweet, tropical]})
iex(8)> Product.create!(%{name: "Cherry", tags: [red]})
iex(9)> Product.read! |> (1)
...(9)> Enum.map(fn product ->
...(9)> %{
...(9)> product_name: product.name,
...(9)> tag_names: Enum.map(product.tags, & &1.name)
...(9)> }
...(9)> end)
[
%{product_name: "Banana", tag_names: ["Sweet", "Tropical"]},
%{product_name: "Apple", tag_names: ["Sweet", "Red"]},
%{product_name: "Cherry", tag_names: ["Red"]}
]
iex(10)> Tag.read! |> (2)
...(10)> Enum.map(fn tag ->
...(10)> %{
...(10)> tag_name: tag.name,
...(10)> product_names: Enum.map(tag.products, & &1.name)
...(10)> }
...(10)> end)
[
%{tag_name: "Tropical", product_names: ["Banana"]},
%{tag_name: "Red", product_names: ["Cherry", "Apple"]},
%{tag_name: "Sweet", product_names: ["Apple", "Banana"]}
]
1 | We don’t have to specify load: [:tags] here because we set it as the default in the :read action. |
2 | We don’t have to specify load: [:tags] here because we set it as the default in the :read action. |
Update many_to_many relationships
Sometimes we want to update the tags
of a product
resource. It feels most natural to do it via the update
action of the product
resource. For that to work we have to define a custom :update
action that will update the tags
relationship. We can more or less copy the code from the :create
action for that:
# [...]
actions do
defaults [:read, :destroy]
create :create do
primary? true
argument :tags, {:array, :map}
change manage_relationship(:tags,
type: :append_and_remove,
on_no_match: :create
)
end
update :update do (1)
primary? true
argument :tags, {:array, :map}
change manage_relationship(:tags,
type: :append_and_remove,
on_no_match: :create
)
end
end
# [...]
1 | Same as the :create action just with :update . |
Let’s use it in the iex
console. We first create a product with two tags and than we update it to have only one tag:
$ iex -S mix
iex(1)> alias App.Shop.Tag
iex(2)> alias App.Shop.Product
iex(3)> good_deal = Tag.create!(%{name: "Good deal"})
iex(4)> yellow = Tag.create!(%{name: "Yellow"})
iex(5)> Product.create!(%{name: "Banana", tags: [yellow, good_deal]}) (1)
iex(6)> Product.by_name!("Banana", load: [:tags]).tags |> Enum.map(& &1.name) (2)
["Yellow", "Good deal"]
iex(7)> banana = Product.by_name!("Banana") (3)
iex(8)> Product.update!(banana, %{tags: [yellow]}) (4)
iex(9)> Product.by_name!("Banana", load: [:tags]).tags |> Enum.map(& &1.name) (5)
["Yellow"]
1 | Create a new product with two tags. |
2 | Query the just created product and print the two tag names. |
3 | Store the product in the variable banana for later use. |
4 | Update the product to have only one tag. |
5 | Double check that the product really only has one tag. |
The between resource ProductTag
is automatically updated. And by update I mean that one entry was deleted.
Unique Tags
We don’t want to have multiple tags with the same name. But right now this is possible:
$ iex -S mix
iex(1)> alias App.Shop.Tag
iex(2)> Tag.create!(%{name: "Yellow"}).id
"d206b758-253d-4f06-9773-5423ae1f6027"
iex(3)> Tag.create!(%{name: "Yellow"}).id
"5d66386c-bb02-4a8e-bf2a-5457477a6da2"
iex(4)> Tag.create!(%{name: "Yellow"}).id
"3497214e-83a0-43bd-b087-143af5ef8c37"
iex(5)> Tag.read! |> Enum.map(& &1.name)
["Yellow", "Yellow", "Yellow"]
We can fix this with identities
in the resource:
defmodule App.Shop.Tag do
use Ash.Resource, data_layer: Ash.DataLayer.Ets
attributes do
uuid_primary_key :id
attribute :name, :string
end
identities do
# identity :unique_name, [:name] (1)
identity :name, [:name] do (2)
pre_check_with App.Shop (3)
end
end
# [...]
1 | Use this if you use a PostgreSQL database. Don’t forget to run a mix ash.codegen after you added it. |
2 | Use this if your use a ETS data layer like we do in this example. |
3 | Since ETS doesn’t support unique indexes we have to check for uniqueness before we create it. |
Now we can not create multiple tags with the same name anymore:
$ iex -S mix
iex(1)> alias App.Shop.Tag
iex(2)> Tag.create!(%{name: "Yellow"}).id
"f03e163f-5a17-4ea4-b708-f2089234d642"
iex(3)> Tag.create!(%{name: "Yellow"}).id
** (Ash.Error.Invalid) Input Invalid
* name: has already been taken
(ash 2.14.18) lib/ash/api/api.ex:2179: Ash.Api.unwrap_or_raise!/3
iex(3)> Tag.create(%{name: "Yellow"}).id
** (KeyError) key :id not found in: {:error,
%Ash.Error.Invalid{
errors: [
%Ash.Error.Changes.InvalidChanges{
fields: [:name],
message: "has already been taken",
[...]
add_tag action
Sometimes it is useful to have an add_tag
argument that creates and adds a new tag to a new product in one go ( e.g. create!(%{name: "Banana", add_tag: %{name: "Yellow"}})
):
# [...]
actions do
defaults [:read, :destroy, :update]
create :create do (1)
primary? true
argument :tags, {:array, :map}
argument :add_tag, :map do
allow_nil? true
end
change manage_relationship(:tags,
type: :append_and_remove,
on_no_match: :create
)
change manage_relationship(
:add_tag,
:tags,
type: :create
)
end
end
[...]
1 | You can copy-paste the code for update :update do too if you want to be able to add tags to existing products. |
Let’s test it:
$ iex -S mix
iex(1)> App.Shop.Product.create!(%{name: "Banana", add_tag: %{name: "Yellow"}})
#App.Shop.Product<
tags: [
#App.Shop.Tag<
products: #Ash.NotLoaded<:relationship>,
products_join_assoc: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "9b95f8cf-9f95-409a-81d3-b6a66e470d2b",
name: "Yellow",
...
>
],
tags_join_assoc: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:loaded>,
id: "52049582-c3cb-458c-bbac-0ba36e57e234",
name: "Banana",
price: nil,
...
>
has_one
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.
+---------+ +------------+
| Product | | Promotion |
+---------+ +------------+
| id |----->| product_id |
| name | | id |
| price | | rebate |
+---------+ +------------+
Let’s adjust our source code accordingly:
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 | We have to load the promotion explicitly. It is not loaded by default. |
If you haven’t missed |