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