Created
April 29, 2019 14:09
-
-
Save gustavopch/118d6bc02ed4a5e5beea06731920146e to your computer and use it in GitHub Desktop.
MongoDB cursor pagination
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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