nestjs-paginate/src/paginate.ts

315 lines
11 KiB
TypeScript
Raw Normal View History

import {
Repository,
SelectQueryBuilder,
Brackets,
FindOptionsWhere,
FindOptionsRelations,
ObjectLiteral,
FindOptionsUtils,
} from 'typeorm'
2023-03-14 21:12:49 +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,
2023-02-15 08:05:52 +00:00
checkIsEmbedded,
2023-01-30 11:17:14 +00:00
Column,
extractVirtualProperty,
fixColumnAlias,
getPropertiesByColumnName,
Order,
positiveNumberOrDefault,
RelationColumn,
SortBy,
hasColumnWithPropertyPath,
includesAllPrimaryKeyColumns,
2023-03-14 21:10:23 +00:00
isEntityKey,
getQueryUrlComponents,
2023-01-30 11:17:14 +00:00
} from './helper'
2023-03-14 21:10:23 +00:00
import { addFilter, FilterOperator, FilterSuffix } from './filter'
2020-06-26 21:25:03 +00:00
const logger: Logger = new Logger('nestjs-paginate')
2023-03-14 21:10:23 +00:00
export { FilterOperator, FilterSuffix }
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?: FindOptionsRelations<T> | 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>[]
select?: Column<T>[] | string[]
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> | string]?: (FilterOperator | FilterSuffix)[]
}
loadEagerRelations?: boolean
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>[] = []
2020-06-26 21:25:03 +00:00
let [items, totalItems]: [T[], number] = [[], 0]
const queryBuilder = repo instanceof Repository ? repo.createQueryBuilder('__root') : repo
2020-06-28 17:34:00 +00:00
const isPostgres = ['postgres', 'cockroachdb'].includes(queryBuilder.connection.options.type)
if (repo instanceof Repository && !config.relations && config.loadEagerRelations === true) {
if (!config.relations) {
FindOptionsUtils.joinEagerRelations(queryBuilder, queryBuilder.alias, repo.metadata)
}
}
2022-12-13 11:35:08 +00:00
if (isPaginated) {
queryBuilder.limit(limit).offset((page - 1) * limit)
}
if (config.relations) {
// relations: ["relation"]
if (Array.isArray(config.relations)) {
config.relations.forEach((relation) => {
queryBuilder.leftJoinAndSelect(`${queryBuilder.alias}.${relation}`, `${queryBuilder.alias}_${relation}`)
})
} else {
// relations: {relation:true}
const createQueryBuilderRelations = (
prefix: string,
relations: FindOptionsRelations<T> | RelationColumn<T>[],
alias?: string
) => {
Object.keys(relations).forEach((relationName) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const relationSchema = relations![relationName]!
queryBuilder.leftJoinAndSelect(
`${alias ?? prefix}.${relationName}`,
`${alias ?? prefix}_${relationName}`
)
if (typeof relationSchema === 'object') {
createQueryBuilderRelations(relationName, relationSchema, `${prefix}_${relationName}`)
}
})
}
createQueryBuilderRelations(queryBuilder.alias, config.relations)
}
}
2020-06-26 21:25:03 +00:00
let nullSort: 'NULLS LAST' | 'NULLS FIRST' | undefined = isPostgres ? 'NULLS FIRST' : undefined
2022-08-22 18:23:28 +00:00
if (config.nullSort) {
nullSort = config.nullSort === 'last' ? 'NULLS LAST' : 'NULLS FIRST'
}
if (config.sortableColumns.length < 1) {
logger.debug("Missing required 'sortableColumns' config.")
throw new ServiceUnavailableException()
}
if (query.sortBy) {
for (const order of query.sortBy) {
if (isEntityKey(config.sortableColumns, order[0]) && ['ASC', 'DESC'].includes(order[1])) {
sortBy.push(order as Order<T>)
}
}
}
if (!sortBy.length) {
sortBy.push(...(config.defaultSortBy || [[config.sortableColumns[0], 'ASC']]))
}
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)
2023-02-15 08:05:52 +00:00
const isEmbeded = checkIsEmbedded(queryBuilder, columnProperties.propertyPath)
const alias = fixColumnAlias(columnProperties, queryBuilder.alias, isRelation, isVirtualProperty, isEmbeded)
2023-01-30 11:17:14 +00:00
queryBuilder.addOrderBy(alias, order[1], nullSort)
2020-06-28 17:34:00 +00:00
}
2020-06-26 21:25:03 +00:00
2023-02-15 09:16:35 +00:00
// When we partial select the columns (main or relation) we must add the primary key column otherwise
// typeorm will not be able to map the result.
2023-02-15 09:16:35 +00:00
const selectParams = config.select || query.select
if (selectParams?.length > 0 && includesAllPrimaryKeyColumns(queryBuilder, selectParams)) {
2023-02-15 09:16:35 +00:00
const cols: string[] = selectParams.reduce((cols, currentCol) => {
if (query.select?.includes(currentCol) ?? true) {
const columnProperties = getPropertiesByColumnName(currentCol)
const isRelation = checkIsRelation(queryBuilder, columnProperties.propertyPath)
const { isVirtualProperty } = extractVirtualProperty(queryBuilder, columnProperties)
if (hasColumnWithPropertyPath(queryBuilder, columnProperties) || isVirtualProperty) {
// here we can avoid to manually fix and add the query of virtual columns
cols.push(fixColumnAlias(columnProperties, queryBuilder.alias, isRelation))
}
2022-07-27 17:58:00 +00:00
}
2023-02-15 09:16:35 +00:00
return cols
}, [])
queryBuilder.select(cols)
2022-07-27 17:58:00 +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()
}
2023-03-14 21:10:23 +00:00
if (config.searchableColumns) {
if (query.searchBy) {
for (const column of query.searchBy) {
if (isEntityKey(config.searchableColumns, column)) {
searchBy.push(column)
}
}
} else {
searchBy.push(...config.searchableColumns)
}
}
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)
2023-02-15 08:05:52 +00:00
const isEmbeded = checkIsEmbedded(qb, property.propertyPath)
const alias = fixColumnAlias(
property,
qb.alias,
isRelation,
isVirtualProperty,
isEmbeded,
virtualQuery
)
2023-01-30 11:17:14 +00:00
const condition: WherePredicateOperator = {
operator: 'ilike',
parameters: [alias, `:${property.column}`],
2023-01-30 11:17:14 +00:00
}
if (isPostgres) {
condition.parameters[0] = `CAST(${condition.parameters[0]} AS text)`
}
2023-01-30 11:17:14 +00:00
qb.orWhere(qb['createWhereConditionExpression'](condition), {
[property.column]: `%${query.search}%`,
2023-01-30 11:17:14 +00:00
})
}
})
)
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
let path: string
const { queryOrigin, queryPath } = getQueryUrlComponents(query.path)
if (config.relativePath) {
path = queryPath
} else if (config.origin) {
path = config.origin + queryPath
} else {
path = queryOrigin + queryPath
}
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)
}