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 Review
s 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"
}
]
}
]
}
}
}