Return a Related Resource via Calculation in Ash
Here's how to return a related resource using a calculation in the Ash framework.
As with most of the things I've learned in Ash, this was hard won knowledge.
Scenario
I have a review. It has votes. One of those votes is the "final" vote. I want the review to have a field on it for final_vote. This will be queryable on the graph using ash_graphql. It'll be a field that I can Ash.load. I can walk the deeper down into the relationships of the vote, if I'd like.
Vote resource
The Vote resource isn't super important to the main point of this article. But I want to show all the pieces. Sometimes I feel like assumed knowledge is missing from the Ash docs.
What makes a vote final is what kind of permission was used to cast it. If it was cast using the review_final_vote permission, it is the final vote. Calculation for that is in the code below. It returns a simple boolean. It can use an expr() expression. This is nice, because this filtering is done at the data layer. It'll show up at query time -- in my case, as generated sql.
defmodule BookStore.ReviewVote do
use Ash.Resource,
domain: BookStore,
extensions: [AshGraphql.Resource, AshPaperTrail.Resource]
# ...
attributes do
uuid_primary_key :id
attribute :slug, :atom do
constraints one_of: [:great, :grand, :glorious]
allow_nil? false
public? true
end
end
relationships do
# ...
belongs_to :review, BookStore.Review do
allow_nil? false
public? true
end
belongs_to :permission, Bookstore.Permission do
allow_nil? false
public? true
end
end
calculations do
calculate :is_final,
:boolean,
expr(
if exists(permission, slug == "review_final_vote") do
true
else
false
end
),
filterable?: true,
public?: true,
end
end
Review resource
This is the more interesting resource because it contains a calculation that should return a ReviewVote resource type. I don't want a primitive value. I want a fully-fleshed out struct that is known as a resource to Ash and can be treated in all other ways as a related object, as in the relationships blocks.
I tried several things before I got here. I tried aggregates using the first aggregate type. That was insufficient. The most I could return was the id of the related resource, like this:
aggregates do
first :final_vote, :votes, :id do
filter expr(is_final == true)
public? true
end
end
Past that, I was off to calculations. I scrambled around in those docs a bunch, never quite finding this scenario laid out. It turns out that this scenario of returning an Ash.Resource must be done as a module calculation, in Elixir, at runtime, in memory. expr() at the data layer won't work. I don't know why, but this is per Zach Daniel:
You won’t be able to this using an expression calculation, you’ll need to use an Elixir calculation. This is because expression calculations done in the data layer can’t return related resources, only relationships and runtime calculations can do that.
Here are a few more details...
The type on the return is :struct, but an extra bit is needed as a constraint in the body: instance_of, which specifies which Ash.Resource struct to hydrate the struct into. In this case, it's BookStore.ReviewVote. I have often tried to set BookStore.ReviewVote where :struct is, only to be stymied. That doesn't work.
Instead of an expr() expression in the calculation, reference the module for your Ash.Calculation implementation. In this case, that's BookStore.FinalVoteCalculation.
Next, the calculation module must implement the behavior of Ash.Calculation. This requires a load and a calculate callback. load must load all the resource attributes (not usually required in a load directive) and all relationships relative to the resource in question (we're inside of Review) that are needed to implement the calculate function body. The calculate function takes records (which, again, are Reviews in this case) and must return the value of the calculation for each review (hence the Enum.map at the first level). For every review, we're returning a final vote or nil, if not found.
defmodule BookStore.Review do
use Ash.Resource,
domain: BookStore,
extensions: [AshGraphql.Resource]
# ...
relationships do
# ...
has_many :votes, BookStore.ReviewVote do
public? true
end
end
calculations do
calculate :final_vote,
:struct,
BookStore.FinalVoteCalculation do
constraints instance_of: BookStore.ReviewVote
public? true
end
end
end
defmodule BookStore.FinalVoteCalculation do
use Ash.Resource.Calculation
@impl true
def load(_, _, _), do: [votes: [:is_final]]
@impl true
def calculate(records, _, _context) do
Enum.map(records, fn review ->
Enum.find(review.votes, fn vote ->
vote.is_final == true
end)
end)
end
end
Query result
And now, glorious day, we can query this field and return its value:
query fetchAllReviews {
listReviews {
results {
finalVote {
id
slug
}
votes {
id
isFinal
slug
}
}
}
}And return its value:
{
"data": {
"listReviews": {
"results": [
{
"finalVote": null,
"votes": []
},
{
"finalVote": {
"id": "7bb4c450-350d-451b-b11f-34d4a2a7b8e3",
"slug": "license",
},
"votes": [
{
"id": "2e43bad1-4f74-475c-b333-fe4e9570feaa",
"isFinal": false,
"slug": "theatrical"
},
{
"id": "7bb4c450-350d-451b-b11f-34d4a2a7b8e3",
"isFinal": true,
"slug": "license"
}
]
}
]
}
}
}