Intro

In part 1 of this series, I described how to create a prisma model for a user and then a type definition for apollo server to access that user on the database. I also showed how to create a query that would return an array of user objects. Those looked like this

# schema.prisma
model User {
  id Int @default(autoincrement()) @id
  uuid String @unique
  name String? @default("")
  userName String? @unique
  email String @unique
  password String
  @@map(name: "users")
}
import { gql } from 'apollo-server-micro'

const typeDefs = gql`

  type Query {
    users: [User]!
    me: User
  }

  type Mutation {
    # mutations
  }

  type User {
    id: Int!
    uuid: String!
    name: String
    userName: String
    email: String!
    password: String!
  }
`

export default typeDefs

This is a good start, but it needs some improvement. This returns from the users query an array of objects like this:

{
  "data": {
    "users": [
      {
        // user 1
      },
      ...
      {
        // user N
      }
    ]
  }
}

If you just need a list of users, this works well enough I guess. But, it's hard to do paginated lists in any meaningful way. So, I want to get into cursor based pagination.

GraphQL and Connections

In the Apollo Blog post "Explaining GraphQL Connections", Caleb Meredith does a great job explaining the way that pagination impacted the shape of GraphQL responses. As he says

"When your core product revolves around a list, like Facebook, it is imperative that you get pagination right."

The GraphQL relay specification goes further. It states that not only do you need edges and nodes, you also need to give each node an "opaque" cursor (more on this is a bit). Further, you need to provide "page info". The this object will tell us four things:

  • if there is a next page in the list
  • if there is a previous page in the list
  • the start cursor for the page
  • the end cursor for the page

In the users query, we'll implement the relay specification pagination arguments. These tell us:

  • how many items to take
  • if we're going forward, the cursor to take after
  • if we're going backward, the cursor to take before

So let's implement edges and nodes for the users in the martial arts app. That model will help when we create other types. We want six things.

  • a User (obviously)
  • a UserEdge that will have a single node property
  • a UsersConnection that will give us a list of UserEdge items
  • a modified users query that will return a UsersConnection
  • a PageInfo type
  • arguments to the users query to implement pagination
// typeDefs.js

import { gql } from 'apollo-server-micro'

const typeDefs = gql`

  type Query {
    users(
      # first and after for forward pagination
      first: Int
      after: String
      # last and before for backward pagination
      last: Int
      before: String
    ): UsersConnection!
    # other queries
  }

  type Mutation {
    # mutations
  }

 # implement edges and nodes for users
  type UsersConnection {
    edges: [UserEdge]!
    total: Int!
    pageInfo: PageInfo!
  }

  type UserEdge {
    node: User!
  }

  type User {
    id: Int!
    uuid: String!
    name: String
    userName: String
    email: String!
    password: String!
  }

  type PageInfo {
    hasPreviousPage: Boolean!
    hasNextPage: Boolean!
    startCursor: String!
    endCursor: String!
  }
`

export default typeDefs

Now, when the users query is performed, it will return an object like this:

{
  "data": {
    "users": {
      "total": N,
      pageInfo: {
        hasPreviousPage: true/false
        hasNextPage: true/false
        startCursor: "someCursor"
        endCursor: "someCursor"
      }
      "edges": [
        {
          "node": {
             // user 1
          },
          ...
          "node": {
            // user N
          }
        }
      ]
    }
  }
}

Now that we've got the types and query arguments set up, it's time to create the pagination.

Cursor Based Pagination and Prisma

Understanding prisma's pagination

Prisma allows for cursor based pagination when using its findMany method. Using that method, you can take a number of items. You can include a where clause in order to refine the selection, but it's not required. So let's say I have 100 users, and I want to get the first 10. That would look like this:

const users = await prisma.user.findMany({
  take: 10
})

Great! Now, let's say we want the next 10 after that. What we need to do is use a decoded cursor and combine it with a skip property to take the next group:

const nextTenUsers = await prisma.user.findMany({
  take: 10,
  skip: 1,
  cursor: {
    id: someDecodedCursor
  }
})

