nestjs-paginate/src/paginate.ts

271 lines
8.8 KiB
TypeScript
Raw Normal View History

import { Repository, SelectQueryBuilder, Brackets, FindOptionsWhere, ObjectLiteral } from 'typeorm'
2020-06-26 21:25:03 +00:00
import { PaginateQuery } from './decorator'
import { ServiceUnavailableException, Logger } from '@nestjs/common'
import { mapKeys } from 'lodash'
2021-08-19 14:42:18 +00:00
import { stringify } from 'querystring'
import { WherePredicateOperator } from 'typeorm/query-builder/WhereClause'
2023-01-30 11:17:14 +00:00
import {
checkIsRelation,
Column,
extractVirtualProperty,
fixColumnAlias,
getPropertiesByColumnName,
Order,
positiveNumberOrDefault,
RelationColumn,
SortBy,
} from './helper'
import { FilterOperator, FilterSuffix } from './operator'
import { addFilter } from './filter'
2020-06-26 21:25:03 +00:00
const logger: Logger = new Logger('nestjs-paginate')
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>[]
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
where?: FindOptionsWhere<T> | FindOptionsWhere<T>[]
filterableColumns?: {
[key in Column<T>]?: (FilterOperator | FilterSuffix)[]
}
withDeleted?: boolean
2022-09-30 11:11:55 +00:00
relativePath?: boolean
origin?: string
2021-08-19 14:42:18 +00:00
}
2022-12-13 11:35:08 +00:00
export const DEFAULT_MAX_LIMIT = 100
export const DEFAULT_LIMIT = 20
export const NO_PAGINATION = 0
export async function paginate<T extends ObjectLiteral>(
2020-06-26 21:25:03 +00:00
query: PaginateQuery,
repo: Repository<T> | SelectQueryBuilder<T>,
config: PaginateConfig<T>
): Promise<Paginated<T>> {
2022-12-13 11:35:08 +00:00
const page = positiveNumberOrDefault(query.page, 1, 1)
const defaultLimit = config.defaultLimit || DEFAULT_LIMIT
const maxLimit = positiveNumberOrDefault(config.maxLimit, DEFAULT_MAX_LIMIT)
const queryLimit = positiveNumberOrDefault(query.limit, defaultLimit)
const isPaginated = !(queryLimit === NO_PAGINATION && maxLimit === NO_PAGINATION)
const limit = isPaginated ? Math.min(queryLimit || defaultLimit, maxLimit || DEFAULT_MAX_LIMIT) : NO_PAGINATION
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>[] = []
2022-12-13 11:35:08 +00:00
let path: string
2022-09-30 11:11:55 +00:00
const r = new RegExp('^(?:[a-z+]+:)?//', 'i')
let queryOrigin = ''
let queryPath = ''
if (r.test(query.path)) {
const url = new URL(query.path)
queryOrigin = url.origin
queryPath = url.pathname
} else {
queryPath = query.path
}
if (config.relativePath) {
path = queryPath
} else if (config.origin) {
path = config.origin + queryPath
} else {
path = queryOrigin + queryPath
}
2020-06-26 21:25:03 +00:00
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) {
logger.debug("Missing required 'sortableColumns' config.")
throw new ServiceUnavailableException()
}
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-26 21:25:03 +00:00
let [items, totalItems]: [T[], number] = [[], 0]
2022-12-13 11:35:08 +00:00
const queryBuilder = repo instanceof Repository ? repo.createQueryBuilder('e') : repo
2020-06-28 17:34:00 +00:00
2022-12-13 11:35:08 +00:00
if (isPaginated) {
2023-01-30 11:17:14 +00:00
// Switch from take and skip to limit and offset
// due to this problem https://github.com/typeorm/typeorm/issues/5670
// (anyway this creates more clean query without double dinstict)
queryBuilder.limit(limit).offset((page - 1) * limit)
// queryBuilder.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
2022-08-22 18:23:28 +00:00
let nullSort: 'NULLS LAST' | 'NULLS FIRST' | undefined = undefined
if (config.nullSort) {
nullSort = config.nullSort === 'last' ? 'NULLS LAST' : 'NULLS FIRST'
}
for (const order of sortBy) {
2023-01-30 11:17:14 +00:00
const columnProperties = getPropertiesByColumnName(order[0])
const { isVirtualProperty } = extractVirtualProperty(queryBuilder, columnProperties)
const isRelation = checkIsRelation(queryBuilder, columnProperties.propertyPath)
const alias = fixColumnAlias(columnProperties, queryBuilder.alias, isRelation, isVirtualProperty)
queryBuilder.addOrderBy(alias, order[1], nullSort)
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
}
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) {
2023-01-30 11:17:14 +00:00
const property = getPropertiesByColumnName(column)
const { isVirtualProperty, query: virtualQuery } = extractVirtualProperty(qb, property)
const isRelation = checkIsRelation(qb, property.propertyPath)
const alias = fixColumnAlias(property, qb.alias, isRelation, isVirtualProperty, virtualQuery)
const condition: WherePredicateOperator = {
operator: 'ilike',
parameters: [alias, `:${column}`],
}
if (['postgres', 'cockroachdb'].includes(queryBuilder.connection.options.type)) {
2023-01-30 11:17:14 +00:00
condition.parameters[0] += '::text'
}
2023-01-30 11:17:14 +00:00
qb.orWhere(qb['createWhereConditionExpression'](condition), {
[column]: `%${query.search}%`,
})
}
})
)
2020-06-26 21:25:03 +00:00
}
2021-08-19 14:42:18 +00:00
if (query.filter) {
addFilter(queryBuilder, query, config.filterableColumns)
2021-08-19 14:42:18 +00:00
}
2022-12-13 11:35:08 +00:00
if (isPaginated) {
;[items, totalItems] = await queryBuilder.getManyAndCount()
} else {
items = await queryBuilder.getMany()
}
2020-06-26 21:25:03 +00:00
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
2022-12-13 11:35:08 +00:00
const totalPages = isPaginated ? Math.ceil(totalItems / limit) : 1
2020-06-26 21:25:03 +00:00
const results: Paginated<T> = {
data: items,
meta: {
2022-12-13 11:35:08 +00:00
itemsPerPage: isPaginated ? limit : items.length,
2023-01-03 08:52:35 +00:00
totalItems: isPaginated ? totalItems : items.length,
2020-06-26 21:25:03 +00:00
currentPage: page,
2022-12-13 11:35:08 +00:00
totalPages,
2020-06-26 21:25:03 +00:00
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)
}