Implementing Custom Pagination With GraphQL-Ruby

Handshake's Pierre Maris demystifies the process of how to implement pagination in GraphQL Ruby using a Custom Connection class.

Here at Handshake, we recently began a project to start replacing some of the REST API endpoints in our Ruby on Rails application with GraphQL, using the GraphQL Ruby Gem. At first it was smooth sailing with GraphQL; we were converting our models to GraphQL and writing the simple queries we were starting out with ease.

Then came the first time when we had to add pagination to a query.

In GraphQL, the standard way of implementing pagination is by using the Connection model. Put simply, Connections are a wrapper a level above the object you’re paginating that handles the pagination. They accept arguments for the number of results to retrieve and a cursor, perform the pagination, and then return the paginated result along with the current cursor and any other metadata you define. Despite the “cursor” terminology, Connections don’t have to use cursor-based pagination. The cursor could also be an offset.

The GraphQL documentation is useful in telling you what Connections are, but isn’t going to tell you anything about how to implement them in your language and GraphQL library of choice. GraphQL Ruby does have documentation about using Custom Connections—unfortunately, it’s lacking in the details on how to actually write one. It says what methods must be implemented, but not much else. There are no example code examples to follow along with, and critically, there’s no mention of how the Connection’s cursor and limit arguments are passed in. 

I’ve found this to be an all too common problem with GraphQL documentation; there’s plentiful high-level documentation on the specification itself, but often not much when it comes to implementation details. In the end, after digging through the GraphQL Ruby API documentation and source code, we were able to complete our first paginated query by creating our own Custom Connection to wrap a search backed by ElasticSearch.

Hopefully this post will help demystify the process of exactly how to implement pagination in GraphQL Ruby using a Custom Connection class.

Custom Connection classes

Custom connections are created by creating a subclass of GraphQL::Pagination::Connection.

There are four methods the subclass must define:

  • nodes: Returns the paginated collection of items, using the query arguments passed in as instance attributes.
  • cursor_for(items): Returns the cursor (as a string) for a single item in the collection returned by the nodes method.
  • has_next_page: Returns a boolean indicating whether there is a next page of results when paginating forward.
  • has_previous_page: Returns a boolean indicating whether there is a previous page of results when paginating backward.

The instance attributes of the class contain everything needed to perform the pagination, including the raw pagination arguments from the query:

  • after_value: The value for the after argument in the query, which is the cursor to fetch the next page of results that come after this value.
  • before_value: The value for the before argument in the query, which is the cursor to fetch the next page of results that come before this value.
  • first_value: The value for the first argument in the query, to fetch the first n results.
  • last_value: The value for the last argument in the query, to fetch the last n results.
  • items: The class the connection was initialized with, which is the collection of items to paginate. The name is a bit misleading, as this can be absolutely anything you want it to be. It could be a full collection of items to paginate, an ActiveRecord model, a database connection, a search class, or whatever else you may need.
  • max_page_size: This is either the value defined for the specific query being executed if it has its own max page size defined, otherwise it’s the max page size in your schema.

Pagination in GraphQL can go either forward or backward. The after_value and first_value attributes and the has_next_page method are used for forward pagination. On the other hand, the before_value and last_value attributes and the has_previous_page method are used when paginating backwards.

Creating a custom Connection class

To keep this simple, we’ll be creating a Connection to handle paginating a generic ActiveRecord::Relation. We won’t be making any assumptions about the objects contained within that Relation beyond them having a unique ID.

Note: GraphQL Ruby does already ship with an ActiveRecordRelationConnection built on top of a generic RelationConnection, which uses offset-based pagination. Cursor-based pagination is available in the Stable Relation Connections offered in the Pro version. We’re building our own version to demonstrate with a collection that any Rails developer should be familiar with.

We’re also going to start by fully implementing forward pagination before moving on to adding the option to also paginate backwards.

Starting out

To start, create a brand new Connection subclass anywhere within your GraphQL project:

class CustomConnection < GraphQL::Pagination::Connection
end
view raw 1977385383_01.rb hosted with ❤ by GitHub

