Skip to content

Instantly share code, notes, and snippets.

@gustavopch
Created April 29, 2019 14:09
Show Gist options
  • Save gustavopch/118d6bc02ed4a5e5beea06731920146e to your computer and use it in GitHub Desktop.
Save gustavopch/118d6bc02ed4a5e5beea06731920146e to your computer and use it in GitHub Desktop.
MongoDB cursor pagination
import base64Url from 'base64-url'
import delve from 'dlv'
import { Collection, ObjectId } from 'mongodb'
// @ts-ignore
import * as EJSON from 'mongodb-extjson'
const DEFAULT_LIMIT = 25
type CursorObject = {
readonly id: ObjectId
readonly value: any
}
const encodeCursor = ({ id, value }: CursorObject): string => {
return base64Url.encode(EJSON.stringify({ id, value }))
}
const decodeCursor = (cursorString: string): { readonly id: ObjectId; readonly value: any } => {
return EJSON.parse(base64Url.decode(cursorString))
}
export type PaginatedQueryParams = {
readonly first?: number
readonly after?: string
readonly last?: number
readonly before?: string
readonly orderBy?: {
readonly field: string
readonly direction: 'asc' | 'desc'
}
readonly query?: { readonly [key: string]: any }
readonly projection?: { readonly [key: string]: any }
}
export type PaginatedQueryResult<TNode = any> = {
readonly totalCount: number
readonly nodes: TNode[]
readonly pageInfo: {
readonly startCursor: string | null
readonly endCursor: string | null
}
}
/**
* Runs a paginated query in a collection using the specified criteria.
*/
export const runPaginatedQuery = async (
collection: Collection,
{ first, after, last, before, orderBy = { field: '_id', direction: 'desc' }, query, projection }: PaginatedQueryParams
): Promise<PaginatedQueryResult> => {
// Compute actual direction considering that using `last` must reverse
// the direction so that we can use limit() properly.
const actualDirection = (orderBy.direction === 'asc' && !last) || (orderBy.direction === 'desc' && last) ? 'asc' : 'desc'
const sort = actualDirection === 'asc' ? { [orderBy.field]: 1, _id: 1 } : { [orderBy.field]: -1, _id: -1 }
// Compute cursor query
let cursorQuery: any
if (after && !last) {
// Because `last` doesn't make sense if you're using `after`
const decodedCursor = decodeCursor(after)
cursorQuery = createCursorQuery(decodedCursor, orderBy.field, actualDirection)
} else if (before && !first) {
// Because `first` doesn't make sense if you're using `before`
const decodedCursor = decodeCursor(before)
cursorQuery = createCursorQuery(decodedCursor, orderBy.field, actualDirection)
} else {
cursorQuery = {}
}
// If `before` is passed, only `last` makes sense;
// If `after` is passed, only `first` makes sense;
// If neither are passed, prioritize `first` over `last`.
const limit = before ? last : after ? first : first || last
const [totalCount, documents] = await Promise.all([
collection.countDocuments(query),
collection
.find({ $and: [cursorQuery, query] }, projection)
.sort(sort)
.limit(limit || DEFAULT_LIMIT)
.toArray()
// When `actualDirection` and `orderBy.direction` are different,
// we must reverse the result so that it's ordered as requested
// by `orderBy.direction`.
.then(documents => (actualDirection !== orderBy.direction ? documents.reverse() : documents))
])
const firstDocument = documents[0]
const lastDocument = documents[documents.length - 1]
return {
totalCount,
nodes: documents,
pageInfo: {
startCursor: firstDocument ? encodeCursor({ id: firstDocument._id, value: delve(firstDocument, orderBy.field) }) : null,
endCursor: lastDocument ? encodeCursor({ id: lastDocument._id, value: delve(lastDocument, orderBy.field) }) : null
}
}
}
const createCursorQuery = ({ id, value }: CursorObject, paginatedField: string, actualDirection: 'asc' | 'desc') => {
const operator = actualDirection === 'asc' ? '$gt' : '$lt'
return {
$or: [
{
[paginatedField]: { [operator]: value }
},
{
[paginatedField]: { $eq: value },
_id: { [operator]: id }
}
]
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment