nestjs-paginate/src/paginate.ts

291 lines
8.3 KiB
TypeScript
Raw Normal View History

2021-08-19 14:42:18 +00:00
import {
Repository,
FindConditions,
SelectQueryBuilder,
ObjectLiteral,
FindOperator,
Equal,
MoreThan,
MoreThanOrEqual,
In,
IsNull,
LessThan,
LessThanOrEqual,
Not,
ILike,
2021-10-11 07:46:31 +00:00
Brackets,
Between,
2021-08-19 14:42:18 +00:00
} from 'typeorm'
2020-06-26 21:25:03 +00:00
import { PaginateQuery } from './decorator'
import { ServiceUnavailableException } from '@nestjs/common'
2021-08-19 14:42:18 +00:00
import { values, mapKeys } from 'lodash'
import { stringify } from 'querystring'
2020-06-26 21:25:03 +00:00
type Column<T> = Extract<keyof T, string>
type Order<T> = [Column<T>, 'ASC' | 'DESC']
type SortBy<T> = Order<T>[]
export class Paginated<T> {
data: T[]
meta: {
itemsPerPage: number
totalItems: number
currentPage: number
totalPages: number
sortBy: SortBy<T>
2021-10-12 11:01:53 +00:00
searchBy: Column<T>[]
2020-06-28 17:34:00 +00:00
search: string
2021-08-19 14:42:18 +00:00
filter?: { [column: string]: string | string[] }
2020-06-26 21:25:03 +00:00
}
links: {
first?: string
previous?: string
current: string
next?: string
last?: string
}
}
export interface PaginateConfig<T> {
sortableColumns: Column<T>[]
2020-06-28 17:34:00 +00:00
searchableColumns?: Column<T>[]
2020-06-26 21:25:03 +00:00
maxLimit?: number
defaultSortBy?: SortBy<T>
defaultLimit?: number
where?: FindConditions<T> | FindConditions<T>[]
2021-08-19 14:42:18 +00:00
filterableColumns?: { [key in Column<T>]?: FilterOperator[] }
}
export enum FilterOperator {
EQ = '$eq',
GT = '$gt',
GTE = '$gte',
IN = '$in',
NULL = '$null',
LT = '$lt',
LTE = '$lte',
BTW = '$btw',
2021-08-19 14:42:18 +00:00
NOT = '$not',
2020-06-26 21:25:03 +00:00
}
export function isOperator(value: unknown): value is FilterOperator {
return values(FilterOperator).includes(value as any)
}
export function getOperatorFn<T>(op: FilterOperator): (...args: any[]) => FindOperator<T> {
switch (op) {
case FilterOperator.EQ:
return Equal
case FilterOperator.GT:
return MoreThan
case FilterOperator.GTE:
return MoreThanOrEqual
case FilterOperator.IN:
return In
case FilterOperator.NULL:
return IsNull
case FilterOperator.LT:
return LessThan
case FilterOperator.LTE:
return LessThanOrEqual
case FilterOperator.BTW:
return Between
case FilterOperator.NOT:
return Not
}
}
export function getFilterTokens(raw: string): string[] {
const tokens = []
const matches = raw.match(/(\$\w+):/g)
if (matches) {
const value = raw.replace(matches.join(''), '')
tokens.push(...matches.map((token) => token.substring(0, token.length - 1)), value)
} else {
tokens.push(raw)
}
if (tokens.length === 0 || tokens.length > 3) {
return []
} else if (tokens.length === 2) {
if (tokens[1] !== FilterOperator.NULL) {
tokens.unshift(null)
}
} else if (tokens.length === 1) {
if (tokens[0] === FilterOperator.NULL) {
tokens.unshift(null)
} else {
tokens.unshift(null, FilterOperator.EQ)
}
}
return tokens
}
function parseFilter<T>(query: PaginateQuery, config: PaginateConfig<T>) {
const filter = {}
for (const column of Object.keys(query.filter)) {
if (!(column in config.filterableColumns)) {
continue
}
const allowedOperators = config.filterableColumns[column as Column<T>]
const input = query.filter[column]
const statements = !Array.isArray(input) ? [input] : input
for (const raw of statements) {
const tokens = getFilterTokens(raw)
if (tokens.length === 0) {
continue
}
const [op2, op1, value] = tokens
if (!isOperator(op1) || !allowedOperators.includes(op1)) {
continue
}
if (isOperator(op2) && !allowedOperators.includes(op2)) {
continue
}
if (isOperator(op1)) {
const args = op1 === FilterOperator.IN ? value.split(',') : value
filter[column] =
op1 === FilterOperator.BTW ? getOperatorFn<T>(op1)(args[0], args[1]) : getOperatorFn<T>(op1)(args)
}
if (isOperator(op2)) {
filter[column] = getOperatorFn<T>(op2)(filter[column])
}
}
}
return filter
}
2020-06-26 21:25:03 +00:00
export async function paginate<T>(
query: PaginateQuery,
repo: Repository<T> | SelectQueryBuilder<T>,
config: PaginateConfig<T>
): Promise<Paginated<T>> {
let page = query.page || 1
const limit = Math.min(query.limit || config.defaultLimit || 20, config.maxLimit || 100)
2020-06-28 17:34:00 +00:00
const sortBy = [] as SortBy<T>
2021-10-12 11:01:53 +00:00
const searchBy: Column<T>[] = []
2020-06-26 21:25:03 +00:00
const path = query.path
2021-10-12 11:01:53 +00:00
function isEntityKey(entityColumns: Column<T>[], column: string): column is Column<T> {
return !!entityColumns.find((c) => c === column)
2020-06-26 21:25:03 +00:00
}
if (config.sortableColumns.length < 1) throw new ServiceUnavailableException()
if (query.sortBy) {
for (const order of query.sortBy) {
2021-10-12 11:22:25 +00:00
if (isEntityKey(config.sortableColumns, order[0]) && ['ASC', 'DESC'].includes(order[1])) {
2020-06-26 21:25:03 +00:00
sortBy.push(order as Order<T>)
}
}
}
2021-10-12 11:01:53 +00:00
2020-06-26 21:25:03 +00:00
if (!sortBy.length) {
2021-10-12 11:22:25 +00:00
sortBy.push(...(config.defaultSortBy || [[config.sortableColumns[0], 'ASC']]))
2020-06-26 21:25:03 +00:00
}
2021-10-12 11:22:25 +00:00
if (config.searchableColumns) {
if (query.searchBy) {
for (const column of query.searchBy) {
if (isEntityKey(config.searchableColumns, column)) {
searchBy.push(column)
}
2021-10-12 11:01:53 +00:00
}
2021-10-12 11:22:25 +00:00
} else {
searchBy.push(...config.searchableColumns)
2021-10-12 11:01:53 +00:00
}
}
2020-06-28 17:34:00 +00:00
if (page < 1) page = 1
2020-06-26 21:25:03 +00:00
let [items, totalItems]: [T[], number] = [[], 0]
2020-06-28 17:34:00 +00:00
let queryBuilder: SelectQueryBuilder<T>
2020-06-26 21:25:03 +00:00
if (repo instanceof Repository) {
2020-06-28 17:34:00 +00:00
queryBuilder = repo
2020-06-26 21:25:03 +00:00
.createQueryBuilder('e')
.take(limit)
.skip((page - 1) * limit)
for (const order of sortBy) {
2020-06-28 17:34:00 +00:00
queryBuilder.addOrderBy('e.' + order[0], order[1])
2020-06-26 21:25:03 +00:00
}
} else {
2020-06-28 17:34:00 +00:00
queryBuilder = repo.take(limit).skip((page - 1) * limit)
2020-06-26 21:25:03 +00:00
for (const order of sortBy) {
2020-06-28 17:34:00 +00:00
queryBuilder.addOrderBy(repo.alias + '.' + order[0], order[1])
2020-06-26 21:25:03 +00:00
}
2020-06-28 17:34:00 +00:00
}
2020-06-26 21:25:03 +00:00
2021-08-19 14:42:18 +00:00
if (config.where) {
2021-12-08 13:34:27 +00:00
queryBuilder.andWhere(new Brackets((qb) => qb.andWhere(config.where)))
2021-08-19 14:42:18 +00:00
}
2021-10-12 11:01:53 +00:00
if (query.search && searchBy.length) {
2021-08-19 14:42:18 +00:00
const search: ObjectLiteral[] = []
2021-10-12 11:01:53 +00:00
for (const column of searchBy) {
2021-08-19 14:42:18 +00:00
search.push({ [column]: ILike(`%${query.search}%`) })
2020-06-28 17:34:00 +00:00
}
2021-12-08 13:34:27 +00:00
queryBuilder.andWhere(new Brackets((qb) => qb.andWhere(search)))
2020-06-26 21:25:03 +00:00
}
2021-08-19 14:42:18 +00:00
if (query.filter) {
const filter = parseFilter<T>(query, config)
2021-12-08 13:34:27 +00:00
queryBuilder.andWhere(new Brackets((qb) => qb.andWhere(filter)))
2021-08-19 14:42:18 +00:00
}
;[items, totalItems] = await queryBuilder.getManyAndCount()
2020-06-28 17:34:00 +00:00
2020-06-26 21:25:03 +00:00
let totalPages = totalItems / limit
if (totalItems % limit) totalPages = Math.ceil(totalPages)
2021-08-19 14:42:18 +00:00
const sortByQuery = sortBy.map((order) => `&sortBy=${order.join(':')}`).join('')
const searchQuery = query.search ? `&search=${query.search}` : ''
2021-10-12 11:01:53 +00:00
const searchByQuery =
query.searchBy && searchBy.length ? searchBy.map((column) => `&searchBy=${column}`).join('') : ''
2021-10-12 11:01:53 +00:00
2021-08-19 14:42:18 +00:00
const filterQuery = query.filter
? '&' +
2021-10-11 07:46:31 +00:00
stringify(
mapKeys(query.filter, (_param, name) => 'filter.' + name),
'&',
'=',
{ encodeURIComponent: (str) => str }
)
2021-08-19 14:42:18 +00:00
: ''
2021-10-12 11:22:25 +00:00
const options = `&limit=${limit}${sortByQuery}${searchQuery}${searchByQuery}${filterQuery}`
2020-06-26 21:25:03 +00:00
const buildLink = (p: number): string => path + '?page=' + p + options
const results: Paginated<T> = {
data: items,
meta: {
itemsPerPage: limit,
totalItems,
currentPage: page,
totalPages: totalPages,
sortBy,
2021-08-19 14:42:18 +00:00
search: query.search,
searchBy: query.search ? searchBy : undefined,
2021-08-19 14:42:18 +00:00
filter: query.filter,
2020-06-26 21:25:03 +00:00
},
links: {
first: page == 1 ? undefined : buildLink(1),
previous: page - 1 < 1 ? undefined : buildLink(page - 1),
current: buildLink(page),
next: page + 1 > totalPages ? undefined : buildLink(page + 1),
last: page == totalPages ? undefined : buildLink(totalPages),
},
}
return Object.assign(new Paginated<T>(), results)
}