UNPKG

typeorm-cursor-paginate

Version:

Cursor-based pagination with directional cursors.

280 lines (246 loc) 8.46 kB
import { SelectQueryBuilder, ObjectType, ObjectLiteral } from "typeorm"; import { Paginated, Cursor, OrderBy, CursorTransformer, DirectionalCursor, } from "./interfaces/paginator"; import { Base64Transformer } from "./transformers/base64-transformer"; import { normalizeOrderBy } from "./utils/normalizeOrderBy"; export interface CursorPaginatorParams<TEntity extends ObjectLiteral> { /** * Columns to order by. * * CAUTION: the set of provided columns must be unique in database, * otherwise entries might be skipped/duplicated. This rule is not enforced * by the library, so be careful. */ orderBy: OrderBy<TEntity> | OrderBy<TEntity>[]; /** * Transformer to use for the cursor stringification and parsing. * * By default, a `Base64Transformer` is used. * * There is also a predefined `JsonTransformer` you can use. */ transformer?: CursorTransformer<TEntity> | null; } export interface CursorPaginatorPaginateParams { /** * The cursor to the next or previous page. * * Use `prevPageCursor` or `nextPageCursor` which you got from * the previous page. */ pageCursor?: string | null; /** * The maximum number of items to return in the current page. * * If not provided, will return all remaining items. */ limit?: number; /** * If true, the total count will not be calculated. * * This can be useful for improving performance. * The default value is false (the total count is calculated). */ noTotalCount?: boolean; } /** * A class that implements a cursor-based pagination for TypeORM. * * @template TEntity The type of the entity that is being selected from the database. */ export class CursorPaginator<TEntity extends ObjectLiteral> { private _orders: [string, boolean][] = []; private _transformer: CursorTransformer<TEntity>; /** * Creates a new instance of the `CursorPaginator` class. * * @param entity The entity type to paginate. * @param options The options for the paginator. */ constructor( public readonly entity: ObjectType<TEntity>, options: CursorPaginatorParams<TEntity>, ) { const { orderBy, transformer } = options; this._orders = normalizeOrderBy(orderBy); if (this._orders.length === 0) { throw new Error("OrderBy must not be empty"); } this._transformer = transformer ?? new Base64Transformer(); } /** * Paginate the results of a query builder using the directional cursor. * * @param qb The query builder to paginate. * @param params The pagination parameters. * @param isRaw If true, the raw results will be returned. */ async paginate( qb: SelectQueryBuilder<TEntity>, params: CursorPaginatorPaginateParams = {}, isRaw = false, ): Promise<Paginated<TEntity>> { const take = params.limit; // limit must not be 0 or negative if (take !== undefined && take < 1) { throw new Error("Limit must be greater than 0 or undefined"); } // a copy of the query builder without "limit", "where" and "order by" // will be used to get the total count const qbForCount = new SelectQueryBuilder(qb); // compute helper values const directionIsProvided = !!params.pageCursor; const cursorTemp: string | null = params.pageCursor || null; const directionalCursor = cursorTemp ? this._parseCursor(cursorTemp) : null; // directionIsNext is true if nextPageCursor is provided or if no cursor is provided const directionIsNext = directionalCursor ? directionalCursor.direction === "next" : true; if (directionalCursor) { this._applyWhereQuery(qb, directionalCursor.cursor, directionIsNext); } for (const [key, asc] of this._orders) { qb.addOrderBy( `${qb.alias}.${key}`, asc === directionIsNext ? "ASC" : "DESC", ); } const query = new SelectQueryBuilder<TEntity>(qb).take(take && take + 1); const [nodes, totalCount] = await Promise.all([ isRaw ? query.getRawMany<TEntity>() : query.getMany(), !params.noTotalCount ? qbForCount.getCount() : Promise.resolve(null), ]); let hasPageInThePrimaryDirection = false; if (!take) { // no pagination } else if (nodes.length === take + 1) { // the next page exists hasPageInThePrimaryDirection = true; nodes.pop(); } else if (nodes.length < take + 1) { // the next page doesn't exist } else { throw new Error( `Got unexpected number of nodes from executing query: ${nodes.length} . ` + `Expected from ${0} to ${take ? take + 1 : nodes.length}`, ); } if (!directionIsNext) { nodes.reverse(); } return { totalCount, nodes, hasPrevPage: directionIsNext ? // if a cursor was provided, assume that there is a page in the direction we came from directionIsProvided : hasPageInThePrimaryDirection, hasNextPage: directionIsNext ? hasPageInThePrimaryDirection : directionIsProvided, prevPageCursor: nodes.length > 0 ? this._stringifyCursor(this._createCursor(nodes[0]), false) : null, nextPageCursor: nodes.length > 0 ? this._stringifyCursor( this._createCursor(nodes[nodes.length - 1]), true, ) : null, }; } private _applyWhereQuery( qb: SelectQueryBuilder<TEntity>, cursor: Cursor<TEntity>, isNext: boolean, ) { const metadata = qb.expressionMap.mainAlias?.metadata; if (!metadata) { throw new Error("Metadata is not defined"); } // TODO: would be nice to rewrite this function // so that instead of building a string, it builds // a deep object of "where" clauses. // Potentially, this could be more secure. let queryPrefix = ""; const queryParts = [] as string[]; const queryParams = {} as Record<string, any>; for (const [key, asc] of this._orders) { const columnName = `${qb.alias}.${key}`; queryParts.push( `(${queryPrefix}${columnName} ${asc === isNext ? ">" : "<"} :cursor__${key})`, ); queryPrefix += `${columnName} = :cursor__${key} AND `; const column = metadata.findColumnWithPropertyPath(key); queryParams[`cursor__${key}`] = column ? qb.connection.driver.preparePersistentValue( cursor[key as keyof TEntity], column, ) : cursor[key as keyof TEntity]; } qb.andWhere(`(${queryParts.join(" OR ")})`, queryParams); } private _createCursor(node: TEntity): Cursor<TEntity> { const cursor = {} as Cursor<TEntity>; for (const [key, _] of this._orders) { cursor[key as keyof TEntity] = node[key as keyof TEntity]; } return cursor; } private _stringifyCursor( cursor: Cursor<TEntity>, isForNextPage: boolean, ): string { return ( (isForNextPage ? "next:" : "prev:") + this._transformer.stringify(cursor) ); } private _parseCursor(cursorString: string): DirectionalCursor<TEntity> { let rawCursor = ""; let isNext = false; if (cursorString.startsWith("next:")) { rawCursor = cursorString.slice(5); isNext = true; } else if (cursorString.startsWith("prev:")) { rawCursor = cursorString.slice(5); } else { throw new Error('Cursor string must start with "next:" or "prev:"'); } const cursor = this._transformer.parse(rawCursor); this._validateCursor(cursor); return { cursor, direction: isNext ? "next" : "prev", }; } /** * Checks that all properties in the cursor are in the orderBy and * that all properties in the orderBy are in the cursor. * Also checks that no property in the cursor is undefined. */ private _validateCursor(cursor: Cursor<TEntity>) { const cursorKeys = Object.keys(cursor) as Array<keyof TEntity>; const orderByKeys = this._orders.map(([key, _]) => key) as Array< keyof TEntity >; if (cursorKeys.length !== orderByKeys.length) { throw new Error(`Cursor must have ${orderByKeys.length} properties`); } for (const key of cursorKeys) { if (!orderByKeys.includes(key)) { throw new Error(`Cursor has extra property ${String(key)}`); } if (cursor[key] === undefined) { throw new Error(`Cursor property ${String(key)} is undefined`); } } } }