UNPKG

@conpago/mongo-cursor-pagination

Version:

Make it easy to return cursor-paginated results from a Mongo collection

383 lines (330 loc) 11.1 kB
import objectPath from "object-path"; import { omit, pick, uniq } from "lodash"; import { AggregateParams, QueryParams, PaginationResponse, PaginatedField, QueryParamsMulti, AggregateParamsMulti, } from "../types"; import { encode } from "./bsonUrlEncoding"; function buildCursor( doc: { _id: any }, params: QueryParams | AggregateParams, shouldSecondarySortOnId: boolean ): string { const paginatedFieldValue = (() => { const { paginatedField, sortCaseInsensitive } = params; const value = objectPath.get(doc, paginatedField); return sortCaseInsensitive && value?.toLowerCase ? value.toLowerCase() : value; })(); const rawCursor = shouldSecondarySortOnId ? [paginatedFieldValue, doc._id] : paginatedFieldValue; // which may actually be the document_id anyways return encode(rawCursor); } function buildCursorMulti( doc: { _id: any }, params: QueryParamsMulti | AggregateParamsMulti, shouldSecondarySortOnId: boolean ): string { const { paginatedFields } = params; const paginatedFieldValues = paginatedFields.reduce((acc, curr) => { const value = objectPath.get(doc, curr.paginatedField); if (curr.sortCaseInsensitive && value?.toLowerCase) { acc[curr.paginatedField] = value.toLowerCase(); return acc; } acc[curr.paginatedField] = value; return acc; }, {}); const rawCursor = shouldSecondarySortOnId ? { ...paginatedFieldValues, _id: doc._id } : paginatedFieldValues; // which may actually be the document_id anyways return encode(rawCursor); } /** * Helper function to encode pagination tokens. * * NOTE: this function modifies the passed-in `response` argument directly. * @param {QueryParams | AggregateParams} params * @param {Object} response The response * @returns void */ export function encodePaginationTokens( params: QueryParams | AggregateParams, response: PaginationResponse, multi = false ): void { const shouldSecondarySortOnId = params.paginatedField !== "_id"; if (response.previous) { response.previous = multi ? buildCursorMulti(response.previous, params, shouldSecondarySortOnId) : buildCursor(response.previous, params, shouldSecondarySortOnId); } if (response.next) { response.next = multi ? buildCursorMulti(response.next, params, shouldSecondarySortOnId) : buildCursor(response.next, params, shouldSecondarySortOnId); } } /** * Parses the raw results from a find or aggregate query and generates a response object that * contain the various pagination properties * * @param {Object[]} results the results from a query * @param {QueryParams | AggregateParams} params The params originally passed to `find` or `aggregate` * * @return {Object} The object containing pagination properties */ export function prepareResponse( results: any[], params: QueryParams | AggregateParams, multi = false ): PaginationResponse { const hasMore = results.length > params.limit; const shouldSecondarySortOnId = params.paginatedField !== "_id"; // Remove the extra element that we added to 'peek' to see if there were more entries. if (hasMore) results.pop(); const hasPrevious = !!params.next || !!(params.previous && hasMore); const hasNext = !!params.previous || hasMore; results = results.map((result: any) => ({ ...result, _cursor: multi ? buildCursorMulti(result, params, shouldSecondarySortOnId) : buildCursor(result, params, shouldSecondarySortOnId), })); // If we sorted reverse to get the previous page, correct the sort order. if (params.previous) results = results.reverse(); const response = { results, previous: results[0], hasPrevious, next: results[results.length - 1], hasNext, }; encodePaginationTokens(params, response, multi); return response; } export type SearchArgs = PaginatedField & Pick<QueryParams, "previous">; /** * Generates a `$sort` object given the parameters * * @param {QueryParams | AggregateParams} params The params originally passed to `find` or `aggregate` * * @return {Object} a sort object */ export function generateSort(params: SearchArgs): Record<string, number> { const sortAsc = (!params.sortAscending && params.previous) || (params.sortAscending && !params.previous); const sortDir = sortAsc ? 1 : -1; if (params.paginatedField == "_id") return { _id: sortDir }; const field = params.sortCaseInsensitive ? `__lower_case_value_${params.paginatedField}` : params.paginatedField; return { [field]: sortDir }; } export function generateSorts(params: QueryParamsMulti) { const sortArgs = params.paginatedFields.map(pf => ({ ...pf, previous: params.previous, })); return Object.assign({}, ...sortArgs.map(f => generateSort(f))); } /** * Generates a cursor query that provides the offset capabilities * * @param {Object} params The params originally passed to `find` or `aggregate` * * @return {Object} a cursor offset query */ export function generateCursorQuery( params: QueryParams | AggregateParams ): object { if (!params.next && !params.previous) return {}; const sortAsc = (!params.sortAscending && params.previous) || (params.sortAscending && !params.previous); // a `next` cursor will have precedence over a `previous` cursor. const cursor = (params.next || params.previous) as any; if (params.paginatedField == "_id") return { _id: sortAsc ? { $gt: cursor } : { $lt: cursor } }; const field = params.sortCaseInsensitive ? "__lower_case_value" // lc value of the paginatedField (via $addFields + $toLower) : params.paginatedField; const notNullNorUndefined = { [field]: { $ne: null } }; const nullOrUndefined = { [field]: null }; const [paginatedFieldValue, idValue] = cursor; // mongo does not distinguish a sort order difference between null or undefined // for sorting purposes, and thus secondarily sorts by _id if (paginatedFieldValue === null || paginatedFieldValue === undefined) { return sortAsc ? { $or: [ notNullNorUndefined, // still have all the non-null, non-undefined values { ...nullOrUndefined, _id: { $gt: idValue } }, ], // & sort remaining using the _id as secondary field } : // if sorting descending value, then all other values must be null || undefined { $or: [{ ...nullOrUndefined, _id: { $lt: idValue } }] }; } // else if value is not null | undefined return sortAsc ? { $or: [ { [field]: { $gt: paginatedFieldValue } }, { [field]: { $eq: paginatedFieldValue }, _id: { $gt: idValue } }, ], } : { $or: [ nullOrUndefined, // in descending order, will still have null && undefined values remaining { [field]: { $lt: paginatedFieldValue } }, { [field]: { $eq: paginatedFieldValue }, _id: { $lt: idValue } }, ], }; } type BigOrRow = Record<string, Record<"$gt" | "$lt" | "$eq", any>>; export function generateCursorQueryMulti(params: QueryParamsMulti) { if (!params.next && !params.previous) return {}; const cursor = (params.next || params.previous) as any; const idValue = cursor._id; /** * ends up looking like this: * ``` * [ * { firstName: { $gt: "george" } }, * { firstName: { $eq: "george" }, { lastName: { $gt: "Costanza" } }, * { firstName: { $eq: "george" }, { lastName: { $eq: "Costanza" }, title: { $gt: "Mr" } } * ] * ``` */ const bigOr: BigOrRow[] = []; const onlyId = params.paginatedFields.length === 1 && params.paginatedFields[0].paginatedField === "_id"; if (onlyId) { return getOrNextLine({ prev: undefined, field: "_id", sortAsc: params.paginatedFields[0].sortAscending, value: idValue ?? cursor, }); } for (let i = 0; i < params.paginatedFields.length; i++) { const pf = params.paginatedFields[i]; const field = pf.sortCaseInsensitive ? `__lower_case_value_${pf.paginatedField}` // lc value of the paginatedField (via $addFields + $toLower) : pf.paginatedField; const sortAsc = ((!pf.sortAscending && params.previous) || (pf.sortAscending && !params.previous)) as boolean; const paginatedFieldValue = cursor[pf.paginatedField]; const prev = bigOr[bigOr.length - 1]; if (paginatedFieldValue) { const newFields = getOrNextLine({ prev, field, sortAsc, value: paginatedFieldValue, }); bigOr.push(newFields); } else { const notNullNorUndefined = { [field]: { $ne: null } }; const nullOrUndefined = { [field]: null }; const previousFields = (() => { if (!prev) { return {}; } return Object.assign( {}, ...Object.entries(prev).map(([k, v]) => convert$lt$gtFieldTo$eq({ [k]: v }) ) ); })(); if (sortAsc) { bigOr.push({ ...previousFields, ...notNullNorUndefined }); } else { bigOr.push({ ...previousFields, ...nullOrUndefined }); } } } return { $or: bigOr }; } function $gt$lt(asc: boolean) { return asc ? ("$gt" as const) : ("$lt" as const); } /** * Takes the previous entries and adds a new one where * all of the operators are $eq except the latest * * i.e. * ``` * //before * { firstName: { $gt: "george" } } * // after * { firstName: { $eq: "george" }, { lastName: {$gt: "Costanza" } } * ``` */ function getOrNextLine({ prev, field, sortAsc, value, }: { prev?: BigOrRow; field: string; sortAsc: boolean; value: string; }): BigOrRow { if (!prev) { return { [field]: { [$gt$lt(sortAsc)]: value } } as BigOrRow; } const previousFields = Object.assign( {}, ...Object.entries(prev).map(([k, v]) => convert$lt$gtFieldTo$eq({ [k]: v })) ); return { ...previousFields, [field]: { [$gt$lt(sortAsc)]: value } }; } /** * transforms * ``` * { firstName: { $gt: "george" } * ``` * to * ``` * { firstName: { $eq: "george" } * ``` */ function convert$lt$gtFieldTo$eq( field: Record<string, Record<"$lt" | "$gt", any>> ): Record<string, Record<"$eq", any>> { const [key, value] = Object.entries(field)[0]; const fieldValue = value ? Object.values(value)[0]: ''; return { [key]: { $eq: fieldValue } }; } /** * response results can have additional fields that were not requested by user (for example, the * fields required to sort and paginate). If projected fields are nominated, return only these. */ export function filterProjectedFields({ projectedFields, results, sortCaseInsensitive, }) { // if (sortCaseInsensitive) results = results.map(result => omit(result, "__lower_case_value")); const requestedFields = projectedFields ? Object.keys(projectedFields).filter( key => projectedFields[key] === 1 || projectedFields[key] === true ) : []; return requestedFields?.length ? results.map(result => pick(result, uniq([...requestedFields, "_cursor"]))) : results; // else if no projection, return full results }