nestjs-paginate/src/paginate.ts

345 lines
11 KiB
TypeScript
Raw Normal View History

2021-08-19 14:42:18 +00:00
import {
Repository,
FindConditions,
SelectQueryBuilder,
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'
import { WherePredicateOperator } from 'typeorm/query-builder/WhereClause'
2022-03-14 19:48:41 +00:00
import { Column, Order, RelationColumn, SortBy } from './helper'
2020-06-26 21:25:03 +00:00
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> {
relations?: RelationColumn<T>[]
2020-06-26 21:25:03 +00:00
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[] }
withDeleted?: boolean
2021-08-19 14:42:18 +00:00
}
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)
}
2022-01-27 21:12:52 +00:00
export const OperatorSymbolToFunction = new Map<FilterOperator, (...args: any[]) => FindOperator<string>>([
[FilterOperator.EQ, Equal],
[FilterOperator.GT, MoreThan],
[FilterOperator.GTE, MoreThanOrEqual],
[FilterOperator.IN, In],
[FilterOperator.NULL, IsNull],
[FilterOperator.LT, LessThan],
[FilterOperator.LTE, LessThanOrEqual],
[FilterOperator.BTW, Between],
[FilterOperator.NOT, 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>) {
2022-02-06 18:52:38 +00:00
const filter: { [columnName: string]: FindOperator<string> } = {}
for (const column of Object.keys(query.filter)) {
if (!(column in config.filterableColumns)) {
continue
}
2022-01-27 21:12:52 +00:00
const allowedOperators = config.filterableColumns[column]
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)) {
2022-01-27 21:12:52 +00:00
switch (op1) {
case FilterOperator.BTW:
filter[column] = OperatorSymbolToFunction.get(op1)(...value.split(','))
break
case FilterOperator.IN:
filter[column] = OperatorSymbolToFunction.get(op1)(value.split(','))
break
default:
filter[column] = OperatorSymbolToFunction.get(op1)(value)
break
}
}
if (isOperator(op2)) {
2022-01-27 21:12:52 +00:00
filter[column] = OperatorSymbolToFunction.get(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)
} else {
2020-06-28 17:34:00 +00:00
queryBuilder = repo.take(limit).skip((page - 1) * limit)
}
if (config.relations?.length) {
config.relations.forEach((relation) => {
queryBuilder.leftJoinAndSelect(`${queryBuilder.alias}.${relation}`, `${queryBuilder.alias}_${relation}`)
})
}
2020-06-26 21:25:03 +00:00
for (const order of sortBy) {
if (order[0].split('.').length > 1) {
queryBuilder.addOrderBy(`${queryBuilder.alias}_${order[0]}`, order[1])
} else {
queryBuilder.addOrderBy(`${queryBuilder.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
}
if (config.withDeleted) {
queryBuilder.withDeleted()
}
2021-10-12 11:01:53 +00:00
if (query.search && searchBy.length) {
queryBuilder.andWhere(
new Brackets((qb: SelectQueryBuilder<T>) => {
for (const column of searchBy) {
const propertyPath = (column as string).split('.')
if (propertyPath.length > 1) {
const condition: WherePredicateOperator = {
operator: 'ilike',
parameters: [`${qb.alias}_${column}`, `:${column}`],
}
qb.orWhere(qb['createWhereConditionExpression'](condition), {
[column]: `%${query.search}%`,
})
} else {
qb.orWhere({
[column]: ILike(`%${query.search}%`),
})
}
}
})
)
2020-06-26 21:25:03 +00:00
}
2021-08-19 14:42:18 +00:00
if (query.filter) {
2022-01-27 21:12:52 +00:00
const filter = parseFilter(query, config)
queryBuilder.andWhere(
new Brackets((qb: SelectQueryBuilder<T>) => {
for (const column in filter) {
const propertyPath = (column as string).split('.')
if (propertyPath.length > 1) {
const condition = qb['getWherePredicateCondition'](
column,
filter[column]
) as WherePredicateOperator
let parameters = { [column]: filter[column].value }
// TODO: refactor below
switch (condition.operator) {
case 'between':
condition.parameters = [`${qb.alias}_${column}`, `:${column}_from`, `:${column}_to`]
parameters = {
[column + '_from']: filter[column].value[0],
[column + '_to']: filter[column].value[1],
}
break
case 'in':
condition.parameters = [`${qb.alias}_${column}`, `:...${column}`]
break
default:
condition.parameters = [`${qb.alias}_${column}`, `:${column}`]
break
}
qb.andWhere(qb['createWhereConditionExpression'](condition), parameters)
} else {
qb.andWhere({
[column]: filter[column],
})
}
}
})
)
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 || !totalItems ? undefined : buildLink(totalPages),
2020-06-26 21:25:03 +00:00
},
}
return Object.assign(new Paginated<T>(), results)
}