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 ofUserEdge
items - a modified
users
query that will return aUsersConnection
- 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!