/ ruby

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.