2023-02-23 07:58:47 +00:00
|
|
|
import {
|
|
|
|
Repository,
|
|
|
|
SelectQueryBuilder,
|
|
|
|
Brackets,
|
|
|
|
FindOptionsWhere,
|
|
|
|
FindOptionsRelations,
|
|
|
|
ObjectLiteral,
|
2023-03-01 08:02:13 +00:00
|
|
|
FindOptionsUtils,
|
2023-02-23 07:58:47 +00:00
|
|
|
} from 'typeorm'
|
2023-03-14 21:12:49 +00:00
|
|
|
import { PaginateQuery } from './decorator'
|
2022-11-16 07:58:00 +00:00
|
|
|
import { ServiceUnavailableException, Logger } from '@nestjs/common'
|
2023-02-09 08:21:09 +00:00
|
|
|
import { mapKeys } from 'lodash'
|
2021-08-19 14:42:18 +00:00
|
|
|
import { stringify } from 'querystring'
|
2022-03-14 19:02:01 +00:00
|
|
|
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,
|
2023-03-01 11:23:17 +00:00
|
|
|
hasColumnWithPropertyPath,
|
|
|
|
includesAllPrimaryKeyColumns,
|
2023-03-14 21:10:23 +00:00
|
|
|
isEntityKey,
|
2023-03-17 21:23:46 +00:00
|
|
|
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
|
|
|
|
2022-11-16 07:58:00 +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> {
|
2023-02-23 07:58:47 +00:00
|
|
|
relations?: FindOptionsRelations<T> | 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>[]
|
2023-03-14 18:12:30 +00:00
|
|
|
select?: Column<T>[] | string[]
|
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>[]
|
2023-02-09 08:21:09 +00:00
|
|
|
filterableColumns?: {
|
2023-03-14 18:12:30 +00:00
|
|
|
[key in Column<T> | string]?: (FilterOperator | FilterSuffix)[]
|
2023-02-09 08:21:09 +00:00
|
|
|
}
|
2023-03-01 08:02:13 +00:00
|
|
|
loadEagerRelations?: boolean
|
2022-05-15 19:01:53 +00:00
|
|
|
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
|
|
|
|
|
2022-11-16 07:58:33 +00:00
|
|
|
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]
|
|
|
|
|
2023-02-23 07:58:47 +00:00
|
|
|
const queryBuilder = repo instanceof Repository ? repo.createQueryBuilder('__root') : repo
|
2020-06-28 17:34:00 +00:00
|
|
|
|
2023-03-17 21:23:46 +00:00
|
|
|
const isPostgres = ['postgres', 'cockroachdb'].includes(queryBuilder.connection.options.type)
|
|
|
|
|
2023-03-01 08:02:13 +00:00
|
|
|
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) {
|
2023-03-17 20:39:13 +00:00
|
|
|
queryBuilder.limit(limit).offset((page - 1) * limit)
|
2022-03-14 19:02:01 +00:00
|
|
|
}
|
|
|
|
|
2023-02-23 07:58:47 +00:00
|
|
|
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)
|
|
|
|
}
|
2022-03-14 19:02:01 +00:00
|
|
|
}
|
2020-06-26 21:25:03 +00:00
|
|
|
|
2023-03-17 21:23:46 +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'
|
|
|
|
}
|
|
|
|
|
2023-03-17 21:23:46 +00:00
|
|
|
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']]))
|
|
|
|
}
|
|
|
|
|
2022-03-14 19:02:01 +00:00
|
|
|
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
|
2023-03-01 11:23:17 +00:00
|
|
|
// typeorm will not be able to map the result.
|
2023-02-15 09:16:35 +00:00
|
|
|
const selectParams = config.select || query.select
|
2023-03-01 11:23:17 +00:00
|
|
|
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)
|
2023-03-01 11:23:17 +00:00
|
|
|
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
|
|
|
}
|
|
|
|
|
2022-05-15 19:01:53 +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) {
|
2022-03-14 19:02:01 +00:00
|
|
|
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-03-14 18:12:30 +00:00
|
|
|
|
2023-01-30 11:17:14 +00:00
|
|
|
const condition: WherePredicateOperator = {
|
|
|
|
operator: 'ilike',
|
2023-03-14 18:12:30 +00:00
|
|
|
parameters: [alias, `:${property.column}`],
|
2023-01-30 11:17:14 +00:00
|
|
|
}
|
2023-01-13 14:53:41 +00:00
|
|
|
|
2023-03-17 21:23:46 +00:00
|
|
|
if (isPostgres) {
|
2023-03-16 19:54:09 +00:00
|
|
|
condition.parameters[0] = `CAST(${condition.parameters[0]} AS text)`
|
2022-03-14 19:02:01 +00:00
|
|
|
}
|
2023-01-30 11:17:14 +00:00
|
|
|
|
|
|
|
qb.orWhere(qb['createWhereConditionExpression'](condition), {
|
2023-03-14 18:12:30 +00:00
|
|
|
[property.column]: `%${query.search}%`,
|
2023-01-30 11:17:14 +00:00
|
|
|
})
|
2022-03-14 19:02:01 +00:00
|
|
|
}
|
|
|
|
})
|
|
|
|
)
|
2020-06-26 21:25:03 +00:00
|
|
|
}
|
|
|
|
|
2021-08-19 14:42:18 +00:00
|
|
|
if (query.filter) {
|
2023-02-09 08:21:09 +00:00
|
|
|
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
|
|
|
|
2023-03-17 21:23:46 +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 =
|
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
|
|
|
|
|
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,
|
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)
|
|
|
|
}
|