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:
import Config
config :app, :ash_domains, [App.Shop]
defmodule App.Shop do
use Ash.Domain, otp_app: :app
resources do
resource App.Shop.Product
end
end
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, public?: true (1)
attribute :price, :decimal, public?: true
end
actions do
default_accept [:name, :price] (2)
defaults [:create, :read, :update, :destroy]
end
end
| 1 | Ash 3.x attributes are private by default; mark the ones you want
writable or exposed as public?: true. |
| 2 | default_accept is the whitelist of attributes the default CRUD
actions will accept as input. |
Now let’s add code interface definitions in our domain to make working with the resource easier:
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:
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, public?: true
end
actions do
default_accept [:name]
defaults [:create, :read, :update, :destroy]
end
end
And we need to add code interface definitions to our domain:
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:
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, public?: true
attribute :price, :decimal, public?: true
end
relationships do (1)
belongs_to :category, App.Shop.Category do (2)
allow_nil? false (3)
public? true (4)
end
end
actions do
default_accept [:name, :price, :category_id] (5)
defaults [:create, :read, :update, :destroy]
end
end
| 1 | The relationships macro defines relationships between resources. |
| 2 | By convention Ash creates a source attribute named
<relationship>_id (category_id here) of type :uuid, matching the
:id primary key on the destination. See
https://hexdocs.pm/ash/relationships.html if you need to override those
defaults. |
| 3 | By default category_id can be nil. Setting allow_nil? to
false makes the relationship required. |
| 4 | In Ash 3.x relationships are also private by default. Make them
public?: true when you want the outside world to see them (including
API extensions such as AshJsonApi or AshGraphql). |
| 5 | The default create/update actions need to accept the foreign key for the relationship to be settable through the code interface. |
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{
id: "cea38a50-2178-42c0-aad9-2624eec49609",
name: "Fruits",
__meta__: #Ecto.Schema.Metadata<:loaded>
}}
iex(3)> # Create a new product in the "Fruits" category
iex(4)> {:ok, orange} = App.Shop.create_product(%{
name: "Orange",
price: Decimal.new("0.15"),
category_id: fruits.id
})
{:ok,
%App.Shop.Product{
id: "fc36e094-a28d-4f78-860d-0fd1e345c1ce",
name: "Orange",
price: Decimal.new("0.15"),
category_id: "cea38a50-2178-42c0-aad9-2624eec49609",
category: #Ash.NotLoaded<:relationship, field: :category>,
__meta__: #Ecto.Schema.Metadata<:loaded>
}}
iex(5)> # Load the category relationship for the orange product
iex(6)> {:ok, orange_with_category} = Ash.load(orange, :category)
{:ok,
%App.Shop.Product{
id: "fc36e094-a28d-4f78-860d-0fd1e345c1ce",
name: "Orange",
price: Decimal.new("0.15"),
category_id: "cea38a50-2178-42c0-aad9-2624eec49609",
category: %App.Shop.Category{
id: "cea38a50-2178-42c0-aad9-2624eec49609",
name: "Fruits",
__meta__: #Ecto.Schema.Metadata<:loaded>
},
__meta__: #Ecto.Schema.Metadata<:loaded>
}}
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{
id: "fc36e094-a28d-4f78-860d-0fd1e345c1ce",
name: "Orange",
price: Decimal.new("0.15"),
category_id: "cea38a50-2178-42c0-aad9-2624eec49609",
category: %App.Shop.Category{
id: "cea38a50-2178-42c0-aad9-2624eec49609",
name: "Fruits",
__meta__: #Ecto.Schema.Metadata<:loaded>
},
__meta__: #Ecto.Schema.Metadata<:loaded>
}}
iex(9)> orange2.category.name
"Fruits"
Ash 3.24+ inspects resources as plain %Module{} structs (the full
set of attributes is visible). Older Ash tutorials may still show the
opaque #Module<…> form produced by an earlier custom Inspect
implementation.
|
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:
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, public?: true
attribute :price, :decimal, public?: true
end
relationships do
belongs_to :category, App.Shop.Category do
allow_nil? false
public? true
end
end
actions do
default_accept [:name, :price, :category_id]
defaults [:create, :update, :destroy] (1)
read :read do
primary? true (2)
prepare build(load: [:category]) (3)
end
end
end
| 1 | Don’t include :read in the defaults when you add a custom read action. |
| 2 | This marks this action as the primary read action for the resource. |
| 3 | The 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{
id: "24348935-6148-4c75-9bf1-55f74ac9397a",
name: "Orange",
price: Decimal.new("0.15"),
category_id: "22ab0824-18ac-4daa-9a13-defd0b8bcd73",
category: %App.Shop.Category{
id: "22ab0824-18ac-4daa-9a13-defd0b8bcd73",
name: "Fruits",
__meta__: #Ecto.Schema.Metadata<:loaded>
},
__meta__: #Ecto.Schema.Metadata<:loaded>
}}
Note how the category is automatically loaded even though we didn’t specify load: [:category].
has_many
has_many is the mirror image of belongs_to. In our shop example a
Category has many Products.
+----------+ +-------------+
| Category | | Product |
+----------+ +-------------+
| id |----->| category_id |
| name | | id |
| | | name |
| | | price |
+----------+ +-------------+
Continuing the belongs_to
setup, add a has_many to the Category resource:
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, public?: true
end
relationships do
has_many :products, App.Shop.Product do (1)
public? true
end
end
actions do
default_accept [:name]
defaults [:create, :read, :update, :destroy]
end
end
| 1 | By default Ash uses the source attribute :id on this side, and
category_id on the destination. To override those, see
https://hexdocs.pm/ash/relationships.html. |
Let’s play with the relationship:
$ iex -S mix
Compiling 1 file (.ex)
Erlang/OTP 28 [erts-16.2.2] [...]
Interactive Elixir (1.20.0-rc.4) [...]
iex(1)> {:ok, fruits} = App.Shop.create_category(%{name: "Fruits"}) (1)
{:ok,
%App.Shop.Category{
id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
name: "Fruits",
products: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:loaded>
}}
iex(2)> App.Shop.create_product!(%{name: "Orange", category_id: fruits.id}) (2)
%App.Shop.Product{
id: "3ec1c834-70a8-403d-8814-3070c77b525e",
name: "Orange",
price: nil,
category_id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
__meta__: #Ecto.Schema.Metadata<:loaded>
}
iex(3)> App.Shop.create_product!(%{name: "Banana", category_id: fruits.id})
%App.Shop.Product{
id: "460d8cfa-2dad-4da0-95db-45012aa33621",
name: "Banana",
...
}
iex(4)> Ash.load(fruits, :products) (3)
{:ok,
%App.Shop.Category{
id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
name: "Fruits",
products: [
%App.Shop.Product{name: "Orange", ...},
%App.Shop.Product{name: "Banana", ...}
],
__meta__: #Ecto.Schema.Metadata<:loaded>
}}
iex(5)> App.Shop.get_category_by_name!("Fruits", load: [:products]) (4)
%App.Shop.Category{
id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
name: "Fruits",
products: [
%App.Shop.Product{name: "Orange", ...},
%App.Shop.Product{name: "Banana", ...}
],
__meta__: #Ecto.Schema.Metadata<:loaded>
}
| 1 | Create a category for fruits. |
| 2 | Create two products in that category. |
| 3 | Load the products for an already-fetched category with Ash.load/2. |
| 4 | Or, equivalently, pass load: [:products] to the read interface so
the association comes back pre-loaded. |
Sideload the products by default
If you always want products loaded whenever you fetch a category,
customize the read action:
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, public?: true
end
relationships do
has_many :products, App.Shop.Product do
public? true
end
end
actions do
default_accept [:name]
defaults [:create, :update, :destroy] (1)
read :read do
primary? true (2)
prepare build(load: [:products]) (3)
end
end
end
| 1 | Drop :read from the defaults list when you add a custom :read
action. |
| 2 | Mark the action as the primary read so it is used when no other action is specified. |
| 3 | Always preload products whenever we fetch a Category. |
Let’s test it in the iex:
iex(17)> App.Shop.get_category_by_name!("Fruits").products |> Enum.map(& &1.name)
["Orange", "Banana"]
many_to_many
many_to_many is a symmetric relationship: each record on one side
can be linked to many records on the other, and vice versa. Classic
example: a Tag resource can be attached to many Product resources,
and each Product can carry many Tag resources.
Under the hood Ash uses a join resource (sometimes called a join table) that holds one row per link.
+---------+ +------------+ +--------+
| Product | | ProductTag | | Tag |
+---------+ +------------+ +--------+
| id |<--->| product_id | | name |
| name | | tag_id |<--->| id |
| price | | | | |
+---------+ +------------+ +--------+
Setting Up a Fresh Ash App
The fastest way to get started with Ash is Igniter. One command scaffolds the project, pulls Ash (and whatever else you ask for), and wires up the config.
$ mix archive.install hex igniter_new
$ mix igniter.new app --install ash --yes
$ cd app
For a Phoenix application with Ash and PostgreSQL support:
$ mix igniter.new app --with phx.new --install ash,ash_postgres --yes
$ cd app
Igniter creates the App.Repo wrapper (when ash_postgres is
included), the application’s supervision tree, a sensible
.formatter.exs, and the Postgres config for every environment. You
still need to add the config :app, :ash_domains, [App.Shop] line to
config/config.exs once you define your first domain, which we do in
each chapter that uses this include.
Interactive web installer
You can also use the interactive web installer at https://ash-hq.org/#get-started to get a custom Igniter command tailored to your needs.
Manual setup alternative
If you want to see every file Igniter would create, walk through
the Ash Setup Guide. For a purely
manual route, mix new --sup app, add {:ash, "~> 3.0"} to
mix.exs, run mix deps.get, and configure .formatter.exs with
import_deps: [:ash]. Everything else in this chapter then matches
the Igniter flow.
We need three resources:
-
Tag -
Product -
ProductTag(the join between the two)
defmodule App.Shop.Tag do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
domain: App.Shop,
otp_app: :app
attributes do
uuid_primary_key :id
attribute :name, :string, public?: true
end
relationships do
many_to_many :products, App.Shop.Product do
through App.Shop.ProductTag (1)
source_attribute_on_join_resource :tag_id
destination_attribute_on_join_resource :product_id
public? true
end
end
actions do
default_accept [:name]
defaults [:read, :update, :destroy]
create :create do
primary? true
accept [:name]
argument :products, {:array, :map} (2)
change manage_relationship(:products,
type: :append_and_remove,
on_no_match: :create
) (3)
end
end
end
| 1 | Tell Ash which resource stores the join rows. |
| 2 | Adding a products argument to the create action lets callers
pass existing products when creating a new tag:
App.Shop.create_tag!(%{name: "Sweet", products: [apple, cherry]}). |
| 3 | manage_relationship/2 translates that argument into add/remove
operations on the ProductTag join resource. |
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, public?: true
attribute :price, :decimal, public?: true
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
public? true
end
end
actions do
default_accept [:name, :price]
defaults [:read, :update, :destroy]
create :create do
primary? true
accept [:name, :price]
argument :tags, {:array, :map} (2)
change manage_relationship(:tags,
type: :append_and_remove,
on_no_match: :create
)
end
end
end
| 1 | Mirror of the many_to_many from the Tag resource. |
| 2 | tags: [sweet, tropical] on create will attach those tags. |
defmodule App.Shop.ProductTag do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
domain: App.Shop,
otp_app: :app
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 | Join rows are effectively immutable: you delete and recreate them
instead of updating, so no :update action. |
| 2 | Two belongs_to relationships form the composite primary key. |
Finally register all three resources with the domain:
defmodule App.Shop do
use Ash.Domain, otp_app: :app
resources do
resource App.Shop.Product do
define :create_product, action: :create
define :list_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.Tag do
define :create_tag, action: :create
define :list_tags, action: :read
define :get_tag_by_id, action: :read, get_by: :id
define :get_tag_by_name, action: :read, get_by: :name
define :update_tag, action: :update
define :destroy_tag, action: :destroy
end
resource App.Shop.ProductTag
end
end
Example in iex
Let us work with a small catalogue.
| Ash uses UUIDs for primary keys. The integers in the diagram below are just for readability. |
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)
+-----------+-------+
Create the catalogue in iex:
$ iex -S mix
iex(1)> sweet = App.Shop.create_tag!(%{name: "Sweet"})
iex(2)> tropical = App.Shop.create_tag!(%{name: "Tropical"})
iex(3)> red = App.Shop.create_tag!(%{name: "Red"})
iex(4)> App.Shop.create_product!(%{name: "Apple", tags: [sweet, red]})
iex(5)> App.Shop.create_product!(%{name: "Banana", tags: [sweet, tropical]})
iex(6)> App.Shop.create_product!(%{name: "Cherry", tags: [red]})
Now fetch products with their tags, and tags with their products:
iex(7)> App.Shop.list_products!(load: [:tags])
... |> Enum.map(fn p -> %{product: p.name, tags: Enum.map(p.tags, & &1.name)} end)
[
%{product: "Apple", tags: ["Sweet", "Red"]},
%{product: "Banana", tags: ["Sweet", "Tropical"]},
%{product: "Cherry", tags: ["Red"]}
]
iex(8)> App.Shop.list_tags!(load: [:products])
... |> Enum.map(fn t -> %{tag: t.name, products: Enum.map(t.products, & &1.name)} end)
[
%{tag: "Sweet", products: ["Apple", "Banana"]},
%{tag: "Tropical", products: ["Banana"]},
%{tag: "Red", products: ["Apple", "Cherry"]}
]
Sideload the join by default
By default Ash does not load the related records. Make the read action pre-load them if you always need them:
actions do
default_accept [:name, :price]
defaults [:update, :destroy] (1)
read :read do
primary? true
prepare build(load: [:tags]) (2)
end
create :create do
primary? true
accept [:name, :price]
argument :tags, {:array, :map}
change manage_relationship(:tags,
type: :append_and_remove,
on_no_match: :create
)
end
end
| 1 | Remove :read from the defaults when you define a custom read. |
| 2 | Always load :tags on read. |
Do the equivalent on App.Shop.Tag to auto-load products. After
that, the same iex session works without the explicit load: option:
iex(9)> App.Shop.list_products!()
... |> Enum.map(fn p -> %{product: p.name, tags: Enum.map(p.tags, & &1.name)} end)
[
%{product: "Apple", tags: ["Sweet", "Red"]},
%{product: "Banana", tags: ["Sweet", "Tropical"]},
%{product: "Cherry", tags: ["Red"]}
]
Updating many_to_many relationships
The natural place to reassign tags is on the product’s :update
action. Duplicate the managed-relationship change from the create:
actions do
default_accept [:name, :price]
defaults [:read, :destroy]
create :create do
primary? true
accept [:name, :price]
argument :tags, {:array, :map}
change manage_relationship(:tags,
type: :append_and_remove,
on_no_match: :create
)
end
update :update do
primary? true
accept [:name, :price]
argument :tags, {:array, :map}
change manage_relationship(:tags,
type: :append_and_remove,
on_no_match: :create
)
end
end
iex session:
$ iex -S mix
iex(1)> good_deal = App.Shop.create_tag!(%{name: "Good deal"})
iex(2)> yellow = App.Shop.create_tag!(%{name: "Yellow"})
iex(3)> App.Shop.create_product!(%{name: "Banana", tags: [yellow, good_deal]}) (1)
iex(4)> App.Shop.get_product_by_name!("Banana", load: [:tags]).tags |> Enum.map(& &1.name) (2)
["Yellow", "Good deal"]
iex(5)> banana = App.Shop.get_product_by_name!("Banana") (3)
iex(6)> App.Shop.update_product!(banana, %{tags: [yellow]}) (4)
iex(7)> App.Shop.get_product_by_name!("Banana", load: [:tags]).tags |> Enum.map(& &1.name) (5)
["Yellow"]
| 1 | Create a Banana with two tags. |
| 2 | Verify the tags were attached. |
| 3 | Hold the banana in a variable for the update. |
| 4 | Reduce the tag list to just yellow. |
| 5 | The update removed the "Good deal" link; the join resource gets the stale row deleted automatically. |
Unique tag names
Right now you can create several tags with the same name:
iex(1)> App.Shop.create_tag!(%{name: "Yellow"}).id
"d206b758-..."
iex(2)> App.Shop.create_tag!(%{name: "Yellow"}).id
"5d66386c-..."
Fix that with an identities block:
defmodule App.Shop.Tag do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
domain: App.Shop,
otp_app: :app
attributes do
uuid_primary_key :id
attribute :name, :string, public?: true
end
identities do
# identity :unique_name, [:name] (1)
identity :name, [:name] do (2)
pre_check_with App.Shop (3)
end
end
# rest of resource unchanged
end
| 1 | If you use AshPostgres, this one line is enough; the unique
constraint is enforced by the database. Run mix ash.codegen to
create the matching migration. |
| 2 | The ETS data layer has no unique indexes, so we spell out the identity and tell Ash how to check it. |
| 3 | pre_check_with tells Ash to run a lookup via App.Shop before
writing, so duplicates fail with a clean changeset error instead of a
runtime crash. |
Now duplicates are rejected with a friendly error:
iex(1)> App.Shop.create_tag!(%{name: "Yellow"})
%App.Shop.Tag{id: "f03e163f-...", name: "Yellow", ...}
iex(2)> App.Shop.create_tag!(%{name: "Yellow"})
** (Ash.Error.Invalid) Input Invalid
* name: has already been taken
add_tag action
Sometimes you want to create a product and a brand-new tag in a single call:
actions do
default_accept [:name, :price]
defaults [:read, :destroy, :update]
create :create do
primary? true
accept [:name, :price]
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
Try it:
iex(1)> App.Shop.create_product!(%{name: "Banana", add_tag: %{name: "Yellow"}})
%App.Shop.Product{
id: "52049582-...",
name: "Banana",
price: nil,
tags: [
%App.Shop.Tag{id: "9b95f8cf-...", name: "Yellow", ...}
],
...
}
has_one
has_one is similar to belongs_to, with one twist: the foreign-key
column lives on the destination resource instead of the source, and
uniqueness on that column means the relationship is always 1:1.
Imagine our shop has occasional promotions: each product can have at most one active promotion, and each promotion applies to exactly one product.
+---------+ +------------+
| Product | | Promotion |
+---------+ +------------+
| id |----->| product_id |
| name | | id |
| price | | rebate |
+---------+ +------------+
Update the domain to register the new resource:
defmodule App.Shop do
use Ash.Domain, otp_app: :app
resources do
resource App.Shop.Product do
define :create_product, action: :create
define :get_product_by_name, action: :read, get_by: :name
end
resource App.Shop.Category
resource App.Shop.Promotion do
define :create_promotion, action: :create
end
end
end
Define the Promotion resource with a belongs_to :product:
defmodule App.Shop.Promotion do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
domain: App.Shop,
otp_app: :app
attributes do
uuid_primary_key :id
attribute :name, :string, public?: true
attribute :rebate, :integer, public?: true
end
relationships do
belongs_to :product, App.Shop.Product do
allow_nil? false
public? true
end
end
actions do
default_accept [:name, :rebate, :product_id]
defaults [:create, :read, :update, :destroy]
end
end
And add has_one :promotion on the Product resource:
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, public?: true
attribute :price, :decimal, public?: true
end
relationships do
belongs_to :category, App.Shop.Category do
public? true
end
has_one :promotion, App.Shop.Promotion do (1)
public? true
end
end
actions do
default_accept [:name, :price, :category_id]
defaults [:create, :read, :update, :destroy]
end
end
| 1 | has_one uses the same <relationship>_id convention as
belongs_to, but the column lives on Promotion, not on Product. |
Let’s use it in iex:
$ iex -S mix
Erlang/OTP 28 [erts-16.2.2] [...]
Interactive Elixir (1.20.0-rc.4) [...]
iex(1)> orange = App.Shop.create_product!(%{name: "Orange", price: Decimal.new("0.2")})
%App.Shop.Product{
id: "c9e9b4ba-408f-4c42-b1e0-e8b3799d5b1f",
name: "Orange",
price: Decimal.new("0.2"),
category_id: nil,
category: #Ash.NotLoaded<:relationship, field: :category>,
promotion: #Ash.NotLoaded<:relationship, field: :promotion>,
__meta__: #Ecto.Schema.Metadata<:loaded>
}
iex(2)> {:ok, _promo} =
...(2)> App.Shop.create_promotion(%{
...(2)> name: "15% off",
...(2)> rebate: 15,
...(2)> product_id: orange.id
...(2)> })
{:ok, %App.Shop.Promotion{...rebate: 15, product_id: "c9e9b4ba-..."}}
iex(3)> Ash.load(orange, :promotion) (1)
{:ok,
%App.Shop.Product{
id: "c9e9b4ba-408f-4c42-b1e0-e8b3799d5b1f",
name: "Orange",
promotion: %App.Shop.Promotion{
id: "68901cef-...",
name: "15% off",
rebate: 15,
product_id: "c9e9b4ba-408f-4c42-b1e0-e8b3799d5b1f",
...
},
...
}}
| 1 | Associations are lazy; call Ash.load/2 (or pass load: […] to
a read) when you need the related record. |
|
|