Custom / virtual fields on read

Hi All, I am creating an API based on an old system and can't modify the original, but I want to change the way the resources are read. More specifically I want to set a certain virtual field based on the value of an existing database field. Normally this is something I would achieve with a virtual field and then modify the output in the view layer. But I understand the idiomatic way in Ash is to push as much as possible into the resource. I read in another post that arguments are basically the equivalent of virtual fields, so looked into setting those based on an attribute value. What would be the best approach to achieve something like this. I already experimented with Ash.Query a bit, but not sure how to achieve reading an attribute from the fetched resource and setting an argument based on that. Any advise and/or guidance is much appreciated. I tried to find it in the docs but am a bit lost where to look... Thanks!
9 Replies
Jmanda
Jmanda•2y ago
Do you want to have a virtual field in your resource whose value is based on the existing field/attribute? If that's the case the you can use Ash Calculations: https://ash-hq.org/docs/guides/ash/latest/topics/calculations. For example if you have a User resource like the following:
defmodule MyApp.Accounts.User do
...
attributes do
attribute :first_name, :string
attribute :last_name, :string
end
...
end
defmodule MyApp.Accounts.User do
...
attributes do
attribute :first_name, :string
attribute :last_name, :string
end
...
end
you can add a full_name calculation(virtual field) to your resource by declaring a calculation :
calculations do
calculate :full_name, :string, expr(first_name <> " " <> last_name)
end
calculations do
calculate :full_name, :string, expr(first_name <> " " <> last_name)
end
that way you can now use user.full_name even if its not defined in your resource attributes. I think you can use the calculation in Ash.Query as well. If that's not what you're looking for you can share some code to provide additional context.
Ash HQ
Guide: Get Started
Read the "Get Started" guide on Ash HQ
drumusician
drumusicianOP•2y ago
cool! Yes I think that might work. Let me try that. ok, cool that worked with a custom Calculations module. For anyone else wondering. I had to set a difficulty level based on a genre, which is in a related resource. So I created this calculation in the main resource
calculations do
calculate(
:difficulty,
:string,
McFun.Games.Calculations.Difficulty
)
end
calculations do
calculate(
:difficulty,
:string,
McFun.Games.Calculations.Difficulty
)
end
And then the implementation in that module was just this:
defmodule McFun.Games.Calculations.Difficulty do
use Ash.Calculation

@impl true
def calculate(records, _opts, %{}) do
Enum.map(records, fn record ->
difficulty(record.genre.identifier)
end)
end

defp difficulty("sudoku_klein"), do: "easy"
defp difficulty("sudoku"), do: "hard"
defp difficulty("sudokueasy"), do: "normal"
defp difficulty(_), do: nil
end
defmodule McFun.Games.Calculations.Difficulty do
use Ash.Calculation

@impl true
def calculate(records, _opts, %{}) do
Enum.map(records, fn record ->
difficulty(record.genre.identifier)
end)
end

defp difficulty("sudoku_klein"), do: "easy"
defp difficulty("sudoku"), do: "hard"
defp difficulty("sudokueasy"), do: "normal"
defp difficulty(_), do: nil
end
šŸ˜€ thanks for the pointer @Jmanda !
Jmanda
Jmanda•2y ago
Awesome 🄳
drumusician
drumusicianOP•2y ago
Ok, now figuring out how to get it to pass into the graphQL response... šŸ˜… Ok stuck again. So the code interface works fine, but the graphQL API returns nil for all these calculated fields. Anyone know how to expose calculated fields in a graphQL API. It is complaining that my main resource doesn't have a primary read action, but that's not the case, so I guess it has to do with the calculation not having a graphQL type
[error] Task #PID<0.1711.0> started from #PID<0.1709.0> terminating
** (Ash.Error.Invalid) Input Invalid

* No primary action of type :read for resource McFun.Games.Game, and no action specified
(ash 2.9.16) lib/ash/api/api.ex:2012: Ash.Api.get_action/4
(ash 2.9.16) lib/ash/api/api.ex:1680: Ash.Api.load/4
(ash 2.9.16) lib/ash/api/api.ex:1631: Ash.Api.load!/4
(ash_graphql 0.25.5) lib/graphql/dataloader.ex:319: Dataloader.Source.AshGraphql.Dataloader.run_batch/2
[error] Task #PID<0.1711.0> started from #PID<0.1709.0> terminating
** (Ash.Error.Invalid) Input Invalid

* No primary action of type :read for resource McFun.Games.Game, and no action specified
(ash 2.9.16) lib/ash/api/api.ex:2012: Ash.Api.get_action/4
(ash 2.9.16) lib/ash/api/api.ex:1680: Ash.Api.load/4
(ash 2.9.16) lib/ash/api/api.ex:1631: Ash.Api.load!/4
(ash_graphql 0.25.5) lib/graphql/dataloader.ex:319: Dataloader.Source.AshGraphql.Dataloader.run_batch/2
Jmanda
Jmanda•2y ago
So I have never used AshGraphql before, but perhaps you can share your code I see how you are defining the GraphQL api in your resource? At least that will provide more context. You can also try to add primary? true to your read action? And it's likely you're referencing a module that does not exist, take a look at these discussions: https://discord.com/channels/711271361523351632/799097774523547669/861263803056652288 and https://discord.com/channels/711271361523351632/799097774523547669/823438146804252702
drumusician
drumusicianOP•2y ago
ok, haha. That simple change, marking one of the read actions as primary? true did the trick. Probably the whole graphQL engine just died on me without that... Thanks for the insights!
ZachDaniel
ZachDaniel•2y ago
Something you'll also need to do, is add def load/3 to express the dependency on genre.identifier
def load(_, _, _) do
[genre: [:identifier]]
end
def load(_, _, _) do
[genre: [:identifier]]
end
How I'd do that personally:
calculations do
calculate :difficulty, :atom, constraints(
cond do
genre_identifier == "sudoku_klein" ->
:easy
genre_identifier == "sudoku" ->
:medium
genre_identifier == "sudokueasy" ->
:normal
true ->
nil
end
) do
constraints [one_of: [:easy, :medium, :hard]]
end
end

# use a first aggregate to make `genre` usable in expressions on this resource
aggregates do
first :genre_identifier, [:genre], :identifier
end
calculations do
calculate :difficulty, :atom, constraints(
cond do
genre_identifier == "sudoku_klein" ->
:easy
genre_identifier == "sudoku" ->
:medium
genre_identifier == "sudokueasy" ->
:normal
true ->
nil
end
) do
constraints [one_of: [:easy, :medium, :hard]]
end
end

# use a first aggregate to make `genre` usable in expressions on this resource
aggregates do
first :genre_identifier, [:genre], :identifier
end
drumusician
drumusicianOP•2y ago
ok, thanks @Zach Daniel ! Need to wrap my head around this conceptually still, so I'll dig in. I have a similar thing now where I need to conditionally add a filter. So depending on the value of the record I need to do different kinds of filtering. Would that als be something to do in a similar way? so for instance: if type == "puzzle" => filter on certain publication dates, if type = "app", don't do those filters. Basically that could also be a calculation with a constraint. Or can I also add a constraint to a Filter?
ZachDaniel
ZachDaniel•2y ago
Depending on what he design is. You can also do it in a query oreparation Should be plenty of examples of preparations in the support forum

Did you find this page helpful?