diff --git a/apps/admin-api/src/api/article/article.service.spec.ts b/apps/admin-api/src/api/article/article.service.spec.ts index 37d370a..bb32f35 100644 --- a/apps/admin-api/src/api/article/article.service.spec.ts +++ b/apps/admin-api/src/api/article/article.service.spec.ts @@ -1,12 +1,28 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ArticleEntity } from '@repo/database-typeorm'; +import { Repository } from 'typeorm'; import { ArticleService } from './article.service'; describe('ArticleService', () => { let service: ArticleService; + let articleRepositoryValue: Partial< + Record, jest.Mock> + >; + + beforeAll(async () => { + articleRepositoryValue = { + findOne: jest.fn(), + }; - beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [ArticleService], + providers: [ + ArticleService, + { + provide: getRepositoryToken(ArticleEntity), + useValue: articleRepositoryValue, + }, + ], }).compile(); service = module.get(ArticleService); diff --git a/apps/admin-api/src/api/article/article.service.ts b/apps/admin-api/src/api/article/article.service.ts index 39db161..61b95b8 100644 --- a/apps/admin-api/src/api/article/article.service.ts +++ b/apps/admin-api/src/api/article/article.service.ts @@ -1,11 +1,17 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { ArticleEntity } from '@repo/database-typeorm'; +import { Repository } from 'typeorm'; @Injectable() export class ArticleService { - constructor() {} + constructor( + @InjectRepository(ArticleEntity) + private readonly articleRepository: Repository, + ) {} async list() { - throw new Error('Method not implemented.'); + this.articleRepository.find(); } async feed() { diff --git a/packages/api/src/constants/app.constant.ts b/packages/api/src/constants/app.constant.ts index 356714e..65ee40e 100644 --- a/packages/api/src/constants/app.constant.ts +++ b/packages/api/src/constants/app.constant.ts @@ -14,3 +14,11 @@ export enum LogService { GOOGLE_LOGGING = 'google_logging', AWS_CLOUDWATCH = 'aws_cloudwatch', } + +export enum Order { + ASC = 'ASC', + DESC = 'DESC', +} + +export const DEFAULT_PAGE_LIMIT = 10; +export const DEFAULT_CURRENT_PAGE = 1; diff --git a/packages/api/src/dto/cursor-pagination/cursor-pagination.dto.ts b/packages/api/src/dto/cursor-pagination/cursor-pagination.dto.ts new file mode 100644 index 0000000..f400b40 --- /dev/null +++ b/packages/api/src/dto/cursor-pagination/cursor-pagination.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { PageOptionsDto } from './page-options.dto'; + +export class CursorPaginationDto { + @ApiProperty() + @Expose() + readonly limit: number; + + @ApiProperty() + @Expose() + readonly afterCursor?: string; + + @ApiProperty() + @Expose() + readonly beforeCursor?: string; + + @ApiProperty() + @Expose() + readonly totalRecords: number; + + constructor( + totalRecords: number, + afterCursor: string, + beforeCursor: string, + pageOptions: PageOptionsDto, + ) { + this.limit = pageOptions.limit; + this.afterCursor = afterCursor; + this.beforeCursor = beforeCursor; + this.totalRecords = totalRecords; + } +} diff --git a/packages/api/src/dto/cursor-pagination/page-options.dto.ts b/packages/api/src/dto/cursor-pagination/page-options.dto.ts new file mode 100644 index 0000000..68fe58c --- /dev/null +++ b/packages/api/src/dto/cursor-pagination/page-options.dto.ts @@ -0,0 +1,20 @@ +import { DEFAULT_PAGE_LIMIT } from '../../constants'; +import { NumberFieldOptional, StringFieldOptional } from '../../decorators'; + +export class PageOptionsDto { + @StringFieldOptional() + afterCursor?: string; + + @StringFieldOptional() + beforeCursor?: string; + + @NumberFieldOptional({ + minimum: 1, + default: DEFAULT_PAGE_LIMIT, + int: true, + }) + readonly limit: number = DEFAULT_PAGE_LIMIT; + + @StringFieldOptional() + readonly q?: string; +} diff --git a/packages/api/src/dto/cursor-pagination/paginated.dto.ts b/packages/api/src/dto/cursor-pagination/paginated.dto.ts new file mode 100644 index 0000000..cfc58fb --- /dev/null +++ b/packages/api/src/dto/cursor-pagination/paginated.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { CursorPaginationDto } from './cursor-pagination.dto'; + +export class CursorPaginatedDto { + @ApiProperty({ type: [Object] }) + @Expose() + readonly data: TData[]; + + @ApiProperty() + @Expose() + pagination: CursorPaginationDto; + + constructor(data: TData[], meta: CursorPaginationDto) { + this.data = data; + this.pagination = meta; + } +} diff --git a/packages/api/src/dto/offset-pagination/offset-pagination.dto.ts b/packages/api/src/dto/offset-pagination/offset-pagination.dto.ts new file mode 100644 index 0000000..3586245 --- /dev/null +++ b/packages/api/src/dto/offset-pagination/offset-pagination.dto.ts @@ -0,0 +1,43 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { PageOptionsDto } from './page-options.dto'; + +export class OffsetPaginationDto { + @ApiProperty() + @Expose() + readonly limit: number; + + @ApiProperty() + @Expose() + readonly currentPage: number; + + @ApiProperty() + @Expose() + readonly nextPage?: number; + + @ApiProperty() + @Expose() + readonly previousPage?: number; + + @ApiProperty() + @Expose() + readonly totalRecords: number; + + @ApiProperty() + @Expose() + readonly totalPages: number; + + constructor(totalRecords: number, pageOptions: PageOptionsDto) { + this.limit = pageOptions.limit; + this.currentPage = pageOptions.offset / pageOptions.limit + 1; + this.nextPage = + this.currentPage < this.totalPages ? this.currentPage + 1 : undefined; + this.previousPage = + this.currentPage > 1 && this.currentPage - 1 < this.totalPages + ? this.currentPage - 1 + : undefined; + this.totalRecords = totalRecords; + this.totalPages = + this.limit > 0 ? Math.ceil(totalRecords / pageOptions.limit) : 0; + } +} diff --git a/packages/api/src/dto/offset-pagination/page-options.dto.ts b/packages/api/src/dto/offset-pagination/page-options.dto.ts new file mode 100644 index 0000000..688ef35 --- /dev/null +++ b/packages/api/src/dto/offset-pagination/page-options.dto.ts @@ -0,0 +1,28 @@ +import { DEFAULT_PAGE_LIMIT, Order } from '../../constants'; +import { + EnumFieldOptional, + NumberFieldOptional, + StringFieldOptional, +} from '../../decorators'; + +export class PageOptionsDto { + @NumberFieldOptional({ + minimum: 1, + default: DEFAULT_PAGE_LIMIT, + int: true, + }) + readonly limit: number = DEFAULT_PAGE_LIMIT; + + @NumberFieldOptional({ + minimum: 0, + default: 0, + int: true, + }) + readonly offset: number = 0; + + @StringFieldOptional() + readonly q?: string; + + @EnumFieldOptional(() => Order, { default: Order.ASC }) + readonly order: Order = Order.ASC; +} diff --git a/packages/api/src/dto/offset-pagination/paginated.dto.ts b/packages/api/src/dto/offset-pagination/paginated.dto.ts new file mode 100644 index 0000000..b798ac0 --- /dev/null +++ b/packages/api/src/dto/offset-pagination/paginated.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { OffsetPaginationDto } from './offset-pagination.dto'; + +export class OffsetPaginatedDto { + @ApiProperty({ type: [Object] }) + @Expose() + readonly data: TData[]; + + @ApiProperty() + @Expose() + pagination: OffsetPaginationDto; + + constructor(data: TData[], meta: OffsetPaginationDto) { + this.data = data; + this.pagination = meta; + } +} diff --git a/packages/api/src/dto/page-pagination/page-options.dto.ts b/packages/api/src/dto/page-pagination/page-options.dto.ts new file mode 100644 index 0000000..ec867fb --- /dev/null +++ b/packages/api/src/dto/page-pagination/page-options.dto.ts @@ -0,0 +1,36 @@ +import { + DEFAULT_CURRENT_PAGE, + DEFAULT_PAGE_LIMIT, + Order, +} from '../../constants'; +import { + EnumFieldOptional, + NumberFieldOptional, + StringFieldOptional, +} from '../../decorators'; + +export class PageOptionsDto { + @NumberFieldOptional({ + minimum: 1, + default: DEFAULT_PAGE_LIMIT, + int: true, + }) + readonly limit: number = DEFAULT_PAGE_LIMIT; + + @NumberFieldOptional({ + minimum: 1, + default: DEFAULT_CURRENT_PAGE, + int: true, + }) + readonly page: number = DEFAULT_CURRENT_PAGE; + + @StringFieldOptional() + readonly q?: string; + + @EnumFieldOptional(() => Order, { default: Order.ASC }) + readonly order: Order = Order.ASC; + + get offset() { + return this.page ? (this.page - 1) * this.limit : 0; + } +} diff --git a/packages/api/src/dto/page-pagination/page-pagination.dto.ts b/packages/api/src/dto/page-pagination/page-pagination.dto.ts new file mode 100644 index 0000000..ea51322 --- /dev/null +++ b/packages/api/src/dto/page-pagination/page-pagination.dto.ts @@ -0,0 +1,43 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { PageOptionsDto } from './page-options.dto'; + +export class PagePaginationDto { + @ApiProperty() + @Expose() + readonly limit: number; + + @ApiProperty() + @Expose() + readonly currentPage: number; + + @ApiProperty() + @Expose() + readonly nextPage?: number; + + @ApiProperty() + @Expose() + readonly previousPage?: number; + + @ApiProperty() + @Expose() + readonly totalRecords: number; + + @ApiProperty() + @Expose() + readonly totalPages: number; + + constructor(totalRecords: number, pageOptions: PageOptionsDto) { + this.limit = pageOptions.limit; + this.currentPage = pageOptions.page; + this.nextPage = + this.currentPage < this.totalPages ? this.currentPage + 1 : undefined; + this.previousPage = + this.currentPage > 1 && this.currentPage - 1 < this.totalPages + ? this.currentPage - 1 + : undefined; + this.totalRecords = totalRecords; + this.totalPages = + this.limit > 0 ? Math.ceil(totalRecords / pageOptions.limit) : 0; + } +} diff --git a/packages/api/src/dto/page-pagination/paginated.dto.ts b/packages/api/src/dto/page-pagination/paginated.dto.ts new file mode 100644 index 0000000..da53878 --- /dev/null +++ b/packages/api/src/dto/page-pagination/paginated.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { PagePaginationDto } from './page-pagination.dto'; + +export class PagePaginatedDto { + @ApiProperty({ type: [Object] }) + @Expose() + readonly data: TData[]; + + @ApiProperty() + @Expose() + pagination: PagePaginationDto; + + constructor(data: TData[], meta: PagePaginationDto) { + this.data = data; + this.pagination = meta; + } +} diff --git a/packages/api/src/utils/cursor-pagination.ts b/packages/api/src/utils/cursor-pagination.ts new file mode 100644 index 0000000..f786398 --- /dev/null +++ b/packages/api/src/utils/cursor-pagination.ts @@ -0,0 +1,448 @@ +import { + Brackets, + ObjectType, + SelectQueryBuilder, + WhereExpressionBuilder, + type ObjectLiteral, +} from 'typeorm'; + +export function buildPaginator( + options: PaginationOptions, +): Paginator { + const { + entity, + query = {}, + alias = entity.name.toLowerCase(), + paginationKeys = ['id' as any], + } = options; + + const paginator = new Paginator(entity, paginationKeys); + + paginator.setAlias(alias); + + if (query.afterCursor) { + paginator.setAfterCursor(query.afterCursor); + } + + if (query.beforeCursor) { + paginator.setBeforeCursor(query.beforeCursor); + } + + if (query.limit) { + paginator.setLimit(query.limit); + } + + if (query.order) { + paginator.setOrder(query.order as Order); + } + + return paginator; +} + +export default class Paginator { + private afterCursor: string | null = null; + + private beforeCursor: string | null = null; + + private nextAfterCursor: string | null = null; + + private nextBeforeCursor: string | null = null; + + private alias: string | null = null; + + private limit = 100; + + private order: Order = Order.DESC; + + public constructor( + private entity: ObjectType, + private paginationKeys: Extract[], + ) { + this.alias = pascalToUnderscore(this.entity.name); + } + + public setAlias(alias: string): void { + this.alias = alias; + } + + public setAfterCursor(cursor: string): void { + this.afterCursor = cursor; + } + + public setBeforeCursor(cursor: string): void { + this.beforeCursor = cursor; + } + + public setLimit(limit: number): void { + this.limit = limit; + } + + public setOrder(order: Order): void { + this.order = order; + } + + public async paginate( + builder: SelectQueryBuilder, + ): Promise> { + const entities = await this.appendPagingQuery(builder).getMany(); + const hasMore = entities.length > this.limit; + + if (hasMore) { + entities.splice(entities.length - 1, 1); + } + + if (entities.length === 0) { + return this.toPagingResult(entities); + } + + if (!this.hasAfterCursor() && this.hasBeforeCursor()) { + entities.reverse(); + } + + if (this.hasBeforeCursor() || hasMore) { + this.nextAfterCursor = this.encode(entities[entities.length - 1]); + } + + if (this.hasAfterCursor() || (hasMore && this.hasBeforeCursor())) { + this.nextBeforeCursor = this.encode(entities[0]); + } + + return this.toPagingResult(entities); + } + + private getCursor(): Cursor { + return { + afterCursor: this.nextAfterCursor, + beforeCursor: this.nextBeforeCursor, + }; + } + + private appendPagingQuery( + builder: SelectQueryBuilder, + ): SelectQueryBuilder { + const cursors: CursorParam = {}; + const clonedBuilder = new SelectQueryBuilder(builder); + + if (this.hasAfterCursor()) { + Object.assign(cursors, this.decode(this.afterCursor as string)); + } else if (this.hasBeforeCursor()) { + Object.assign(cursors, this.decode(this.beforeCursor as string)); + } + + if (Object.keys(cursors).length > 0) { + clonedBuilder.andWhere( + new Brackets((where) => this.buildCursorQuery(where, cursors)), + ); + } + + clonedBuilder.take(this.limit + 1); + + const paginationKeyOrders = this.buildOrder(); + Object.keys(paginationKeyOrders).forEach((orderKey) => { + clonedBuilder.addOrderBy( + orderKey, + paginationKeyOrders[orderKey] === 'ASC' ? 'ASC' : 'DESC', + ); + }); + + return clonedBuilder; + } + + /** + * Original method from the library + * @param where WhereExpressionBuilder + * @param cursors CursorParam + */ + private _buildCursorQuery( + where: WhereExpressionBuilder, + cursors: CursorParam, + ): void { + const operator = this.getOperator(); + const params: CursorParam = {}; + let query = ''; + this.paginationKeys.forEach((key) => { + params[key] = cursors[key]; + where.orWhere(`${query}${this.alias}.${key} ${operator} :${key}`, params); + query = `${query}${this.alias}.${key} = :${key} AND `; + }); + } + + /** + * Only support PostgreSQL + * @param where WhereExpressionBuilder + * @param cursors CursorParam + */ + private async buildCursorQuery( + where: WhereExpressionBuilder, + cursors: CursorParam, + ) { + const operator = this.getOperator(); + const params: CursorParam = {}; + let query = ''; + for (const key of this.paginationKeys) { + params[key] = cursors[key]; + const type = this.getEntityPropertyType(key); + if (type === 'date') { + where.orWhere( + `${query}date_trunc('milliseconds', ${this.alias}.${key}) ${operator} :${key}`, + params, + ); + query = `${query}date_trunc('milliseconds', ${this.alias}.${key}) = :${key} AND `; + } else { + where.orWhere( + `${query}${this.alias}.${key} ${operator} :${key}`, + params, + ); + query = `${query}${this.alias}.${key} = :${key} AND `; + } + } + } + + private getOperator(): string { + if (this.hasAfterCursor()) { + return this.order === Order.ASC ? '>' : '<'; + } + + if (this.hasBeforeCursor()) { + return this.order === Order.ASC ? '<' : '>'; + } + + return '='; + } + + private buildOrder(): OrderByCondition { + let { order } = this; + + if (!this.hasAfterCursor() && this.hasBeforeCursor()) { + order = this.flipOrder(order); + } + + const orderByCondition: OrderByCondition = {}; + this.paginationKeys.forEach((key) => { + orderByCondition[`${this.alias}.${key}`] = order; + }); + + return orderByCondition; + } + + private hasAfterCursor(): boolean { + return this.afterCursor !== null; + } + + private hasBeforeCursor(): boolean { + return this.beforeCursor !== null; + } + + private encode(entity: Entity): string { + const payload = this.paginationKeys + .map((key) => { + const type = this.getEntityPropertyType(key); + const value = encodeByType(type, entity[key]); + return `${key}:${value}`; + }) + .join(','); + + return btoa(payload); + } + + private decode(cursor: string): CursorParam { + const cursors: CursorParam = {}; + const columns = atob(cursor).split(','); + columns.forEach((column) => { + const [key, raw] = column.split(':'); + const type = this.getEntityPropertyType(key); + const value = decodeByType(type, raw); + cursors[key] = value; + }); + + return cursors; + } + + private getEntityPropertyType(key: string): string { + return Reflect.getMetadata( + 'design:type', + this.entity.prototype, + key, + ).name.toLowerCase(); + } + + private flipOrder(order: Order): Order { + return order === Order.ASC ? Order.DESC : Order.ASC; + } + + private toPagingResult(entities: Entity[]): PagingResult { + return { + data: entities, + cursor: this.getCursor(), + }; + } +} + +export interface PagingQuery { + afterCursor?: string; + beforeCursor?: string; + limit?: number; + order?: Order | 'ASC' | 'DESC'; +} + +export interface PaginationOptions { + entity: ObjectType; + alias?: string; + query?: PagingQuery; + paginationKeys?: Extract[]; +} + +export type EscapeFn = (name: string) => string; + +export interface CursorParam { + [key: string]: any; +} + +export interface Cursor { + beforeCursor: string | null; + afterCursor: string | null; +} + +export interface PagingResult { + data: Entity[]; + cursor: Cursor; +} + +enum Order { + ASC = 'ASC', + DESC = 'DESC', +} + +export type OrderByCondition = { + [columnName: string]: + | ('ASC' | 'DESC') + | { + order: 'ASC' | 'DESC'; + nulls?: 'NULLS FIRST' | 'NULLS LAST'; + }; +}; + +function atob(value: string): string { + return Buffer.from(value, 'base64').toString(); +} + +function btoa(value: string): string { + return Buffer.from(value).toString('base64'); +} + +function encodeByType(type: string, value: any): string | null { + if (value === null) return null; + + switch (type) { + case 'date': { + return (value as Date).getTime().toString(); + } + case 'number': { + return `${value}`; + } + case 'string': { + return encodeURIComponent(value); + } + case 'object': { + /** + * if reflection type is Object, check whether an object is a date. + * see: https://github.com/rbuckton/reflect-metadata/issues/84 + */ + if (typeof value.getTime === 'function') { + return (value as Date).getTime().toString(); + } + + /** + * Support for branded id's having the following structure + * + * interface Uuid extends String { + * _uuidBrand: string; + * } + * + * or + * + * declare const __brand: unique symbol; + * type Brand = { [__brand]: B }; + * type Branded = T & Brand; + * type Uuid = Branded; + * + * the above interface or type will support toString() method + */ + if (typeof value.toString === 'function') { + return value.toString(); + } + + break; + } + default: + break; + } + + throw new Error(`unknown type in cursor: [${type}]${value}`); +} + +function decodeByType(type: string, value: string): string | number | Date { + switch (type) { + case 'object': { + /** + * Support for branded id's having the following structure + * + * interface Uuid extends String { + * _uuidBrand: string; + * } + * + * or + * + * declare const __brand: unique symbol; + * type Brand = { [__brand]: B }; + * type Branded = T & Brand; + * type Uuid = Branded; + * + * the above interface or type will support toString() method + */ + if (typeof value.toString === 'function') { + return value.toString(); + } + + break; + } + case 'date': { + const timestamp = parseInt(value, 10); + + if (Number.isNaN(timestamp)) { + throw new Error('date column in cursor should be a valid timestamp'); + } + + return new Date(timestamp); + } + + case 'number': { + const num = parseFloat(value); + + if (Number.isNaN(num)) { + throw new Error('number column in cursor should be a valid number'); + } + + return num; + } + + case 'string': { + return decodeURIComponent(value); + } + + default: { + throw new Error(`unknown type in cursor: [${type}]${value}`); + } + } +} + +function pascalToUnderscore(str: string): string { + return camelOrPascalToUnderscore(str); +} + +function camelOrPascalToUnderscore(str: string): string { + return str + .split(/(?=[A-Z])/) + .join('_') + .toLowerCase(); +} diff --git a/packages/api/src/utils/index.ts b/packages/api/src/utils/index.ts index cab191e..274a833 100644 --- a/packages/api/src/utils/index.ts +++ b/packages/api/src/utils/index.ts @@ -1 +1,3 @@ +export * from './cursor-pagination'; export * from './exception-handler.util'; +export * from './page-pagination'; diff --git a/packages/api/src/utils/page-pagination.ts b/packages/api/src/utils/page-pagination.ts new file mode 100644 index 0000000..c6adb55 --- /dev/null +++ b/packages/api/src/utils/page-pagination.ts @@ -0,0 +1,28 @@ +import { SelectQueryBuilder } from 'typeorm'; +import { PageOptionsDto } from '../dto/page-pagination/page-options.dto'; +import { PagePaginationDto } from '../dto/page-pagination/page-pagination.dto'; + +export async function paginate( + builder: SelectQueryBuilder, + pageOptionsDto: PageOptionsDto, + options?: Partial<{ + skipCount: boolean; + takeAll: boolean; + }>, +): Promise<[T[], PagePaginationDto]> { + if (!options?.takeAll) { + builder.skip(pageOptionsDto.offset).take(pageOptionsDto.limit); + } + + const entities: T[] = await builder.getMany(); + + let count = -1; + + if (!options?.skipCount) { + count = await builder.getCount(); + } + + const metaDto = new PagePaginationDto(count, pageOptionsDto); + + return [entities, metaDto]; +}