An NPM package to simplify GraphQL relay-style pagination

An NPM package to simplify GraphQL relay-style pagination

·

7 min read

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 and Edge types for each type we wish to paginate.

  • Add first and after 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 the after 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 the Edge 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 and PeopleConnection for Query.people

  • PeopleFilmEdge and PeopleFilmConnection for People.films

  • CinemaEdge and CinemaConnection for Query.cinemas

  • CinemaFilmEdge and CinemaFilmConnection for Cinema.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