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!