Count loader for graphql-batch
Efficiently counting records and load associations using GraphQL Ruby
When implemented a GraphQL API using GraphQL Ruby it is beneficial to ensure that executed queries don’t trigger any N+1 query problems.
One (of many) solutions to this is graphql-batch which provides a means of delcaring loaders to perform batched execution, avoding N+1 query problems.
A record and assocation loader is provided by default and these perform their jobs admirably, though alone they do not cover the gamut of N+1 issues that can be encountered.
In this post we’ll address the issue of counting generically using CountLoader
, and more explicitly associations using AssociationCountLoader
.
The following example type if fetched multiple times in a query (e.g. as a collection) would cause multiple queries in counting:
1
2
3
4
5
6
7
8
class Types::UserType < Types::BaseObject
field :name, String, null: false
field :post_count, Integer, "blog posts authored by this user", null: false
def post_count
object.posts.count
end
end
We can resolve this issue by implementing a CountLoader
and updating the typing accordingly.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class CountLoader < GraphQL::Batch::Loader
def initialize(model, field)
super()
@model = model
@field = field
end
def perform(ids)
counts = @model.where(@field => ids).group(@field).count
counts.each { |id, count| fulfill(id, count) }
ids.each { |id| fulfill(id, 0) unless fulfilled?(id) }
end
end
1
2
3
4
5
6
7
8
class Types::UserType < Types::BaseObject
field :name, String, null: false
field :post_count, Integer, "blog posts authored by this user", null: false
def post_count
CountLoader.for(Post, :user_id).load(object.id)
end
end
Using the CountLoader
above the multiple queries as a result of counting have been resolved. As can be seen the structure of CountLoader
is very similar to the provided RecordLoader
.
Counting ActiveRecord associations
Reddit user u/Owumaro suggested using an ActiveRecord association name rather than having to specify the join model and foriegn key back.
The provided AssociationCountLoader
reflects on the provided relationship to alleviate the need for specifying these extra implementation details (it should be less britle).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class AssociationCountLoader < GraphQL::Batch::Loader
def initialize(model, association_name)
super()
@model = model
@association_name = association_name
end
def perform(records)
reflection = @model.reflect_on_association(@association_name)
reflection.check_preloadable!
klass = reflection.klass
field = reflection.join_primary_key
counts = klass.where(field => records).group(field).count
records.each do |record|
record_key = record[reflection.active_record_primary_key]
fulfill(record, counts[record_key] || 0)
end
end
end
Using AssociationCountLoader
post_count
in Types::UserType
changes to:
1
2
3
def post_count
AssociationCountLoader.for(User, :posts).load(object)
end
2021-04-14 Update: Implementations of the loaders shown here are being maintained at https://github.com/jamesbrooks/graphql-batch-loaders