You may also want to set a value for default_max_page_size in your schema if you haven’t already.

The cursor_for method and encoding a cursor

This method returns a cursor for a single item in the results. It’s good to start with this method to decide on how you’re going to encode the cursor before you have to decode it once you start work on the nodes method.

How do you decide on what to use as a cursor? It depends on how the underlying collection of objects you’re paginating itself handles sorting, cursors and offsets. Often it depends on how the result is being sorted, and the cursor will be the same field that’s being sorted on. Cursors should also always be unique, otherwise you run the risk of either skipping or duplicating results when there are multiple items with the same cursor. If the value you want to use isn’t unique, combine it with a unique tiebreaker such as an ID to create a unique cursor.

Cursors are also commonly base 64 encoded. It’s not required, but as the GraphQL documentation puts it: it’s a reminder that cursors are opaque and the format should not be relied upon.

In our case, we’re only using the item’s ID, which is unique and makes our method as simple as it gets. It does have to be converted to a string first, however:

def cursor_for(item)
Base64.encode64(item.id.to_s)
end
view raw 1977385383_02.rb hosted with ❤ by GitHub

Paginating using the nodes method

This is where the actual pagination happens.

Because we’re only starting out with paginating forward, you’ll only be using the after_value attribute for the cursor and the first_value attribute for the number of results to return. If an after_value wasn’t included in the query, there’s no cursor and the returned results will start from the beginning, and if there’s no first_value to set a page size, you’ll fallback to using the max_page_size.

But, there’s an important consideration before starting: how are you going to determine whether there is a next page or a previous page? That may be easy if the underlying class you’re paginating already gives that to you, or if the full collection of objects is readily available to compare to the paginated result. It’s more difficult if fetching the paginated results requires a potentially expensive database query or network request.

In our case, we found the easiest option was to query for one additional object and memoize the result. This is a common technique in cursor-based pagination; the number of results we return is still limited to the page size, but the presence of that extra object tells us whether there’s another page of results.

We’re going to be using the page size in several places in this class, so it’s helpful to create a short helper method to get the page size from either the first_value or max_page_size to avoid repeating this over and over:

def page_size
@first_value || max_page_size
end
view raw 1977385383_03.rb hosted with ❤ by GitHub

Now, create a memoized method to perform the pagination using the cursor and the page size:

def results
@_results ||= begin
# If there’s an after cursor, decode it and only query for records with an id that come after that cursor
@items = @items.where('id > ?', Base64.decode64(@after_value)) if @after_value.present?
@items.limit(page_size + 1) # Fetch one extra result to determine if there's another page
end
end
view raw 1977385383_04.rb hosted with ❤ by GitHub

Once we have all of that, we can write a nodes method that’s only a single line:

def nodes
results.slice(0, page_size) # Remove the extra result we fetched to check if there's another page
end
view raw 1977385383_05.rb hosted with ❤ by GitHub

Because we fetched one extra result to determine if there was another page, we have to remove it before returning the results. Keep in mind that popping the last result off of the end won’t work—if there were fewer results than the page size, doing so would always remove the last real result.

Finishing the has_next_page and has_previous_page methods

All that’s left is finishing the has_next_page and has_previous_page methods, and has_previous_page will always return false because we’re not adding backwards pagination yet.

The only thing has_next_page has to do is check to see if that one extra result we fetched is present. If it is, there will be more results than the page size and we’ll know there’s another page.

def has_next_page
results.size > page_size
end
# Always return false because we're not implementing backwards pagination yet
def has_previous_page
false
end
view raw 1977385383_06.rb hosted with ❤ by GitHub

The full class

Your Custom Connection is now finished and ready to use:

