import { SelectQueryBuilder, ObjectType } from 'typeorm' import { CursorPagination, Cursor, OrderBy, CursorTransformer, Nullable, Take, PromiseCursorPagination } from './interfaces/paginator' import { Base64Transformer } from './transformers/base64-transformer' import { normalizeOrderBy } from './utils/normalizeOrderBy' export interface CursorPaginatorParams> { orderBy: OrderBy | OrderBy[] columnNames?: TColumnNames | null take?: Nullable | number | null transformer?: CursorTransformer | null } export interface CursorPaginatorPaginateParams { prevCursor?: string | null nextCursor?: string | null take?: number | null } export class CursorPaginator> { orders: [string, boolean][] = [] columnNames: Record takeOptions: Take transformer: CursorTransformer constructor( public entity: ObjectType, { orderBy, columnNames, take, transformer, }: CursorPaginatorParams, ) { this.orders = normalizeOrderBy(orderBy) this.columnNames = columnNames ?? {} this.takeOptions = typeof take === 'number' ? { default: take, min: 0, max: Infinity, } : { default: take?.default ?? 20, min: Math.max(0, take?.min ?? 0), // never negative max: take?.max ?? Infinity, } this.transformer = transformer ?? new Base64Transformer() } async paginate(qb: SelectQueryBuilder, params: CursorPaginatorPaginateParams = {}): Promise> { const take = Math.max(this.takeOptions.min, Math.min(params.take || this.takeOptions.default, this.takeOptions.max)) const qbForCount = qb.clone() if (params.prevCursor) { try { this._applyWhereQuery(qb, this.transformer.parse(params.prevCursor), false) } catch { qb.andWhere('1 = 0') } for (const [key, value] of this.orders) { qb.addOrderBy(this.columnNames[key] ?? `${qb.alias}.${key}`, value ? 'DESC' : 'ASC') } let hasPrev = false const nodes = await qb.clone().take(take + 1).getMany().then(nodes => { if (nodes.length > take) { hasPrev = true } return nodes.slice(0, take).reverse() }) return { count: await qbForCount.getCount(), nodes, hasPrev, hasNext: true, prevCursor: nodes.length > 0 ? this.transformer.stringify(this._createCursor(nodes[0])) : null, nextCursor: nodes.length > 0 ? this.transformer.stringify(this._createCursor(nodes[nodes.length - 1])) : null, } } if (params.nextCursor) { try { this._applyWhereQuery(qb, this.transformer.parse(params.nextCursor), true) } catch { qb.andWhere('1 = 0') } } for (const [key, value] of this.orders) { qb.addOrderBy(this.columnNames[key] ?? `${qb.alias}.${key}`, value ? 'ASC' : 'DESC') } let hasNext = false const nodes = await qb.clone().take(take + 1).getMany().then(nodes => { if (nodes.length > take) { hasNext = true } return nodes.slice(0, take) }) return { count: await qbForCount.getCount(), nodes: nodes.slice(0, take), hasPrev: !!params.nextCursor, hasNext, prevCursor: nodes.length > 0 ? this.transformer.stringify(this._createCursor(nodes[0])) : null, nextCursor: nodes.length > 0 ? this.transformer.stringify(this._createCursor(nodes[nodes.length - 1])) : null, } } promisePaginate(qb: SelectQueryBuilder, params: CursorPaginatorPaginateParams = {}): PromiseCursorPagination { const take = Math.max(this.takeOptions.min, Math.min(params.take || this.takeOptions.default, this.takeOptions.max)) const qbForCount = qb.clone() if (params.prevCursor) { try { this._applyWhereQuery(qb, this.transformer.parse(params.prevCursor), false) } catch { qb.andWhere('1 = 0') } for (const [key, value] of this.orders) { qb.addOrderBy(this.columnNames[key] ?? `${qb.alias}.${key}`, value ? 'DESC' : 'ASC') } let cachePromiseNodes = null as Promise, 'count'>> | null const promiseNodes = () => { if (!cachePromiseNodes) { cachePromiseNodes = qb.clone().take(take + 1).getMany().then(nodes => { let hasPrev = false if (nodes.length > take) { hasPrev = true } nodes = nodes.slice(0, take).reverse() return { nodes, hasPrev, hasNext: true, prevCursor: nodes.length > 0 ? this.transformer.stringify(this._createCursor(nodes[0])) : null, nextCursor: nodes.length > 0 ? this.transformer.stringify(this._createCursor(nodes[nodes.length - 1])) : null, } }) } return cachePromiseNodes } return { get count() { return qbForCount.getCount() }, get nodes() { return promiseNodes().then(({ nodes }) => nodes) }, get hasPrev() { return promiseNodes().then(({ hasPrev }) => hasPrev) }, get hasNext() { return promiseNodes().then(({ hasNext }) => hasNext) }, get prevCursor() { return promiseNodes().then(({ prevCursor }) => prevCursor) }, get nextCursor() { return promiseNodes().then(({ nextCursor }) => nextCursor) }, } } if (params.nextCursor) { try { this._applyWhereQuery(qb, this.transformer.parse(params.nextCursor), true) } catch { qb.andWhere('1 = 0') } } for (const [key, value] of this.orders) { qb.addOrderBy(this.columnNames[key] ?? `${qb.alias}.${key}`, value ? 'ASC' : 'DESC') } let cachePromiseNodes = null as Promise, 'count'>> | null const promiseNodes = () => { if (!cachePromiseNodes) { cachePromiseNodes = qb.clone().take(take + 1).getMany().then(nodes => { let hasNext = false if (nodes.length > take) { hasNext = true } nodes = nodes.slice(0, take) return { nodes: nodes.slice(0, take), hasPrev: !!params.nextCursor, hasNext, prevCursor: nodes.length > 0 ? this.transformer.stringify(this._createCursor(nodes[0])) : null, nextCursor: nodes.length > 0 ? this.transformer.stringify(this._createCursor(nodes[nodes.length - 1])) : null, } }) } return cachePromiseNodes } return { get count() { return qbForCount.getCount() }, get nodes() { return promiseNodes().then(({ nodes }) => nodes) }, get hasPrev() { return promiseNodes().then(({ hasPrev }) => hasPrev) }, get hasNext() { return promiseNodes().then(({ hasNext }) => hasNext) }, get prevCursor() { return promiseNodes().then(({ prevCursor }) => prevCursor) }, get nextCursor() { return promiseNodes().then(({ nextCursor }) => nextCursor) }, } } _applyWhereQuery(qb: SelectQueryBuilder, cursor: Cursor, isNext: boolean) { const metadata = qb.expressionMap.mainAlias!.metadata let queryPrefix = '' const queryParts = [] as string[] const queryParams = {} as Record for (const [key, asc] of this.orders) { const columnName = this.columnNames[key] ?? `${qb.alias}.${key}` queryParts.push(`(${queryPrefix}${columnName} ${!asc !== isNext ? '>' : '<'} :cursor__${key})`) queryPrefix = `${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) } _createCursor(node: TEntity): Cursor { const cursor = {} as Cursor for (const [key, _] of this.orders) { cursor[key as keyof TEntity] = node[key as keyof TEntity] } return cursor } }