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.
To start, create a brand new Connection subclass anywhere within your GraphQL project:
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:
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:
Now, create a memoized method to perform the pagination using the cursor and the page size:
Once we have all of that, we can write a nodes method that’s only a single line:
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.
The full class
Your Custom Connection is now finished and ready to use:
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:
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:
Speaking of the page size, update that method to handle both directions:
Updating the query
Now we can update the querying in our results method to handle both directions:
There are two differences in the backwards query:
- The condition in the where clause is reversed to find records with an id that come before the cursor.
- 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:
The fully completed class
Here you have it, a fully implemented Custom Connection class supporting both forward and backward pagination:
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.