The adoption of GraphQL seems to be increasing and that means we should be aware of it and understand it.
In this article, we'll accomplish the following things:
- Create a Rails App
- Define our simple DB schema
- Define our simple GraphQL schema, types, queries and mutations
- Optimize our API's to avoid N+1 queries
- Add pagination to the API's
Note: There is a new version of the graphql. Version 1.8 has a slightly different syntax and folder structure.
Versions:
- ruby -v 2.4.1
- graphql -v 1.7.14
- rails -v 5.1.4
- Create a rails app with the below command. Use your own name instead of APP_NAME
rails new APP_NAME- Let's define our tables with
rails g modelcommands
rails generate model Pet name:text kind:text owner_id:integer
rails generate model Owner first_name:text last_name:text bio:text
rails generate model Activity description:text pet_id:integer owner_id:integer
- It is time to run the generated migrations
rails db:create && rails db:migrate
- The tables and models have been generated. We will now add relationships to our models
# activity.rb
class Activity < ApplicationRecord
belongs_to :owner
belongs_to :pet
end
# pet.rb
class Pet < ApplicationRecord
belongs_to :owner, required: false
has_many :activities
end
# owner.rb
class Owner < ApplicationRecord
has_many :activities
has_many :pets
end- Now that our db schema and models are defined, let's add some data to the app
Add faker gem to the Gemfile:
gem 'faker', group: :developmentAnd then install it by running
bundle installOnce it is installed, start your rails console
rails cIn the console, let's populate our models with some data
15.times { Pet.create(name: Faker::Dog.name, kind: Faker::Dog.breed) }
15.times { Owner.create(first_name: Faker::Name.first_name, last_name: Faker::Name.last_name, bio: Faker::Lorem.paragraph) }
owners = Owner.all
Pet.all.each do |pet|
owner = owners.sample
pet.update(owner: owner)
5.times { Activity.create(description: Faker::Seinfeld.quote, owner: owner, pet: pet) }
end- It is now time to add graphql to our app. In the
Gemfileadd graphql dependency
# Gemfile
gem 'graphql', '1.7.14'Once the dependency has been added to the Gemfile, let's install the gem and initialize it in our project
$ bundle install
$ rails generate graphql:installThis will create the "conventional" folder structure and add a default type and schema that we'll need to define ourselves
- Now it is time to define our graphQL types. There is a types folder under graphql, that holds our types. Let's add our 3 types
# in ./graphql/types/pet_type.rb file
Types::PetType = GraphQL::ObjectType.define do
name 'PetType'
description 'Represents a pet'
field :id, !types.ID, 'The ID of the pet'
field :name, types.String, 'The name of the pet'
field :kind, types.String, 'A type of animal'
# notice that we could define a custom field and provide a block that will
# define how to resolve/build this field
field :capKind, types.String, 'An all caps version of the kind' do
resolve ->(obj, args, ctx) {
obj.kind.upcase
}
end
# notice that we could map active record relations
field :owner, Types::OwnerType, 'The owner of the pet'
field :activities, types[Types::ActivityType]
end
# in ./graphql/types/owner_type.rb
Types::OwnerType = GraphQL::ObjectType.define do
name 'OwnerType'
description 'Represents a owner model'
field :id, types.ID, 'The unique ID of the owner'
field :firstName, types.String, 'The first name of the owner', property: :first_name # notice that we use property to map active record field to the graphql field
field :lastName, types.String, 'The last name of the owner', property: :last_name
field :bio, types.String, 'A bio for the owner giving some details about them'
field :activities, types[Types::ActivityType]
field :pets, types[Types::PetType]
end
#in ./graphql/types/activity_type.rb
Types::ActivityType = GraphQL::ObjectType.define do
name 'ActivityType'
description 'Represents a activity for owner and a pet'
field :id, types.ID, 'The ID of the activity'
field :description, types.String, 'The name for the activity'
field :owner, Types::OwnerType, 'The owner who participated in the activity'
field :pet, Types::PetType, 'The pet that the activity was performed for'
end- Let's define couple of queries
# ./graphql/type/query_type.rb
field :pet, Types::PetType do
description 'Retrieve a pet post by id'
argument :id, !types.ID, 'The ID of the pet to retrieve'
resolve ->(obj, args, ctx) {
Pet.find(args[:id])
}
end
field :pets, types[Types::PetType] do
description 'Retrieve a list of all pets'
resolve ->(obj, args, ctx) {
Pet.all
}
end- Let's start graphiQL!! Wait, what is it? It is a graphic tool to query your new endpoint/s and it comes with graphql ruby gem out of the box when using with ... yes, full rails. It is not available with rails api. To start it, just start your rails app!
rails serveNavigate to localhost:3000/graphiql
Experiment with the query or run the below query:
{
pet(id: 1) {
name
kind
capKind
owner {
lastName
firstName
}
}
pets {
id
name
owner {
lastName
firstName
bio
pets {
name
kind
}
}
activities {
description
}
}
}- That's great!!! But what about the rest of the CRUD ops? In graphQL, we use MUTATIONS:
queries that have consequences. Let's create a
create petmutation.
# ./graphql/types/mutation_type.rb
Types::MutationType = GraphQL::ObjectType.define do
name "Mutation"
field :createPet, Types::PetType do
description 'Allows you to create a new pet'
argument :name, !types.String
argument :kind, !types.String
resolve ->(obj, args, ctx) {
pet = Pet.new(args.to_h)
pet.save
pet
}
end
endTry out a create pet query(reload your localhost page and the code complete should pop up)
mutation {
createPet(name: "Raisin", kind: "Frenchie") {
id
name
kind
}
}Try adding update and delete mutations on your own?
How does graphql api perform at the moment? Does listing pets and related objects result in N+1 number of queries? Look in the terminal where you've started rails to see what and how many queries are run for the below query?
{
pets {
id
name
owner {
lastName
firstName
bio
pets {
name
kind
}
}
activities {
description
}
}
}Yes, the results are not so great. How could we deal with this? There are couple solutions and today will look at the garphql-batch
Couple of more options to consider:
- batch-loader gem
- Using .includes in ActiveRecord
- graphql-batch gem
Add it to our Gemfile and run bundle install
gem 'graphql-batch'- We will now create two custom loaders: let's take a look
# in ./graphql/record_loader.rb
# this class will take foreign keys for all of our records
# and retrieve it from the provided model in one call to the db
class RecordLoader < GraphQL::Batch::Loader
def initialize(model)
@model = model
end
def perform(ids)
@model.where(id: ids).each { |record| fulfill(record.id, record) }
ids.each { |id| fulfill(id, nil) unless fulfilled?(id) }
end
end
# in ./graphql/one_to_many_loader
# this will take the related model and the foreign key to the current
# object and will batch all of the selects for us
# could this be written even simpler?
class OneToManyLoader < GraphQL::Batch::Loader
def initialize(model, foreign_key)
@model = model
@foreign_key = foreign_key
end
def perform(ids)
all_records = @model.where(@foreign_key => ids).to_a
# this is for a one to many relationship batch processing
# we want to fulfill every foreign key with an array of matched records
ids.each do |id|
matches = all_records.select{ |r| id == r.send(@foreign_key) }
fulfill(id, matches)
end
end
end- Now we need to update our schema to use graphql-batch
# ./graphql/APP_NAME_schema.rb
GraphqlTutorialSchema = GraphQL::Schema.define do
mutation(Types::MutationType)
query(Types::QueryType)
use GraphQL::Batch # add this line
end- Let's now update our type definitions to resolve model relationships with the new resolve definitions
# ./graphql/pet_type.rb
# in Pet type replace the relationships with the below
field :owner, -> { Types::OwnerType } do
resolve -> (obj, args, ctx) {
RecordLoader.for(Owner).load(obj.owner_id)
}
end
field :activities, -> { types[Types::ActivityType] } do
resolve -> (obj, args, ctx) {
OneToManyLoader.for(Activity, :pet_id).load(obj.id)
}
end
# ./graphql/owner_type.rb
field :pets, -> { types[Types::PetType] } do
resolve -> (obj, args, ctx) {
OneToManyLoader.for(Pet, :owner_id).load(obj.id)
}
end
# ./graphql/activity_type.rb
field :owner, -> { Types::OwnerType } do
resolve -> (obj, args, ctx) do
RecordLoader.for(Owner).load(obj.owner_id)
end
end
field :pet, -> { Types::PetType } do
resolve -> (obj, args, ctx) do
RecordLoader.for(Pet).load(obj.pet_id)
end
end - Let's check how many queries will run with our new batch loaders?
{
pets {
id
name
owner {
lastName
firstName
bio
pets {
name
kind
}
}
activities {
description
}
}
}We are now down to four database queries, which is a great improvement.
- What about pagination? As data sets grow, will not having pagination have a negative impact on performance?
Here is the spec: https://facebook.github.io/relay/graphql/connections.htm
The spec calls for last, first and after. When providing last and first, that is the number of records to take from either the beginning or the end of the result set. After specifies the offset.
The default schema for pagination looks something like below. pageInfo provides some info about navigation.
edges provides the data in the node elements. That is where we put our usual query.
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
edges {
cursor
node {
}
}- We could add pagination two our query and we could also add pagination to the related entities in our query. We are going to accomplish all of that by using connections. Connections comes with pagination out of the box. Let's give that a try.
# ./graphql/types/query_type.rb
# we update from field to connection and define new return type of
# Types::PetType.connection_type
connection :pets, Types::PetType.connection_type do
resolve -> (obj, args, ctx) {
Pet.all
}
endThat's all! Let's play around with it now:
{
pets(last: 4) {
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
edges {
cursor
node {
id
name
owner {
lastName
firstName
bio
pets {
name
kind
}
}
activities {
id
description
}
}
}
}
}What about paginating one to many relationships like our activities? Wouldn't it be nice to only show the first 2 and then show more if user requests it?
We could do that as well. Add a connection to our pet_type.rb:
connection :activities, Types::ActivityType.connection_type do
resolve -> (obj, args, ctx) {
OneToManyLoader.for(Activity, :pet_id).load([obj.id])
}
endNow we need to change our query, because activies returns a paginated object and not a plain Types::ActivityType
{
pets(last: 4) {
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
edges {
cursor
node {
id
name
owner {
lastName
firstName
bio
pets {
name
kind
}
}
activities(first: 2) {
edges {
node {
id
decription
}
}
}
}
}
}
}Resources:
Noteworthy: