N + 1 in GraphQL on Rails

We can also run into n + 1 queries when dealing with GraphQL in Rails. The standard pattern for solving N + 1 in GraphQL is with loaders.

Let’s update the project from our previous N + 1 post to use GraphQL. Add gem "graphql" to our Gemfile. Then run the following commands:

blog ➤ bundle
Fetching gem metadata from https://rubygems.org/............
...
blog ➤ rails generate graphql:install
      create  app/graphql/types
      create  app/graphql/types/.keep
      create  app/graphql/blog_schema.rb
      create  app/graphql/types/base_object.rb
      create  app/graphql/types/base_argument.rb
      create  app/graphql/types/base_field.rb
      create  app/graphql/types/base_enum.rb
      create  app/graphql/types/base_input_object.rb
      create  app/graphql/types/base_interface.rb
      create  app/graphql/types/base_scalar.rb
      create  app/graphql/types/base_union.rb
      create  app/graphql/types/query_type.rb
add_root_type  query
      create  app/graphql/mutations
      create  app/graphql/mutations/.keep
      create  app/graphql/mutations/base_mutation.rb
      create  app/graphql/types/mutation_type.rb
add_root_type  mutation
      create  app/controllers/graphql_controller.rb
       route  post "/graphql", to: "graphql#execute"
     gemfile  graphiql-rails
       route  graphiql-rails
      create  app/graphql/types/node_type.rb
      insert  app/graphql/types/query_type.rb
      create  app/graphql/types/base_connection.rb
      create  app/graphql/types/base_edge.rb
      insert  app/graphql/types/base_object.rb
      insert  app/graphql/types/base_object.rb
      insert  app/graphql/types/base_union.rb
      insert  app/graphql/types/base_union.rb
      insert  app/graphql/types/base_interface.rb
      insert  app/graphql/types/base_interface.rb
      insert  app/graphql/blog_schema.rb
Gemfile has been modified, make sure you `bundle install`
# we then run bundle again to install GraphiQL
blog ➤ bundle

Awesome. The GraphQL process installs a GraphiQL engine where we can interact with our GraphQL API. Run rails s in a separate tab and visit http://localhost:3000/graphiql to see our GraphiQL interface.

There’s not much we can do with the interface at the moment. Let’s create the GraphQL type for our models:

blog ➤ rails g graphql:object User
      create  app/graphql/types/user_type.rb
blog ➤ rails g graphql:object Post
      create  app/graphql/types/post_type.rb
blog ➤ rails g graphql:object Comment
      create  app/graphql/types/comment_type.rb

By default, these generated type include all info in the database. Let’s remove the id references and replace them with associations:

module Types
  class UserType < Types::BaseObject
    field :id, ID, null: false
    field :username, String, null: false
    field :created_at, GraphQL::Types::ISO8601DateTime, null: false
    field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
    field :posts, [PostType], null: false
  end
end

module Types
  class PostType < Types::BaseObject
    field :id, ID, null: false
    field :title, String, null: true
    field :body, String, null: true
    field :created_at, GraphQL::Types::ISO8601DateTime, null: false
    field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
    field :featured, Boolean, null: false
    field :comments, [CommentType], null: false
  end
end

module Types
  class CommentType < Types::BaseObject
    field :id, ID, null: false
    field :text, String, null: true
    field :created_at, GraphQL::Types::ISO8601DateTime, null: false
    field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
  end
end

To expose our users in our schema, let’s replace the test_field line in our QueryType with the following:

field :users, UserType.connection_type, null: false, description: "A connection for all of our users."

def users
  User.all
end

This allows us to query for our users names:

query {
  users {
    nodes {
      username
    }
  }
}

Gives us:

{
  "data": {
    "users": {
      "nodes": [
        {
          "username": "keoghpe"
        },
        {
          "username": "elon"
        },
        {
          "username": "gandalf"
        },
        {
          "username": "dhh"
        },
        {
          "username": "frodo"
        }
      ]
    }
  }
}

Let’s try query for some nested data, for posts & comments:

query {
  users {
    nodes {
      username

      posts {
        title
        body

        comments {
          text
        }
      }
    }
  }
}

This mamoth query runs & returns our expected data, but generates a TONNE of N + 1 queries:

  User Load (0.1ms)  SELECT "users".* FROM "users"
  Post Load (1.5ms)  SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = ?  [["user_id", 1]]
  Post Load (0.6ms)  SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = ?  [["user_id", 2]]
  Post Load (0.8ms)  SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = ?  [["user_id", 3]]
  Post Load (0.6ms)  SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = ?  [["user_id", 4]]
  Post Load (0.7ms)  SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = ?  [["user_id", 5]]
   (0.1ms)  SELECT sqlite_version(*)
  Comment Load (3.7ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ?  [["post_id", 1]]
  Comment Load (0.8ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ?  [["post_id", 2]]
  Comment Load (0.8ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ?  [["post_id", 3]]
  Comment Load (0.7ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ?  [["post_id", 4]]
  Comment Load (0.7ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ?  [["post_id", 5]]
  Comment Load (0.7ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ?  [["post_id", 6]]
  Comment Load (0.7ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ?  [["post_id", 7]]
  Comment Load (0.7ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ?  [["post_id", 8]]
  Comment Load (0.9ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ?  [["post_id", 9]]
  Comment Load (0.7ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ?  [["post_id", 10]]
  Comment Load (0.9ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ?  [["post_id", 11]]
  # ... and a tonne more queries!

We could solve this by updating our user method to use includes:

    def users
      User.all.includes(posts: :comments)
    end

This does resolve our N + 1 problem:

   (0.1ms)  SELECT sqlite_version(*)
  User Load (0.1ms)  SELECT "users".* FROM "users"
  Post Load (1.5ms)  SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN (?, ?, ?, ?, ?)  [["user_id", 1], ["user_id", 2], ["user_id", 3], ["user_id", 4], ["user_id", 5]]
   (0.1ms)  SELECT sqlite_version(*)
  Comment Load (26.5ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (?, ?,
# etc

However, now we will always make these queries, even when we’re not interested in the associations. Our original query for just usernames is more inefficient, as it queries the posts and comments tables unnecessarily.

GraphQL Loaders to the Rescue!

Let’s reverse our users method to it’s original contents:

def users
  User.all
end

The graphql-batch gem allows us to batch requests to the database in GraphQL. Batch loaders allow us to collect up all the ids for a model and make a single query to the database. If the field isn’t part of the GraphQL query, we never make the SQL query.

To get started, we add gem 'graphql-batch' to our Gemfile and run bundle. We also need to add the following to our blog_schema.rb:

  use GraphQL::Batch

The Gem contains an example loader for associations. We can create a new loaders directory in our graphql directory and copy this file there (making sure to add the Loaders namespace):

module Loaders
  class AssociationLoader < GraphQL::Batch::Loader
  # ... the rest of the code from the repo goes here.
  end
end

To use this loader, we add new methods to our UserType and PostType:

# in UserType:
    def posts
      Loaders::AssociationLoader.for(User, :posts).load(object)
    end

# in PostType:

    def comments
      Loaders::AssociationLoader.for(Post, :comments).load(object)
    end

Now we can run our queries, and only the appropriate number of queries will be made!

Written on December 14, 2021