Dealing with N+1 in GraphQL (Part 2)
In Part 1, I was showing how at Product Hunt we use GraphQL::Batch
to solve N+1 when loading associations.
Those are the obvious first candidates for optimization in the GraphQL world.
But the N+1 queries is hidden in a lot of places.
For example, let's say we have the following query:
query {
posts(date: 'today') {
id
name
isVoted
}
}
This isVoted
returns whether the current user (viewer) have voted for this post.
The naive implementation would be something like:
class Graph::Types::PostType < GraphQL::Schema::Object
field :id, ID, null: false
# ..
field :is_voted, function: Graph::Resolvers::IsVotedResolver.new
end
class Graph::Resolvers::IsVotedResolver < GraphQL::Function
type !types.Boolean
def call(post, _args, ctx)
# handle not-logged in users
return false unless ctx.current_user?
# check if user have voted for a post
ctx.current_user.votes.where(post_id: post.id).exists?
end
end
This generates the following queries:
SELECT * FROM posts WHERE DATE(featured_at) = {date}
SELECT * FROM votes WHERE post_id={post.id} and user_id={user_id}
SELECT * FROM votes WHERE post_id={post.id} and user_id={user_id}
SELECT * FROM votes WHERE post_id={post.id} and user_id={user_id}
SELECT * FROM votes WHERE post_id={post.id} and user_id={user_id}
SELECT * FROM votes WHERE post_id={post.id} and user_id={user_id}
SELECT * FROM votes WHERE post_id={post.id} and user_id={user_id}
SELECT * FROM votes WHERE post_id={post.id} and user_id={user_id}
-- ...
Those are N+1. But there isn't a build in solution in Rails for those. Because of this, they are often unaddressed.
The solution we have in Product Hunt for this problem as of most of N+1 is to uses GraphQL::Batch
:
class Graph::Resolvers::IsVotedResolver < GraphQL::Function
type !types.Boolean
def call(post, _args, ctx)
# handle not-logged in users
return false unless ctx.current_user?
# `.for` ensures we get the same loader instance
loader = VotesLoader.for(ctx.current_user)
# `.load` adds the post id to the list of things to load
loader.load(post.id)
# return a future promise for this particular post load
loader
end
class VotesLoader < GraphQL::Batch::Loader
def initialize(user)
@user = user
end
# this gets called after all `isVoted` were collected
def perform(post_ids)
# THE TRICK: load with one query only the voted post id from votes, not all posts
voted_post_ids = @user.votes.where(post_id: post_ids).pluck(:post_id)
# fulfill the future promises
post_ids.each do |post_id|
fulfill post_id, voted_post_ids.include?(post_id)
end
end
end
end
This now generates only two queries.
SELECT * FROM posts WHERE DATE(featured_at) = {date}
SELECT post_id FROM votes WHERE post_id IN ({post_ids})
This problem exists with normal Rails applications. It isn't GraphQL specific. But I have rarely seen this being addressed. With GraphQL::Batch
we can elegantly solve this problem.