Building an aggregates-only Resource

I'm building an admin dashboard that will display several aggregate data points sourced from different database tables. These data points should be available from a single AshGraphql query. How would you do this? I created a resource called Dashboard. My first approach was to create a calculation to calculate a data point. That wasn't going anywhere, so I defined an primary read action that loaded the data point from inside the prepare block. That's where I'm at so far. The problem is that calling read() on the API returns nil. Here is an example of the kind of data I want to expose: - Member counts - Signup counts - Event counts - Attendee counts
54 Replies
moxley
moxleyOP2y ago
The test for this looks like this:
test "success", ctx do
ctx =
ctx
|> create_event()
|> create_member(roles: ["admin"])
|> create_signup()

dashboard =
Dashboard
|> Ash.Query.set_tenant(ctx.org.id)
|> Ash.Query.for_read(:read, %{}, actor: ctx.member)
|> GF.Ash.read_one!(actor: ctx.member)

# fails
assert dashboard != nil
end
test "success", ctx do
ctx =
ctx
|> create_event()
|> create_member(roles: ["admin"])
|> create_signup()

dashboard =
Dashboard
|> Ash.Query.set_tenant(ctx.org.id)
|> Ash.Query.for_read(:read, %{}, actor: ctx.member)
|> GF.Ash.read_one!(actor: ctx.member)

# fails
assert dashboard != nil
end
The big challenge is trying to figure out what goes inside the MemberCountCalculation module. I'm not finding what I need in the documentation. And here's the action:
actions do
read :read do
get? true
primary? true

prepare fn query, context ->
if context.actor do
org_id = context.actor.org_id
counts = GF.Members.basic_member_counts(org_id)

Ash.DataLayer.Simple.set_data(query, [
struct!(__MODULE__, %{
members_count: counts.active_members
})
])
else
query
end
end
end
end
actions do
read :read do
get? true
primary? true

prepare fn query, context ->
if context.actor do
org_id = context.actor.org_id
counts = GF.Members.basic_member_counts(org_id)