The skip proprety here prevents prisma from starting with the last cursor of the previous set. If we want to paginate backwards, we can use a negative value for take instead of a positive value.

This is the basic way prisma handles cursor based pagination. But if we compare this to the relay style pagination above, they don't match up exactly. The relay pagination uses things like first and last rather than take for instance. So let's see how we can build out something to combine these.

Cursors and Direction

In all fairness, I borrowed my approach heavily from the prisma-relay-cursor-connection library. But I kind of wanted to understand this on my own. So I used it as a guide.

At /lib/pagination.js I set up a file that would help me with the methods I wanted to use to do things like:

  • encode and decode cursors
  • check for pagination direction
  • and perform the actual pagination

I'll start with the cursors and pagination direction. Cursors are unique, "opaque" identifiers. In this case, "opaque" means that you can't just look at a cursor and determine the particular ID of an item. So I used base64 encoded strings that have two parts: a "type" and an ID. For instance user:10. When that value is encoded, it creates a unique cursor that's different from either the database ID itself or the uuid for a record. Since the context for the cursor functions is the server, it's appropriate to use node's Buffer class rather than atob or btoa. Next, we can determine the direction of the pagination by examining the arguments passed from the graphql resolver. By checking for first or last we can determine which way to paginate, either forward or back.

// /lib/pagination.js

export const cursorGenerator = (type = '', id = 0) => {
  return Buffer.from(`${type}:${id}`).toString('base64')
}

export const cursorDecoder = (cursor = '') => {
  const decoded = Buffer.from(cursor, 'base64').toString()
  const parts = decoded.split(':')
  return parseInt(parts[1])
}

export const isForwardPagination = args => {
  return args && args.first !== undefined ? true : false
}

export const isBackwardPagination = args => {
  return args && args.last !== undefined ? true : false
}

Pagination

Now comes the tricky part. By the end of the paginateWithCursors function, we'll need the basic nodes that can be connected to form edges as well as the page info required by the relay spec. So I'll start by creating the shell of a function

// /lib/pagination.js

export const paginateWithCursors = async (config = {
  prisma,
  args,
  type: 'users',
}) => {

  const { prisma, args, type } = config
  let nodes = []
  let hasNextPage = false
  let hasPreviousPage = false

  if (isForwardPagination(args)) {

  } else if (isBackwardPagination(args)) {

  } else {

  }

  const pageInfo = {
    hasNextPage,
    hasPreviousPage,
    startCursor: '',
    endCursor: ''
  }

  return {
    nodes,
    pageInfo
  }
}

Now I want to zoom into the the isForwardPagination block. What we need to do is limit the number of records that a user can take at a time. Suppose there's a database with massive numbers of records and somebody requests 50,000. So I'll set an arbitrary maximum of 100. This will be the most the first value can be. But really, we need to take 1 more than that. Why? Because if there's one more than that number, we know the user can paginate to the next page. Next, we need to figure out if there's a cursor in the after or before argument. If there is, that means we need to skip 1. Not only that, but it needs to be applied to the prisma function. At that point, we can set the nodes according to the result of the query.

Let's look at that much.

// /lib/pagination.js

export const paginateWithCursors = async (config = {
  prisma,
  args,
  type: 'users',
}) => {

  const { prisma, args, type } = config
  let nodes = []
  let hasNextPage = false
  let hasPreviousPage = false

  if (isForwardPagination(args)) {
    // the most we'll allow to first to be is 100
    const first = args.first > 100 ? 100 : args.first

    // take 1 extra node
    const take = Math.abs(first + 1)

    // if there's a cursor, decode it
    const cursorID = args.after ? cursorDecoder(args.after) : null

    // if there's a cursor, we need to apply a skip
    const skip = cursorID ? 1 : 0

    // set up args for findMany
    const prismaArgs = {
      take,
      skip,
    }

    // if there's a decoded cursor ID, apply that to the args
    if (cursorID) {
      prismaArgs.cursor = {
        id: cursorID
      }
    }
    // assign nodes to the result
    nodes = await prisma[type].findMany(prismaArgs)

  } else if (isBackwardPagination(args)) {

  } else {

  }

  const pageInfo = {
    hasNextPage,
    hasPreviousPage,
    startCursor: '',
    endCursor: ''
  }

  return {
    nodes,
    pageInfo
  }
}