class CustomConnection < GraphQL::Pagination::Connection
def nodes
results.slice(0, page_size) # Remove the extra result we fetched to check if there's another page
end
def cursor_for(item)
Base64.encode64(item.id.to_s)
end
def has_next_page
results.size > page_size
end
# Always return false because we're not implementing backwards pagination yet
def has_previous_page
false
end
def page_size
@first_value || max_page_size
end
def results
@_results ||= begin
# If there’s an after cursor, decode it and only query for records with an id that come after that cursor
@items = @items.where('id > ?', Base64.decode64(@after_value)) if @after_value.present?
@items.limit(page_size + 1) # Fetch one extra result to determine if there's another page
end
end
end
view raw 1977385383_07.rb hosted with ❤ by GitHub

Using the new connection class

To use the new Connection, you’ll add it into a query definition in the QueryType class in your schema. You must call .connection_type on the object being returned by the query to tell GraphQL return a Connection with those objects, then initialize and return the Custom Connection class with the collection you want to paginate in the definition of that query.

For the sake of this example, let’s imagine we have an ApplicationRecord subclass named User and a GraphQL type mapped to that subclass named Customer. The query we’re going to add our Custom Connection to fetches all of the Users, ordered by ID, and returns all of the Customer objects:

class QueryType < GraphQL::Schema::Object
field :users, Types::Customer.connection_type, null: false do
description "Show all customers"
end
def employers(query:)
CustomConnection.new(User.order(:id))
end
end
view raw 1977385383_08.rb hosted with ❤ by GitHub

Adding backwards pagination

If you want to also support backwards pagination, you’ll be using the before_value for the cursor and last_value for the page size. The presence of either of those in the query will indicate that the pagination should be backwards rather than forward.

Like the page size, you’ll be checking the pagination direction all over the place so it’s useful to create another helper method:

def direction
if @before_value.present? || @last_value.present?
:backward
else
# Fall back to forward by default
:forward
end
end
view raw 1977385383_09.rb hosted with ❤ by GitHub

Speaking of the page size, update that method to handle both directions:

def page_size
if direction == :forward
@first_value || max_page_size
elsif direction == :backward
@last_value || max_page_size
end
end
view raw 1977385383_10.rb hosted with ❤ by GitHub

Updating the query

Now we can update the querying in our results method to handle both directions:

def results
@_results ||= begin
if direction == :forward
# If there’s an after cursor, decode it and only query for records with an id that come after that cursor
@items = @items.where('id > ?', Base64.decode64(@after_value)) if @after_value.present?
elsif direction == :backward
# If there’s a before cursor, decode it and only query for records with an id that come before that cursor
@items = @items.where('id < ?', Base64.decode64(@before_value)) if @before_value.present?
@items = @items.reverse_order
end
@items.limit(page_size + 1) # Fetch one extra result to determine if there's another page
end
end
view raw 1977385383_11.rb hosted with ❤ by GitHub

There are two differences in the backwards query:

  1. The condition in the where clause is reversed to find records with an id that come before the cursor.
  2. The ordering of the records is reversed to get the results that come immediately before the cursor. If we skipped this step, the same first results would be returned every time.

Updating has_next_page and has_previous_page

Supporting backwards pagination means has_previous_page will have to do something other than always returning false. Add the same comparison of the results to the page size to both methods, and also add a new check to always return false unless the pagination is in the correct direction for each:

def has_next_page
return false unless direction == :forward
results.size > page_size
end
def has_previous_page
return false unless direction == :backward
results.size > page_size
end
view raw 1977385383_12.rb hosted with ❤ by GitHub

The fully completed class

Here you have it, a fully implemented Custom Connection class supporting both forward and backward pagination:

def has_next_page
return false unless direction == :forward
results.size > page_size
end
def has_previous_page
return false unless direction == :backward
results.size > page_size
end
view raw 1977385383_12.rb hosted with ❤ by GitHub

Conclusions

Writing your own Custom Connections isn’t all that difficult once you know how it’s done.

Of course, there are plenty of improvements that could be made to the one we just created to make it more useful in the real world. For starters, using an ID as a hardcoded cursor renders it useless for any query that sorts on another field. That could be solved by adding in a method to set the fields used to encode and decode the cursor, for example.

With some small improvements, you could have a re-usable, general purpose Connection. In the long-run, it may even save you time by being the single piece of code responsible for all pagination.

Photo by Stas Knop from Pexels