Relationships

Relationships define connections between resources. In any application this is the bread and butter of the data modeling.

In Ash 3.0, relationships are defined within resources that are part of a domain. This allows Ash to effectively manage and navigate the connections between your data.

Setup

We discuss relationships in the context of a simple online shop. To get started, create a new application using Igniter:

$ 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.

After setting up your application, create the following files for a Product resource:

config/config.exs
import Config

config :app, :ash_domains, [App.Shop]
lib/app/shop.ex
defmodule App.Shop do
  use Ash.Domain, otp_app: :app

  resources do
    resource App.Shop.Product
  end
end
lib/app/shop/product.ex
defmodule App.Shop.Product do
  use Ash.Resource,
    data_layer: Ash.DataLayer.Ets,
    domain: App.Shop,
    otp_app: :app

  attributes do
    uuid_primary_key :id
    attribute :name, :string
    attribute :price, :decimal
  end

  actions do
    defaults [:create, :read, :update, :destroy]
  end
end

Now let’s add code interface definitions in our domain to make working with the resource easier:

lib/app/shop.ex
defmodule App.Shop do
  use Ash.Domain, otp_app: :app

  resources do
    resource App.Shop.Product do
      define :create_product, action: :create
      define :read_products, action: :read
      define :get_product_by_id, action: :read, get_by: :id
      define :get_product_by_name, action: :read, get_by: :name
      define :update_product, action: :update
      define :destroy_product, action: :destroy
    end
  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       |
+----------+      +-------------+

We need a new Category resource:

lib/app/shop/category.ex
defmodule App.Shop.Category do
  use Ash.Resource,
    data_layer: Ash.DataLayer.Ets,
    domain: App.Shop,
    otp_app: :app

  attributes do
    uuid_primary_key :id
    attribute :name, :string
  end

  actions do
    defaults [:create, :read, :update, :destroy]
  end
end

And we need to add code interface definitions to our domain:

lib/app/shop.ex
defmodule App.Shop do
  use Ash.Domain, otp_app: :app

  resources do
    resource App.Shop.Product do
      define :create_product, action: :create
      define :read_products, action: :read
      define :get_product_by_id, action: :read, get_by: :id
      define :get_product_by_name, action: :read, get_by: :name
      define :update_product, action: :update
      define :destroy_product, action: :destroy
    end

    resource App.Shop.Category do
      define :create_category, action: :create
      define :read_categories, action: :read
      define :get_category_by_id, action: :read, get_by: :id
      define :get_category_by_name, action: :read, get_by: :name
      define :update_category, action: :update
      define :destroy_category, action: :destroy
    end
  end
end

To configure the belongs_to relationship to Category we add a relationships block to the Product resource:

lib/app/shop/product.ex
defmodule App.Shop.Product do
  use Ash.Resource,
    data_layer: Ash.DataLayer.Ets,
    domain: App.Shop,
    otp_app: :app

  attributes do
    uuid_primary_key :id
    attribute :name, :string
    attribute :price, :decimal
  end

  relationships do (1)
    belongs_to :category, App.Shop.Category do (2)
      allow_nil? false (3)
    end
  end

  actions do
    defaults [:create, :read, :update, :destroy]
  end
end
1The relationships macro defines relationships between resources.
2The source_attribute is defined as :<relationship_name>_id (category_id in this case) 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
3By default the category_id can be nil. Setting allow_nil? to false makes the relationship required.

Let’s test this in the iex:

$ iex -S mix
iex(1)> # Create a new category
iex(2)> {:ok, fruits} = App.Shop.create_category(%{name: "Fruits"})
{:ok,
 #App.Shop.Category<
   __meta__: #Ecto.Schema.Metadata<:loaded>,
   id: "91cb42d8-45c2-451d-8261-72ae4d94a3c6",
   name: "Fruits",
   ...
 >}
