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,
|
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
|
2021-09-28 11:15:24 +00:00
|
|
|
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',
|
|
|
|
NOT = '$not',
|
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
|
2021-08-19 11:30:06 +00:00
|
|
|
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-10-11 07:46:31 +00:00
|
|
|
queryBuilder = queryBuilder.andWhere(new Brackets((queryBuilder) => queryBuilder.andWhere(config.where))) // Postgres fix (https://github.com/ppetzold/nestjs-paginate/pull/97)
|
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-08-19 17:48:50 +00:00
|
|
|
queryBuilder = queryBuilder.andWhere(search)
|
2020-06-26 21:25:03 +00:00
|
|
|
}
|
|
|
|
|
2021-08-19 14:42:18 +00:00
|
|
|
if (query.filter) {
|
|
|
|
const filter = {}
|
|
|
|
function getOperatorFn(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.NOT:
|
|
|
|
return Not
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function isOperator(value: any): value is FilterOperator {
|
|
|
|
return values(FilterOperator).includes(value)
|
|
|
|
}
|
|
|
|
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 = raw.split(':')
|
|
|
|
if (tokens.length === 0 || tokens.length > 3) {
|
|
|
|
continue
|
|
|
|
} 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)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
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] = getOperatorFn(op1)(args)
|
|
|
|
}
|
|
|
|
if (isOperator(op2)) {
|
|
|
|
filter[column] = getOperatorFn(op2)(filter[column])
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-08-19 17:48:50 +00:00
|
|
|
queryBuilder = queryBuilder.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 =
|
2021-10-12 11:23:58 +00:00
|
|
|
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,
|
2021-10-12 11:32:03 +00:00
|
|
|
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)
|
|
|
|
}
|