mongo-cursor-pagination
Version:
Make it easy to return cursor-paginated results from a Mongo collection
105 lines (92 loc) • 2.95 kB
text/typescript
import { Collection, Document } from 'mongodb';
import _ from 'underscore';
import config from './config';
import bsonUrlEncoding, { Encodable } from './utils/bsonUrlEncoding';
export interface SearchParams {
query?: Record<string, any>;
limit?: number;
fields?: Record<string, number>;
next?: Encodable;
}
export interface SearchResponse<T = Document> {
results: T[];
next?: string;
previous?: string;
}
/**
* Performs a search query on a Mongo collection and pages the results. This is different from
* find() in that the results are ordered by their relevancy, and as such, it does not take
* a paginatedField parameter. Note that this is less performant than find() because it must
* perform the full search on each call to this function.
* @param collection - A MongoDB collection object. This MUST have a Mongo $text index.
* @param searchString - The string to search for.
* @param params - Search parameters:
* - `query`: Additional query filters.
* - `limit`: Number of results per page (1 to `config.MAX_LIMIT`).
* - `fields`: Fields to include in the results.
* - `next`: Cursor to continue pagination.
* @returns A paginated response containing results and a cursor for the next page.
*/
export default async function search<T = Document>(
collection: Collection<T>,
searchString: string,
params: SearchParams
): Promise<SearchResponse<T>> {
if (_.isString(params.limit)) params.limit = parseInt((params.limit as any) as string, 10);
if (params.next) {
params.next = bsonUrlEncoding.decode(params.next as string);
}
params = _.defaults(params, {
query: {},
limit: config.MAX_LIMIT,
});
if (params.limit! < 1) params.limit = 1;
if (params.limit! > config.MAX_LIMIT) params.limit = config.MAX_LIMIT;
const aggregatePipeline: Document[] = [
{
$match: {
...params.query,
$text: { $search: searchString },
},
},
{
$project: {
...params.fields,
_id: 1,
score: { $meta: 'textScore' },
},
},
{
$sort: {
score: { $meta: 'textScore' },
_id: -1,
},
},
];
if (params.next) {
aggregatePipeline.push({
$match: {
$or: [
{ score: { $lt: params.next[0] } },
{ score: { $eq: params.next[0] }, _id: { $lt: params.next[1] } },
],
},
});
}
aggregatePipeline.push({
$limit: params.limit!,
});
const aggregateMethod = 'aggregateAsCursor' in collection ? 'aggregateAsCursor' : 'aggregate';
const results = await collection[aggregateMethod](aggregatePipeline).toArray();
const fullPageOfResults = results.length === params.limit;
const response: SearchResponse<T> = {
results,
};
if (fullPageOfResults) {
const lastResult = _.last(results);
if (lastResult) {
response.next = bsonUrlEncoding.encode([lastResult.score, lastResult._id]);
}
}
return response;
}