Ash.DataLayer.Simple.set_data(query, [
struct!(__MODULE__, %{
members_count: counts.active_members
})
])
else
query
end
end
end
end
Is this the right approach? What exactly goes in the prepare block to make this work?
ZachDaniel
ZachDaniel2y ago
Hmm...not sure why its returning nil for the result, as that pattern looks generally correct The way you're doing it is a reasonable way to do what you're looking to do. Although likely what I'd do is add calculations and just return struct!(__MODULE__, %{}) from the read. I'd populate a random UUID also, as resources expect that. Even in cases like this it can be useful and is cheap enough to do I think. i.e struct!(__MODULE__, %{id: Ash.UUID.generate()}) Have you confirmed that your hook there is being called? you said its returning nil, but is your call to set_data definitely happening for example? If you call read_one does it return a list? **read instead of read_one?
moxley
moxleyOP2y ago
Okay, that helps to know if I'm on the right track. The hook you mention, is that the prepare block? Yes, that's getting called. In fact, it's called twice. The first time, it has an actor, and the second time, it doesn't. I suspect the second call is the one that's being used for the return value. I added id: Ash.UUID.generate() too.
ZachDaniel
ZachDaniel2y ago
hm...its getting called twice ??
moxley
moxleyOP2y ago
Yeah, I don't know why.
ZachDaniel
ZachDaniel2y ago
try putting it in a before_action block actually since you don't technically want it to happen on query validation it could mean that somewhere we are rerunning query preparations, which isn't necessarily that big of a bug since anything intensive should be behind a before_action block there is an Ash.Query.before_action just like Ash.Changeset
moxley
moxleyOP2y ago
Does the Ash.Query.before_action() go inside the prepare block? After some debugging, it shows that the prepare block gets called both for Ash.Query.for_read((), and API.read_one!(). So here's the action def:
actions do
read :read do
get? true
primary? true

prepare fn query, context ->
IO.puts("read prepare")

Ash.Query.before_action(query, fn query ->
IO.puts("before action")

if context.actor do
IO.puts("actor present")
org_id = context.actor.org_id
counts = GF.Members.basic_member_counts(org_id)

Ash.DataLayer.Simple.set_data(query, [
struct!(__MODULE__, %{
id: Ash.UUID.generate(),
members_count: counts.active_members
})
])
else
IO.puts("No actor present")
query
end
end)
end
end
end
actions do
read :read do
get? true
primary? true

prepare fn query, context ->
IO.puts("read prepare")

Ash.Query.before_action(query, fn query ->
IO.puts("before action")

if context.actor do
IO.puts("actor present")
org_id = context.actor.org_id
counts = GF.Members.basic_member_counts(org_id)

Ash.DataLayer.Simple.set_data(query, [
struct!(__MODULE__, %{
id: Ash.UUID.generate(),
members_count: counts.active_members
})
])
else
IO.puts("No actor present")
query
end
end)
end
end
end
The read_one!() is still returning nil, and read!() returns an empty list. The "before_action" and "actor present" debug statements are displayed in the test output. The "No actor present" is no longer displayed.
ZachDaniel
ZachDaniel2y ago
something very strange is happening then can you inspect the value of query.__validated_for_action__ at the beginning of your preparation?
moxley
moxleyOP2y ago
query.__validated_for_action__ : nil It's nil
ZachDaniel
ZachDaniel2y ago
both times its called? The reproduction here seems pretty simple, I'll take a look at it this evening or tomorrow morning. For now, what you actually want to do is pass the actor when building the query
moxley
moxleyOP2y ago
Okay, I tried something. I commented out the multitenancy definition, and now it's returning the struct.
ZachDaniel
ZachDaniel2y ago
🤔 huh huh that doesn't make a lot of sense what was the multitenancy definition?
moxley
moxleyOP2y ago
Oh wait. It's not that. I commented out the Ash.Query.set_tenant(ctx.org.id) in the test
ZachDaniel
ZachDaniel2y ago
so strange
moxley
moxleyOP2y ago
That's where the issue stems
ZachDaniel
ZachDaniel2y ago
so if a tenant is set, for some reason, its calling your query preparations twice So do for_read(...., actor: actor) Using the code interface, for example, solves that problem, but that is actually the best way to pass things in when building queries i.e Query.for_read(..., actor: actor, authorize?: authorize?, tenant: tenant) same for change sets, and we carry it over it just doesn't technically matter if you don't use the actor in preparations/changesets. But...for 3.0 maybe I'll actually make that error, and require passing it when building the query, to avoid this confusion
moxley
moxleyOP2y ago
Or, another way to get it to pass is to leave in the Ash.Query.set_tenant(ctx.org.id), but comment out the multitenancy section.
ZachDaniel
ZachDaniel2y ago
good to know so that means its not in the tenant setting code its in the "doing multitenant stuff" code
moxley
moxleyOP2y ago
About the API.read!() call, I realized that about the redundant actor specification, and removed it, so that it's only in the for_read() call. I did that earlier.
ZachDaniel
ZachDaniel2y ago
wow, okay, so this is actually pretty interesting there is an interaction here I didn't consider
moxley
moxleyOP2y ago
So here's my test now:
dashboard =
Dashboard
|> Ash.Query.set_tenant(ctx.org.id)
|> Ash.Query.for_read(:read, %{}, actor: ctx.member)
|> GF.Ash.read_one!()

dbg(dashboard)
dashboard =
Dashboard
|> Ash.Query.set_tenant(ctx.org.id)
|> Ash.Query.for_read(:read, %{}, actor: ctx.member)
|> GF.Ash.read_one!()

dbg(dashboard)
And based on what you said, the set_tenant() call is unecessary, if I pass the tenant to the for_read() call...
ZachDaniel
ZachDaniel2y ago
Yep okay nvm I thought I had found it but I hadn't but also I'm investigating in safari on an ipad 😆
moxley
moxleyOP2y ago
So now I'm at this in the test:
dashboard =
Dashboard
|> Ash.Query.for_read(:read, %{}, actor: ctx.member, tenant: ctx.org.id)
|> GF.Ash.read_one!()

dbg(dashboard)
dashboard =
Dashboard
|> Ash.Query.for_read(:read, %{}, actor: ctx.member, tenant: ctx.org.id)
|> GF.Ash.read_one!()

dbg(dashboard)
ZachDaniel
ZachDaniel2y ago
I believe this will be easy for me to debug. because it should be easy to reproduce actually, lets try something real quick can you raise an error if actor is nil and show me the stacktrace?
moxley
moxleyOP2y ago
Raise it if the actor is nil? It's not nil though. Do you mean in the before_action() fn?
ZachDaniel
ZachDaniel2y ago
maybe I need to regroup 😆 is it still getting called twice? but now both times the actor is not nil?
moxley
moxleyOP2y ago
I'ts not getting called twice now, because I added the before_action() call. The prepare is getting called twice, but the before_action() anon fn is getting called once:
actions do
read :read do
get? true
primary? true

prepare fn query, context ->
IO.inspect(query.__validated_for_action__, label: "query.__validated_for_action__ ")

Ash.Query.before_action(query, fn query ->
IO.puts("before action")

if context.actor do
IO.puts("actor present")
org_id = context.actor.org_id
counts = GF.Members.basic_member_counts(org_id)

Ash.DataLayer.Simple.set_data(query, [
struct!(__MODULE__, %{
id: Ash.UUID.generate(),
members_count: counts.active_members
})
])
else
raise "No actor present"
end
end)
end
end
end
actions do
read :read do
get? true
primary? true

prepare fn query, context ->
IO.inspect(query.__validated_for_action__, label: "query.__validated_for_action__ ")

Ash.Query.before_action(query, fn query ->
IO.puts("before action")

if context.actor do
IO.puts("actor present")
org_id = context.actor.org_id
counts = GF.Members.basic_member_counts(org_id)

Ash.DataLayer.Simple.set_data(query, [
struct!(__MODULE__, %{
id: Ash.UUID.generate(),
members_count: counts.active_members
})
])
else
raise "No actor present"
end
end)
end
end
end
When I comment out the multitenancy block, the prepare block only gets called once.
ZachDaniel
ZachDaniel2y ago
But it still doesn't work right?
moxley
moxleyOP2y ago
It does work for that case Found it! I needed to add the tenant attribute value to the struct:
Ash.DataLayer.Simple.set_data(query, [
struct!(__MODULE__, %{
id: Ash.UUID.generate(),
members_count: counts.active_members,
org_id: org_id
})
])
Ash.DataLayer.Simple.set_data(query, [
struct!(__MODULE__, %{
id: Ash.UUID.generate(),
members_count: counts.active_members,
org_id: org_id
})
])
I should have thought of that. The prepare block is still called twice.
ZachDaniel
ZachDaniel2y ago
Okay, yeah that makes sense Sounds like we probably need some helpers for this potentially like helpers for constructing manually returned records, where it sets some metadata that the simple data layer can yell at you about
moxley
moxleyOP2y ago
Cool.
ZachDaniel
ZachDaniel2y ago
Sorry I didn't catch that sooner
moxley
moxleyOP2y ago
So now I'm on to testing the GQL query...
ZachDaniel
ZachDaniel2y ago
🤞
moxley
moxleyOP2y ago
I'm getting this in the logs:
"Cannot query field \"getAdminDashboard\" on type \"RootQueryType\"
"Cannot query field \"getAdminDashboard\" on type \"RootQueryType\"
ZachDaniel
ZachDaniel2y ago
Is this in a new api>?
moxley
moxleyOP2y ago
My graphql section looks like this:
graphql do
type :admin_dashboard

queries do
get :get_admin_dashboard, :read do
identity false
end
end
end
graphql do
type :admin_dashboard

queries do
get :get_admin_dashboard, :read do
identity false
end
end
end
No, it's an existing api
ZachDaniel
ZachDaniel2y ago
is it in the registry?
moxley
moxleyOP2y ago
Yep
ZachDaniel
ZachDaniel2y ago
hmm....from a gql perspective this shouldn't be any different than any other thing maybe restart/recompile?
moxley
moxleyOP2y ago
I'll try force-compile
ZachDaniel
ZachDaniel2y ago
does it show up when you look at the schema?
moxley
moxleyOP2y ago
Looks like force-compile resolved that. I'm onto another issue...
ZachDaniel
ZachDaniel2y ago
strange, that shouldn't be necessary what's up?
moxley
moxleyOP2y ago
I think it's just a bad test setup...
ZachDaniel
ZachDaniel2y ago
I think what you'll also want to do is make those fields into calculations, now that you've got the action returning. That way when a subset of fields are requested over gql it won't calculate all of them
moxley
moxleyOP2y ago
Okay, I see. Yeah, try that out.
ZachDaniel
ZachDaniel2y ago
👍 LMK if you run into anything else/if the issue you mentioned isn't due to your tests 😆
moxley
moxleyOP2y ago
The GraphQL query is working. Thanks for your help! I might need help with the calculations, but let's see how far I can get...
ZachDaniel
ZachDaniel2y ago
I can give you a quick template to get you started, unless you'd like to figure it out on your own
moxley
moxleyOP2y ago
That would be helpful!
ZachDaniel
ZachDaniel2y ago
defmodule YourApp.YourApi.AdminDashboard.Calculations.MemberCounts do
use Ash.Calculations

# This is technically not necessary because you always select org_id in your manual action
# but is good form
def select(_, _, _), do: [:org_id]

def calculate(records, _, _) do
# There will only ever be one in your setup
Enum.map(records, fn record ->
member_counts(record.org_id)
end)
end
end
defmodule YourApp.YourApi.AdminDashboard.Calculations.MemberCounts do
use Ash.Calculations

# This is technically not necessary because you always select org_id in your manual action
# but is good form
def select(_, _, _), do: [:org_id]

def calculate(records, _, _) do
# There will only ever be one in your setup
Enum.map(records, fn record ->
member_counts(record.org_id)
end)
end
end
Then in your admin dashboard resource:
calculations do
calculate :member_counts, :map, YourApp.YourApi.AdminDashboard.Calculations.MemberCounts
end
calculations do
calculate :member_counts, :map, YourApp.YourApi.AdminDashboard.Calculations.MemberCounts
end
moxley
moxleyOP2y ago
I see. Nice! I converted it to a calculation, and it's working. One small question: In the read prepare block, I'm getting the tenant value from the actor, because I couldn't find it another way. Is ther a way to get the tenant without having to get it from the actor?
ZachDaniel
ZachDaniel2y ago
It should be in query.tenant

Did you find this page helpful?