By Luke Jones
Add pagination to your GraphQL schema without the boilerplate
We introduce you to therelay-pagination-directive, a new package from Nearform. This package reduces the boilerplate required to implement cursor-based pagination in GraphQL.
You can add the directive to your schema, tag the fields you wish to paginate and all of the types will be generated for you as well as wrapping your resolver responses to create the Connection model objects. All you need to do is update your data queries.
The benefit of this is it reduces work duplication. This means you have more time to work on revenue-generating tasks, such as designing, developing and delivering new products and features.
The problem
At some point, you’ll need to implement pagination within your GraphQL server. Whether to support a paginated UI function or to enable loading large payloads without crashing your server.
The GraphQL documentation describes the ‘Connection Model’ as the best approach to solving pagination within GraphQL. To implement this we need to make the following changes:
Create
Connection
andEdge
types for each type we wish to paginate.Add
first
andafter
args to the schema field (forward only pagination).Update your data queries to limit the response size, based on the
first
value, and filter based on theafter
value. Ensure your query has a consistent sort order.Update the resolvers of each type to return the
Connection
object. Within this map your dataset to match theEdge
type.
This can generate a lot of extra work and 90% of the time you’re replicating the effort for each type you wish to paginate.
Making things easier
If you've used GQL directives before you're ready to use this library. Here's an example of integrating the directive with Mercurius. The functionality of this directive happens within the GraphQL execution, so this should be similar for any provider.
import { connectionDirective } from 'relay-pagination-directive'
import Fastify from 'fastify'
import mercurius from 'mercurius'
const { connectionDirectiveTypeDefs, connectionDirectiveTransformer } =
connectionDirective()
const typeDefs = `
type People {
id: ID!
name: String!
type: String!
}
type Query {
people: People @connection // Directive added to the type we wish to
// paginate
}
`
const resolvers = {
Query: {
people: async (root, { first, after }, { app }) => {
return app.db.getUsers(first, after)
},
},
}
// Include the provided directive type definition along with your
// application types
const schema = makeExecutableSchema({
typeDefs: [connectionDirectiveTypeDefs, typeDefs],
resolvers
})
// Apply the directive schema transformer
const connectionSchema = connectionDirectiveTransformer(schema)
app.register(mercurius, {
schema: connectionSchema,
graphiql: true
})
await app.listen({ port: 3000 })
Did you notice how we've decorated the people
query with the @connection
directive? By adding this and using the connectionDirectiveTransformer
to wrap your schema you're ready to paginate your users list.
The @connection
directive will take the GQL type it's been applied to and generate the related Connection
and Edge
for you. Following this example, our generated schema would now look like this.
type PageInfo {
startCursor: String
endCursor: String
hasNextPage: Boolean!
hasPreviousPage: Boolean!
}
type People {
id: ID!
name: String!
type: String!
}
type PeopleEdge {
node: People!
cursor: ID!
}
type PeopleConnection {
edges: [PeopleEdge!]!
pageInfo: PageInfo
}
type Query {
people(first: Int!, after: ID): PeopleConnection
}
As well as generating these types for you, the people
query resolver will be wrapped so that the existing result will be transformed to match the Connection Model schema. This means your existing resolver response can remain unchanged.
One change you will need to make is updating your database queries to respect the first
and after
values. You’ll also need to ensure your query sorting will be the same across all page queries. Here is an example of converting a simple ‘get all’ query to support pagination.
/* Original query */
select id, name
from users
/* Updated to support pagination */
select id, name
from users
where id < 10 -- If 'after' is supplied add the where clause to get the next page
order by id desc -- Ensuring consistent ordering
limit 10 -- The 'first' value restricting the response size
Full examples of implementing pagination support are available in the repository
Continuing with the above example, our resolver returns an array of People
records. An array response from the resolver is assumed to be a collection of nodes and so the transformed result will look like this:
Modelling multi-edge relationships
Often we need to represent multiple relationships to the same model.
Graph data entity relationship diagram
Within GraphQL, this is done by adding properties to the edge between two nodes. This means we need to create multiple Connections/Edges for the same base type. This is achieved in relay-pagination-directive
by supplying a prefix
argument when adding the directive to your types.
type Film {
id: ID!
name: String!
release: Int!
}
type People {
id: ID!
name: String!
type: String!
films: Film! @connection(prefix: "PeopleFilm")
}
type Cinema {
id: ID!
name: String!
films: Film! @connection(prefix: "CinemaFilm")
}
type Query {
people: [People!]! @connection
cinemas: Cinema @connection
}
In this instance, the following types will be generated:
PeopleEdge
andPeopleConnection
forQuery.people
PeopleFilmEdge
andPeopleFilmConnection
forPeople.films
CinemaEdge
andCinemaConnection
forQuery.cinemas
CinemaFilmEdge
andCinemaFilmConnection
forCinema.films
As shown above, we have different fields available for the PeopleFilm
and CinemaFilm
relationships (roles
, performance
, showTimes
etc). So that these properties are added to our generated types, you need to provide their definitions via the configuration object that is passed to the connectionDirective
function.
const typeOptionsMap = {
People: {
edgeProps: {
PeopleFilm: {
roles: '[String!]!',
performance: 'Int!'
}
}
},
Cinema: {
edgeProps: {
CinemaFilm: {
price: 'Int1',
showTimes: 'JSON'
}
}
}
}
const { connectionDirectiveTypeDefs, connectionDirectiveTransformer } =
connectionDirective(typeConnectionMap)
Generating opaque cursors
GraphQL pagination suggests the use of opaque string values for our cursors. That is a string which holds no referential value when viewed by a user, generally a base64 encoded string. This provides us with flexibility over the server pagination implementation as the value holds no meaning to the consumer.
By default, we will look for an id
property on your nodes and use this, along with the name of the GraphQL type, to generate a cursor value. It's possible to use a different property for the cursor value or to provide an encoding function to generate a cursor. It is also possible to skip cursor generation altogether and just use a property value that exists on your objects.
You can decide on the cursor implementation strategy for your application on a per-type basis using the typeOptionsMap
configuration object as shown below:
import { encodeCursor, connectionDirective } from 'relay-pagination-directive'
const typeOptionsMap = {
People: {
encodeCursor: false, // Don't use any encoding, pick the cursor value from
cursorPropOrFn: 'otherId' // the 'otherId' property
},
Cinema: {
encodeCursor: true, // the default option
cursorPropOrFn: 'otherId' // encodeCursor('Cinema', item['otherId'])
}
Film: {
cursorPropOrFn: (type, item) => {
// Add multiple properties to the cursor to drive
// multi-value sorting on your DB queries
return encodeCursor(type, `${item.id}:${item.OtherId}`)
}
}
}
const { connectionDirectiveTypeDefs, connectionDirectiveTransformer } =
connectionDirective(typeOptionsMap)
Providing an accurate "hasNextPage" value
One of the key drivers of pagination is the ability to know whether you should query for another page. In the Connection Model, this is powered by the pageInfo.hasNextPage
value. If you choose not to provide this value, then we'll determine the value based on whether the length of returned nodes matches that of the requested page size. This can be effective in situations where you aren't displaying any UI element to indicate there is a next page. In scenarios where this value needs to be more precise, it can be supplied from your resolver.
const resolvers = {
Query: {
people: async (root, { first, after }, { app }) => {
const users = await app.db.getUsers(first + 1, after)
return {
edges: users,
pageInfo: {
hasNextPage: users.length > first,
}
}
},
},
}
When an object is returned by a resolver we will merge the pageInfo
object with the library-provided value. The edges
property will be transformed to match the Connection
schema and any additional properties will be added to the Connection
object.
Strategies for determining the hasNextPage
value are described in more detail in the library documentation
Try it out
GraphQL pagination can be a complex topic, especially for those new to the Graph data way of thinking. Using this directive allows you to implement powerful pagination functionality without making huge changes to your existing setup.
Hopefully from what you've seen here this library will be able to simplify your GraphQL setup. Full details of configuration options and examples of what can be done with this directive are available in the GitHub repository https://github.com/nearform/relay-pagination-directive