iex(3)> # Create a new product in the "Fruits" category
iex(4)> {:ok, orange} = App.Shop.create_product(%{
                  name: "Orange",
                  price: 0.15,
                  category_id: fruits.id
                })
{:ok,
 #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)> # Load the category relationship for the orange product
iex(6)> {:ok, orange_with_category} = Ash.load(orange, :category)
{: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(7)> # Fetch a product with its category pre-loaded
iex(8)> {:ok, orange2} = App.Shop.get_product_by_name("Orange", load: [:category])
{: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(9)> orange2.category.name
"Fruits"

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 customize the read action:

lib/app/shop/product.ex
defmodule App.Shop.Product do
  use Ash.Resource,
    data_layer: Ash.DataLayer.Ets,
    domain: App.Shop,
    otp_app: :app

  attributes do
    uuid_primary_key :id
    attribute :name, :string
    attribute :price, :decimal
  end

  relationships do
    belongs_to :category, App.Shop.Category do
      allow_nil? false
    end
  end

  actions do
    defaults [:create, :update, :destroy] (1)

    read :read do
      primary? true (2)
      prepare build(load: [:category]) (3)
    end
  end
end
1Don’t include :read in the defaults when you add a custom read action.
2This marks this action as the primary read action for the resource.
3The prepare step always sideloads the Category when fetching a Product.

Let’s test it in the iex:

iex(10)> {:ok, orange} = App.Shop.get_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",
   ...
 >}

Note how the category is automatically loaded even though we didn’t specify load: [:category].

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       |
+----------+      +-------------+

Using the belongs_to example and setup we can now add a has_many relationship to the Category resource:

lib/app/shop/resources/category.ex
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
1The 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",
  ...
>
1Create a category for fruits.
2Create two products and assign them to the fruits category.
3Load the products for the fruits category.
4Sideload 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:

lib/app/shop/resources/category.ex
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
1Don’t forget to remove :read from the defaults when you add a custom read action.
2See https://ash-hq.org/docs/guides/ash/latest/topics/actions#primary-actions
3Always 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   |     |            |     |        |
+---------+     +------------+     +--------+

Setup

Setting Up a Fresh Ash App

The fastest way to get started with Ash is using Igniter, a powerful code generator for Ash applications. Igniter handles all the boilerplate setup for you, making it easy to get up and running quickly.

First, install the Igniter archive if you haven’t already:

$ mix archive.install hex igniter_new

Then create a fresh Ash application:

$ mix igniter.new app --install ash
$ cd app

For a Phoenix application with Ash and PostgreSQL support:

$ mix igniter.new app --with phx.new --install ash,ash_postgres
$ cd app

Igniter will create all the necessary files and folder structure, install dependencies, and set up your configuration automatically.

Using the Interactive Web Installer

You can also use the interactive web installer at https://ash-hq.org/#get-started to create a custom setup command tailored to your specific needs.

Manual Setup Alternative

If you prefer the manual approach, you can follow the steps in the manual approach or use this bash script that performs the same setup:

mix new --sup app && cd app
awk '/defp deps do/,/\[/ {
       if ($0 ~ /\[/) {
           print $0;
           print "{:ash, \"~> 3.0\"}";
           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_domains, [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:

lib/app/shop/resources/tag.ex
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
1The configuration for the many_to_many relationship to product.
2Now we can use the products: [] argument to do this: Tag.create!(%{name: "Sweet", products: [apple, cherry]})
3This uses the products: [] argument to create a Tag resource with a list of Product resources assigned to it.
lib/app/shop/resources/product.ex
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
1The configuration for the many_to_many relationship to tag.
2Now we can use the tags: [] argument to do this: Product.create!(%{name: "Banana", tags: [sweet, tropical]})
3This uses the tags: [] argument to create a Product resource with a list of Tag resources assigned to it.
lib/app/shop/resources/product_tag.ex
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
1No need for an :update action. Entries in the join table are immutable. You can delete but not change them.
2The glue between the Product and the Tag resources.

Finally we have to add the Tag and ProductTag resources to the App.Shop API module.

lib/app/shop.ex
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)
+-----------+-------+
1Not 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:

lib/app/product.ex
  # [...]

  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

  # [...]
1Don’t forget to remove :read here.
2Always sideload the tags relationship.
lib/app/product.ex
  # [...]

  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

  # [...]
1Don’t forget to remove :read here.
2Always 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"]}
]
1We don’t have to specify load: [:tags] here because we set it as the default in the :read action.
2We 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:

lib/app/shop/resources/product.ex
  # [...]
  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
  # [...]
1Same 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"]
1Create a new product with two tags.
2Query the just created product and print the two tag names.
3Store the product in the variable banana for later use.
4Update the product to have only one tag.
5Double 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:

lib/app/shop/resources/tag.ex
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
  # [...]
1Use this if you use a PostgreSQL database. Don’t forget to run a mix ash.codegen after you added it.
2Use this if your use a ETS data layer like we do in this example.
3Since 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"}})):

lib/app/shop/resources/product.ex
  # [...]
  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
  [...]
1You 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:

lib/app/shop.ex
defmodule App.Shop do
  use Ash.Api

  resources do
    resource App.Shop.Product
    resource App.Shop.Category
    resource App.Shop.Promotion
  end
end
lib/app/shop/resources/promotion.ex
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
lib/app/shop/resources/product.ex
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,
   ...
 >}
1We have to load the promotion explicitly. It is not loaded by default.

If you haven’t missed has_one in your life so fare I wouldn’t use it at all. belongs_to is probably the better choice.