I’m doing some performance and scalability updates on an application written in a mix of Ruby on Rails and React with Typescript. So far, we’ve added a GraphQL endpoint to our existing Rails app in order to improve the performance of several dashboard-style pages.
We brought in the excellent GraphQL Ruby gem to define and serve up our GraphQL schema. This gem comes with a neat generator tool that creates GraphQL types from your existing Rails model classes. Of course, as we built out our schema, we quickly ran into the N+1 query problem that almost always comes into play when building a GraphQL API.
To address the N+1 problem, we needed some form of batch loading or lazy loading that could integrate easily with Ruby GraphQL. Initially, we tried out GraphQL Batch. True to Ruby on Rails form, this gem patched into the GraphQL Ruby DSL seamlessly and allowed some simple cases to be dealt with in one line of code. However, we quickly ran into a wall of complications as our queries became more nuanced and complex. Checking permissions; joining across has_many
, belongs_to
, and has_many:through
associations; or doing things like counting aggregates all required custom handler classes.
Then we tried out BatchLoader. Its website lists a few reasons it stands out from other batch loading alternatives, and one reason caught our eye:
BatchLoader doesn’t force you to share batching through variables or custom defined classes, just pass a block to the batch method.
This block-centric API let us easily build out correctly batched fields, even if fields were based on more complex relationships.
Consider a set of model classes for a hypothetical school system:
class District < ApplicationRecord
has_many :buildings
end
class Building < ApplicationRecord
belongs_to :district
has_many :rooms
has_many :classes,
through: :rooms,
source: :room
has_many :employees
end
class Room < ApplicationRecord
belongs_to :building
has_many :classes
end
class Class < ApplicationRecord
belongs_to :room
end
class Employee < ApplicationRecord
belongs_to :building
end
A possible query might look something like this:
query BuildingInfoForDistrict {
districtById(id: 12) {
buildings {
roomCount
rooms {
cleanedOn
}
classes {
taughtBy
}
teachers: employees(type: "teacher")
aministrators: employees(type: "admin")
}
}
}
We want to find out some information about each building in district twelve:
- How many rooms are there?
- When is each room cleaned?
- Who teaches each class?
- Who are the teachers who work there?
- Who are the administrators who work there?
Below are examples of how implement each field on BuildingType
so that resolving this query requires a constant number of database queries.
Counting Associated Objects
def roomCount
BatchLoader::GraphQL.for(object.id).batch(default_value: 0) do |building_ids, loader|
Room.where(building_id: building_ids)
.group(:building_id)
.count
.each { |building_id, count| loader.call(building_id, count) }
end
end
Here, we let ActiveRecord and the database handle grouping and counting the number of rooms in each building. Once the query returns, loop through the results, and resolve each building ID with its associated count.
Has Many
def rooms
BatchLoader::GraphQL.for(object.id).batch(default_value: []) do |building_ids, loader|
Room.where(building_id: building_ids)
.each { |room| loader.call(room.building_id) { |rooms| rooms << room } }
end
end
This example demonstrates batch loading a has_many
relationship. It’s conceptually similar to the group and count example, but we need the actual rooms instead of the count. BatchLoader allows us to pass a block to the loader
lambda, which allows us to add each room to an array associated with that room’s building ID.
Has Many Through
def classes
BatchLoader::GraphQL.for(object.id).batch(default_value: []) do |building_ids, loader|
Room.where(building_id: building_ids)
.includes(:classes)
.each { |room| loader.call(room.building_id, room.classes) }
end
end
In this case, we need to determine all the classes owned by all the rooms in each building. The trouble is that classes are associated with buildings through rooms. This means that if we call into the Class
model, we’ll have no way to associate our array of building IDs with classes. But how do we return a Class
object from a query against the Room
model?
We can actually use Rails’ built-in eager loading tool. By adding includes(:classes)
, we tell Rails to first get all the matching rooms, then load all the associated classes at once and keep them in memory. That way, when we loop over the resulting rooms, we don’t trigger another database query each time we access room.classes
.
Batching Fields with Arguments
def employees(type: nil)
BatchLoader::GraphQL.for(object.id).batch(default_value: [], key: type) do |building_ids, loader, args|
employee_type = args[:key]
Employees.where(building_id: building_ids)
.where(type: employee_type)
.each { |employee| loader.call(employee.building_id, employee) }
end
end
Finally, we have our employees field. This field accepts a parameter that lets us filter by what type of employee we’re interested in. How can we batch load these requests?
BatchLoader allows us to specify a key for the batch. In this case, we set our key to the requested employee type. Under the hood, BatchLoader partitions all of the requests whose keys match into the same batch. In the handler block, we can pull our employee type back out of arguments and resolve all the employees of the matching type for all of the building IDs in the batch.
This approach also handles arbitrary arguments. Simply bundle args up into a hash and set the batch key to the JSON dump of the hash. Then all requests with matching arguments will be batched together, and the args can be extracted with JSON.parse()
.
BatchLoader is a great tool for addressing the N+1 query problem in a GraphQL Ruby API. The simple, block-based API allows you to easily write custom field resolvers using normal ActiveRecord
query strategies without cluttering your project up with lots of custom handler classes.