Avoid Code Duplication with GraphQL Cursor based Pagination

Viewed 264

I've been looking all over for an answer to this and I've been banging my head on the wall. I wrote a cursor based pagination example that works well with graphql and the thing is I thought I would do the same thing with authors, that I did with books and the only way I can figure out how to do this is to completely duplicate everything. On the root query there is quite a long chunk of code handling the pagination and I would hate to do that all over for the authors endpoint but I can't seem to find a way to do this while reusing the code

Here is the code

const express = require('express')
const { graphqlHTTP } = require('express-graphql')
const {
    GraphQLSchema,
    GraphQLObjectType,
    GraphQLString,
    GraphQLList,
    GraphQLInt,
    GraphQLNonNull
} = require('graphql')

const {
    PageType,
    convertNodeToCursor,
    convertCursorToNodeId
} = require('./pagination')

const app = express()

const authors = [
    { id: 1, name: "Author 1"},
    { id: 2, name: "Author 2"},
    { id: 3, name: "Author 3"}
]

const books = [
    { id: 1, title: "Book 1", authorId: 1 },
    { id: 2, title: "Book 2", authorId: 1 },
    { id: 3, title: "Book 3", authorId: 1 },
    { id: 4, title: "Book 4", authorId: 2 },
    { id: 5, title: "Book 5", authorId: 2 },
    { id: 6, title: "Book 6", authorId: 2 },
    { id: 7, title: "Book 7", authorId: 3 },
    { id: 8, title: "Book 8", authorId: 3 },
    { id: 9, title: "Book 9", authorId: 3 }
]




const Book = new GraphQLObjectType({
    name: 'Book',
    description: 'this is a book',
    fields: () => ({
        id: { type: GraphQLNonNull(GraphQLInt) },
        title: { type: GraphQLNonNull(GraphQLString) },
        authorId: { type: GraphQLNonNull(GraphQLInt) },
        author: {
            type: Author,
            resolve: ({authorId}) => {
                return authors.find(author => author.id === authorId)
            }
        }
    })
})

const Author = new GraphQLObjectType({
    name: 'Author',
    description: 'this represents the author of a book',
    fields: () => ({
        id: { type: GraphQLNonNull(GraphQLInt) },
        name: { type: GraphQLNonNull(GraphQLString) },
        books: { 
            type: GraphQLList(Book),
            resolve: ({id}) => {
                return books.filter(book => book.authorId === id)
            }
        }
    })
})



const RootQuery = new GraphQLObjectType({
    name: 'RootQueryType',
    description: 'this is the root query',
    fields: () => ({
        book: {
            type: Book,
            description: 'a single book',
            args: {
                id: { type: GraphQLInt }
            },
            resolve: (_, { id }) => {
                return books.find(book => book.id === id)
            }
        },
        author: {
            type: Author,
            description: 'a single author',
            args: {
                id: { type: GraphQLInt },
            },
            resolve: (_, { id }) => {
                return authors.find(author => author.id === id)
            }
        },
        books: {
            type: PageType(Book),
            description: 'a list of books',
            args: {
                first: { type: GraphQLInt },
                afterCursor: { type: GraphQLString }
            },
            resolve: (_, { first, afterCursor }) => {
                let afterIndex = 0

                if (typeof afterCursor === 'string') {
                    let nodeId = convertCursorToNodeId(afterCursor)
                    let nodeIndex = books.findIndex(book => book.id === nodeId)
                    if (nodeIndex >= 0) {
                        afterIndex = nodeIndex + 1 
                    }
                }
                    
                const slicedData = books.slice(afterIndex, afterIndex + first)
                console.log('sliced data: ', slicedData)
                const edges = slicedData.map(node => ({
                    node,
                    cursor: convertNodeToCursor(node)
                }))

                let startCursor = null
                let endCursor = null
                if (edges.length > 0) {
                    startCursor = convertNodeToCursor(edges[0].node)
                    endCursor = convertNodeToCursor(edges[edges.length - 1].node)
                }

                let hasNextPage = books.length > afterIndex + first

                return {
                    totalCount: books.length,
                    edges,
                    pageInfo: {
                        startCursor,
                        endCursor,
                        hasNextPage
                    }
                }
            }

        }
    })
})

const schema = new GraphQLSchema({
    query: RootQuery
})

app.use('/graphql', graphqlHTTP({
    schema,
    graphiql: true
}))

app.listen(3000, () => console.log('app running at http://localhost:3000/graphql'))

and I handle the pagination in another file here:

const {
    GraphQLString,
    GraphQLInt,
    GraphQLBoolean,
    GraphQLObjectType,
    GraphQLList,
} = require('graphql')


const Edge = (itemType) => {
    return new GraphQLObjectType({
        name: 'EdgeType',
        fields: () => ({
            node: { type: itemType },
            cursor: { type: GraphQLString }
        })
    })
}

const PageInfo = new GraphQLObjectType({
    name: 'PageInfoType',
    fields: () => ({
        startCursor: { type: GraphQLString },
        endCursor: { type: GraphQLString },
        hasNextPage: { type: GraphQLBoolean }
    })
})

const PageType = (itemType) => {
    return new GraphQLObjectType({
        name: 'PageType',
        fields: () => ({
            totalCount: { type: GraphQLInt },
            edges: { type: new GraphQLList(Edge(itemType)) },
            pageInfo: { type: PageInfo }
        })
    })
}



const convertNodeToCursor = (node) => {
    // Encoding the cursor value to Base 64 as suggested in GraphQL documentation
    return Buffer.from((node.id).toString()).toString('base64')
}

const convertCursorToNodeId = (cursor) => {
    // Decoding the cursor value from Base 64 to integer
    return parseInt(Buffer.from(cursor, 'base64').toString('ascii'))
}

module.exports = {
    PageType,
    convertNodeToCursor,
    convertCursorToNodeId
}

Now if I copy and paste the books endpoint and change it to authors, and change the type to PageType(Author) then I get another error:

Schema must contain uniquely named types but contains multiple types named "PageType".

So this clearly isn't a solution either

1 Answers

You cannot have one EdgeType that contains Authors and another EdgeType that contains Books. Instead, you will need one AuthorEdge and one BookEdge type.

The same holds for the PageType - there can't be two different types with different fields but the same name.

The solution is relatively simple though - if you dynamically generated these types in a function, also name them dynamically:

const Edge = (itemType) => {
    return new GraphQLObjectType({
        name: itemType.name + 'Edge',
//            ^^^^^^^^^^^^^^^^^^^^^^
        fields: () => ({
            node: { type: itemType },
            cursor: { type: GraphQLString }
        })
    })
}

const PageInfo = new GraphQLObjectType({
    name: 'PageInfo',
    fields: () => ({
        startCursor: { type: GraphQLString },
        endCursor: { type: GraphQLString },
        hasNextPage: { type: GraphQLBoolean }
    })
})

const PageType = (itemType) => {
    return new GraphQLObjectType({
        name: itemType.name + 'sPage',
//            ^^^^^^^^^^^^^^^^^^^^^^^
        fields: () => ({
            totalCount: { type: GraphQLInt },
            edges: { type: new GraphQLList(Edge(itemType)) },
            pageInfo: { type: PageInfo }
        })
    })
}
Related