Next, we can figure out if there are next and previous pages. The previous page will be determined by the presence of the after argument. If we had a cursor there, we can assume that there was a page that it came from. Last, if the number of nodes is greater than the value of first, we can say that there is a next page. Finally, in order to return the correct number of results, if there was a next page, we pop off the last item in the nodes array.

// /lib/pagination.js

export const paginateWithCursors = async (config = {
  prisma,
  args,
  type: 'users',
}) => {

  const { prisma, args, type } = config
  let nodes = []
  let hasNextPage = false
  let hasPreviousPage = false

  if (isForwardPagination(args)) {
    // the most we'll allow to first to be is 100
    const first = args.first > 100 ? 100 : args.first

    // take 1 extra node
    const take = Math.abs(first + 1)

    // if there's a cursor, decode it
    const cursorID = args.after ? cursorDecoder(args.after) : null

    // if there's a cursor, we need to apply a skip
    const skip = cursorID ? 1 : 0

    // set up args for findMany
    const prismaArgs = {
      take,
      skip,
    }

    // if there's a decoded cursor ID, apply that to the args
    if (cursorID) {
      prismaArgs.cursor = {
        id: cursorID
      }
    }
    // assign nodes to the result
    nodes = await prisma[type].findMany(prismaArgs)

    // if there was an args.after, then there was a previous page
    hasPreviousPage = !!args.after

    // if the total number of nodes is more than the clamped value of "first" there is a next page
    hasNextPage = nodes.length > first

    // before returning, discard the last entry from the nodes
    if (hasNextPage) nodes.pop()
  } else if (isBackwardPagination(args)) {

  } else {

  }

  const pageInfo = {
    hasNextPage,
    hasPreviousPage,
    startCursor: '',
    endCursor: ''
  }

  return {
    nodes,
    pageInfo
  }
}

To go backwards, we essentially do the opposite. If you want to see that, you can look at the function in the repo.

Assigning the Page Info

At this point, we've got the nodes taken care of and part of the page info. But we still need the start and end cursors. Those can be created from the IDs of the first and last nodes.

// /lib/pagination.js

export const paginateWithCursors = async (config = {
  prisma,
  args,
  type: 'users',
}) => {

  const { prisma, args, type } = config
  let nodes = []
  let hasNextPage = false
  let hasPreviousPage = false

  if (isForwardPagination(args)) {
    // forward
  } else if (isBackwardPagination(args)) {
    // backward
  } else {
    // no pagination
  }

  const lastOfType = nodes[nodes.length - 1]

  const pageInfo = {
    hasPreviousPage,
    hasNextPage,
    startCursor: cursorGenerator(type, nodes[0]['id']),
    endCursor: cursorGenerator(type, lastOfType.id)
  }

  return {
    nodes,
    pageInfo
  }
}

Using Pagination in a Query

Now, we can take this function and bring it into the query. Since pagination is something that (presumably) needs to be done often, abstracting the functionality helps keep the code DRY.

const resolvers = {
  Query: {
    users: async (_, args, { prisma }) => {

      // handle the paginated request
      const { nodes, pageInfo } = await paginateWithCursors({
        prisma,
        args,
        type: 'user',
      })

      // create edges
      const edges = nodes.map(user => {
        return {
          cursor: cursorGenerator('user', user.id),
          node: user
        }
      })

      // return a well formatted response
      return {
        edges,
        pageInfo,
        total: edges.length
      }
    }
  }
}

Conclusion

This is a much better way of handling the data than what we had in parts 1 and 2. Learning about relay style pagination was certainly a challenge, but one that I think was well worth it. I hope you enjoyed this post!