2021-08-19 14:42:18 +00:00
|
|
|
import {
|
|
|
|
Repository,
|
|
|
|
SelectQueryBuilder,
|
|
|
|
FindOperator,
|
|
|
|
Equal,
|
|
|
|
MoreThan,
|
|
|
|
MoreThanOrEqual,
|
|
|
|
In,
|
|
|
|
IsNull,
|
|
|
|
LessThan,
|
|
|
|
LessThanOrEqual,
|
|
|
|
Not,
|
|
|
|
ILike,
|
2021-10-11 07:46:31 +00:00
|
|
|
Brackets,
|
2022-01-27 12:32:51 +00:00
|
|
|
Between,
|
2022-06-20 08:38:36 +00:00
|
|
|
FindOptionsWhere,
|
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'
|
2022-03-14 19:02:01 +00:00
|
|
|
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> {
|
2022-03-14 19:02:01 +00:00
|
|
|
relations?: RelationColumn<T>[]
|
2020-06-26 21:25:03 +00:00
|
|
|
sortableColumns: Column<T>[]
|
2022-08-20 22:25:48 +00:00
|
|
|
nullSort?: 'first' | 'last'
|
2020-06-28 17:34:00 +00:00
|
|
|
searchableColumns?: Column<T>[]
|
2022-07-27 17:58:00 +00:00
|
|
|
select?: Column<T>[]
|
2020-06-26 21:25:03 +00:00
|
|
|
maxLimit?: number
|
|
|
|
defaultSortBy?: SortBy<T>
|
|
|
|
defaultLimit?: number
|
2022-06-20 08:38:36 +00:00
|
|
|
where?: FindOptionsWhere<T> | FindOptionsWhere<T>[]
|
2021-08-19 14:42:18 +00:00
|
|
|
filterableColumns?: { [key in Column<T>]?: FilterOperator[] }
|
2022-05-15 19:01:53 +00:00
|
|
|
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',
|
2022-01-27 12:32:51 +00:00
|
|
|
BTW = '$btw',
|
2021-08-19 14:42:18 +00:00
|
|
|
NOT = '$not',
|
2020-06-26 21:25:03 +00:00
|
|
|
}
|
|
|
|
|
2021-11-16 17:49:13 +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],
|
|
|
|
])
|
2021-11-16 17:49:13 +00:00
|
|
|
|
|
|
|
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> } = {}
|
2021-11-16 17:49:13 +00:00
|
|
|
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]
|
2021-11-16 17:49:13 +00:00
|
|
|
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
|
|
|
|
}
|
2021-11-16 17:49:13 +00:00
|
|
|
}
|
|
|
|
if (isOperator(op2)) {
|
2022-01-27 21:12:52 +00:00
|
|
|
filter[column] = OperatorSymbolToFunction.get(op2)(filter[column])
|
2021-11-16 17:49:13 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
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
|
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()
|
|
|
|
|
2022-08-22 13:46:23 +00:00
|
|
|
let NULL_SORT: 'NULLS LAST' | 'NULLS FIRST'
|
|
|
|
if (config.nullSort) {
|
|
|
|
NULL_SORT = config.nullSort === 'last' ? 'NULLS LAST' : 'NULLS FIRST'
|
|
|
|
}
|
2022-08-20 22:25:48 +00:00
|
|
|
|
2020-06-26 21:25:03 +00:00
|
|
|
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)
|
2022-03-14 19:02:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if (config.relations?.length) {
|
|
|
|
config.relations.forEach((relation) => {
|
|
|
|
queryBuilder.leftJoinAndSelect(`${queryBuilder.alias}.${relation}`, `${queryBuilder.alias}_${relation}`)
|
|
|
|
})
|
|
|
|
}
|
2020-06-26 21:25:03 +00:00
|
|
|
|
2022-03-14 19:02:01 +00:00
|
|
|
for (const order of sortBy) {
|
|
|
|
if (order[0].split('.').length > 1) {
|
2022-08-20 22:25:48 +00:00
|
|
|
queryBuilder.addOrderBy(`${queryBuilder.alias}_${order[0]}`, order[1], NULL_SORT)
|
2022-03-14 19:02:01 +00:00
|
|
|
} else {
|
2022-08-20 22:25:48 +00:00
|
|
|
queryBuilder.addOrderBy(`${queryBuilder.alias}.${order[0]}`, order[1], NULL_SORT)
|
2020-06-26 21:25:03 +00:00
|
|
|
}
|
2020-06-28 17:34:00 +00:00
|
|
|
}
|
2020-06-26 21:25:03 +00:00
|
|
|
|
2022-07-27 17:58:00 +00:00
|
|
|
if (config.select?.length > 0) {
|
|
|
|
const mappedSelect = config.select.map((col) => {
|
|
|
|
if (col.includes('.')) {
|
|
|
|
const [rel, relCol] = col.split('.')
|
|
|
|
return `${queryBuilder.alias}_${rel}.${relCol}`
|
|
|
|
}
|
|
|
|
|
|
|
|
return `${queryBuilder.alias}.${col}`
|
|
|
|
})
|
|
|
|
queryBuilder.select(mappedSelect)
|
|
|
|
}
|
|
|
|
|
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
|
|
|
}
|
|
|
|
|
2022-05-15 19:01:53 +00:00
|
|
|
if (config.withDeleted) {
|
|
|
|
queryBuilder.withDeleted()
|
|
|
|
}
|
|
|
|
|
2021-10-12 11:01:53 +00:00
|
|
|
if (query.search && searchBy.length) {
|
2022-03-14 19:02:01 +00:00
|
|
|
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)
|
2022-03-14 19:02:01 +00:00
|
|
|
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
|
2022-03-14 19:02:55 +00:00
|
|
|
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)
|
2022-03-14 19:02:01 +00:00
|
|
|
} 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 =
|
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),
|
2022-02-10 18:21:59 +00:00
|
|
|
last: page == totalPages || !totalItems ? undefined : buildLink(totalPages),
|
2020-06-26 21:25:03 +00:00
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
return Object.assign(new Paginated<T>(), results)
|
|
|
|
}
|