2.7 KiB
GraphQL Batch Loaders
Motivation
In a Canvas REST API end-point. N+1 Queries are commonly found, but easy to avoid. When a controller action runs, we know what data is being requested and can preload appropriately.
As a GraphQL type resolver executes, it is too late to preload data. Consider the following query:
query assignmentsAndGroupSets {
course(id: "1") {
assignmentsConnection {
nodes {
id
groupSet { # N+1 !?
name
}
}
}
}
}
When resolving the groupSet
field above, the only context we have is an
individual assignment. It's not possible to use the normal
scope.preload(...)
approach of preventing N+1 queries.
To solve this problem, instead of returning an ActiveRecord instance in the
groupSet
resolver, we use the graphql-batch
gem to return a deferred value.
Example:
# bad example
def group_set
assignment.group_set # this will result in N+1 queries
end
# good example
def group_set
Loaders::AssociationLoader.for(Assignment, :group_category).load(assignment)
end
# short (but still good) example
def group_set
# a helper method is provided since this is such a common use-case
load_association(:group_category)
end
# bad example (async confusion)
def group_set
group_set = nil
Loaders::AssociationLoader.for(Assignment, :group_category).
load(assignment).
then {
group_set = assignment.group_category
}
group_set # this will still be nil when at this point
# (you must return a promise when dealing with loaders)
end
See graphql-batch for more information.
Available Batch Loaders
Loaders::AssociationLoader
can be used for any instances that would have used
.preloads
or ActiveRecord::Associations::Preloader.new
in the past (it uses
those methods under the hood).
Loaders::IDLoader
and Loaders::ForeignKeyLoader
can be used to batch-load
records by id.
It may also be necessary to write your own batch loader from time to time.
How To Write a New Batch Loader
Writing a new batch loader is easy. At a minimum, you must define a class that
inherits from GraphQL::Batch::Loader
which defines a perform
method (and
probably a constructor).
Example:
class CustomLoader < GraphQL::Batch::Loader
def initialize(*args)
# constructor arguments can be used to provide information in the perform
# method. they also define "buckets" for batching
end
def perform(objects)
results = do_something_to_batch_load_data
objects.each { |o|
# fulfill provides the value for the deferred object we returned in
# our resolver
fulfill(o, results[o])
}
end
end