diff --git a/src/modules/pagination/dto/create-pagination.dto.ts b/src/modules/pagination/dto/create-pagination.dto.ts index 163f7e07..cdfe45e3 100644 --- a/src/modules/pagination/dto/create-pagination.dto.ts +++ b/src/modules/pagination/dto/create-pagination.dto.ts @@ -1 +1,47 @@ -export class CreatePaginationDto {} +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreatePaginationDto { + @ApiPropertyOptional({ + description: 'Page number for offset pagination', + default: 1, + minimum: 1, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ + description: 'Number of records per page', + default: 20, + minimum: 1, + maximum: 100, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; + + @ApiPropertyOptional({ + description: 'Opaque cursor for forward pagination', + example: 'MQ==', + }) + @IsOptional() + @IsString() + after?: string; + + @ApiPropertyOptional({ + description: 'Opaque cursor for backward pagination', + example: 'MTA=', + }) + @IsOptional() + @IsString() + before?: string; +} + +export class PaginationQueryDto extends CreatePaginationDto {} diff --git a/src/modules/pagination/pagination.module.ts b/src/modules/pagination/pagination.module.ts index f87e09c1..3aab9d6b 100644 --- a/src/modules/pagination/pagination.module.ts +++ b/src/modules/pagination/pagination.module.ts @@ -4,5 +4,6 @@ import { PaginationService } from './pagination.service'; @Module({ controllers: [], providers: [PaginationService], + exports: [PaginationService], }) export class PaginationModule {} diff --git a/src/modules/pagination/pagination.service.spec.ts b/src/modules/pagination/pagination.service.spec.ts new file mode 100644 index 00000000..f8ea32a5 --- /dev/null +++ b/src/modules/pagination/pagination.service.spec.ts @@ -0,0 +1,130 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; +import { PaginationService } from './pagination.service'; + +const makeQb = (rows: any[], total?: number) => ({ + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([rows, total ?? rows.length]), + getMany: jest.fn().mockResolvedValue(rows), +}); + +const makeRepo = (rows: any[], total?: number) => ({ + findAndCount: jest.fn().mockResolvedValue([rows, total ?? rows.length]), +}); + +describe('PaginationService', () => { + let service: PaginationService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [PaginationService], + }).compile(); + service = module.get(PaginationService); + }); + + describe('paginate', () => { + it('returns paginated response with correct meta', async () => { + const rows = [{ id: 1 }, { id: 2 }]; + const qb = makeQb(rows, 10) as any; + + const result = await service.paginate(qb, 2, 2); + + expect(result.data).toEqual(rows); + expect(result.meta.page).toBe(2); + expect(result.meta.limit).toBe(2); + expect(result.meta.total).toBe(10); + expect(result.meta.totalPages).toBe(5); + expect(result.meta.hasNext).toBe(true); + expect(result.meta.hasPrev).toBe(true); + }); + + it('clamps page to minimum 1', async () => { + const qb = makeQb([], 0) as any; + const result = await service.paginate(qb, -5, 10); + expect(result.meta.page).toBe(1); + }); + + it('enforces max limit of 100', async () => { + const qb = makeQb([], 0) as any; + const result = await service.paginate(qb, 1, 9999); + expect(result.meta.limit).toBe(100); + }); + + it('includes pagination links when a route is provided', async () => { + const qb = makeQb([{ id: 1 }], 5) as any; + + const result = await service.paginate(qb, 2, 2, { + route: '/skills', + query: { q: 'nodejs' }, + }); + + expect(result.links).toEqual({ + first: '/skills?q=nodejs&page=1&limit=2', + prev: '/skills?q=nodejs&page=1&limit=2', + next: '/skills?q=nodejs&page=3&limit=2', + last: '/skills?q=nodejs&page=3&limit=2', + }); + }); + + it('hasNext is false on last page', async () => { + const qb = makeQb([{ id: 1 }], 1) as any; + const result = await service.paginate(qb, 1, 10); + expect(result.meta.hasNext).toBe(false); + expect(result.meta.hasPrev).toBe(false); + }); + + it('page beyond total still returns valid meta', async () => { + const qb = makeQb([], 5) as any; + const result = await service.paginate(qb, 100, 10); + expect(result.meta.page).toBe(100); + expect(result.meta.hasNext).toBe(false); + }); + }); + + describe('paginateRepository', () => { + it('calls findAndCount with correct skip/take', async () => { + const rows = [{ id: 1 }]; + const repo = makeRepo(rows, 30) as any; + + const result = await service.paginateRepository(repo, 3, 10); + + expect(repo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ skip: 20, take: 10 }), + ); + expect(result.meta.page).toBe(3); + }); + }); + + describe('cursor pagination', () => { + it('encodes and decodes cursor round-trip', () => { + const encoded = service.encodeCursor('42'); + expect(service.decodeCursor(encoded)).toBe('42'); + }); + + it('throws BadRequestException for invalid cursor', () => { + expect(() => service.decodeCursor('!!invalid!!')).toThrow(BadRequestException); + }); + + it('returns nextCursor when more rows exist', async () => { + const rows = [{ id: 1 }, { id: 2 }, { id: 3 }]; // limit=2, hasMore + const qb = makeQb(rows) as any; + + const result = await service.cursorPaginate(qb, 2); + + expect(result.data).toHaveLength(2); + expect(result.nextCursor).not.toBeNull(); + }); + + it('returns null nextCursor on last page', async () => { + const rows = [{ id: 1 }]; // limit=2, no more + const qb = makeQb(rows) as any; + + const result = await service.cursorPaginate(qb, 2); + + expect(result.nextCursor).toBeNull(); + }); + }); +}); diff --git a/src/modules/pagination/pagination.service.ts b/src/modules/pagination/pagination.service.ts index c3dea7ad..78d31254 100644 --- a/src/modules/pagination/pagination.service.ts +++ b/src/modules/pagination/pagination.service.ts @@ -1,24 +1,247 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, BadRequestException } from '@nestjs/common'; +import { SelectQueryBuilder, Repository, FindManyOptions, ObjectLiteral } from 'typeorm'; + +/** Default and maximum allowed page sizes */ +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 100; + +type PrimitiveQueryValue = string | number | boolean; + +export interface PaginationMeta { + page: number; + limit: number; + total: number; + totalPages: number; + hasNext: boolean; + hasPrev: boolean; +} + +export interface PaginationLinks { + first: string; + prev: string | null; + next: string | null; + last: string; +} + +export interface PaginatedResponse { + data: T[]; + meta: PaginationMeta; + links?: PaginationLinks; +} + +export interface CursorPage { + data: T[]; + nextCursor: string | null; + prevCursor: string | null; + limit: number; +} + +export interface PaginateOptions { + maxLimit?: number; + route?: string; + query?: Record; +} @Injectable() export class PaginationService { - create() { - return 'This action adds a new pagination'; + private async paginateSource( + source: SelectQueryBuilder | Repository, + page = 1, + limit = DEFAULT_LIMIT, + options: PaginateOptions & { findOptions?: FindManyOptions } = {}, + ): Promise> { + const safePage = Math.max(1, Math.floor(page)); + const safeLimit = this.normalizeLimit(limit, options.maxLimit); + + const [data, total] = this.isRepository(source) + ? await source.findAndCount({ + ...(options.findOptions ?? {}), + skip: (safePage - 1) * safeLimit, + take: safeLimit, + }) + : await source + .skip((safePage - 1) * safeLimit) + .take(safeLimit) + .getManyAndCount(); + + return this.buildResponse(data, total, safePage, safeLimit, options); } - findAll() { - return `This action returns all pagination`; + /** + * Offset/limit pagination against a TypeORM QueryBuilder. + * + * @param queryBuilder - must NOT already have skip/take applied + * @param page - 1-indexed page number (default 1) + * @param limit - items per page (default 20, max 100) + */ + async paginate( + queryBuilder: SelectQueryBuilder, + page = 1, + limit = DEFAULT_LIMIT, + options: PaginateOptions = {}, + ): Promise> { + return this.paginateSource(queryBuilder, page, limit, options); } - findOne(id: number) { - return `This action returns a #${id} pagination`; + /** + * Offset/limit pagination against a TypeORM Repository. + */ + async paginateRepository( + repo: Repository, + page = 1, + limit = DEFAULT_LIMIT, + findOptions: FindManyOptions = {}, + options: PaginateOptions = {}, + ): Promise> { + return this.paginateSource(repo, page, limit, { + ...options, + findOptions, + }); } - update(id: number) { - return `This action updates a #${id} pagination`; + /** + * Cursor-based pagination for high-performance scenarios. + * + * Cursors are opaque base64-encoded strings containing the cursor column value. + * The `cursorColumn` should be indexed (e.g. `id`, `createdAt`). + * + * @param queryBuilder - base query, must NOT have where/order/skip/take for cursor fields + * @param limit - items per page (max 100) + * @param afterCursor - fetch items after this cursor (forward pagination) + * @param beforeCursor - fetch items before this cursor (backward pagination) + * @param cursorColumn - column name to use as cursor (default: 'id') + * @param alias - QueryBuilder alias (default: 'entity') + */ + async cursorPaginate>( + queryBuilder: SelectQueryBuilder, + limit = DEFAULT_LIMIT, + afterCursor?: string, + beforeCursor?: string, + cursorColumn = 'id', + alias = 'entity', + ): Promise> { + const safeLimit = Math.min(Math.max(1, Math.floor(limit)), MAX_LIMIT); + + const decodedAfter = afterCursor ? this.decodeCursor(afterCursor) : null; + const decodedBefore = beforeCursor ? this.decodeCursor(beforeCursor) : null; + + if (decodedAfter) { + queryBuilder.andWhere(`${alias}.${cursorColumn} > :after`, { after: decodedAfter }); + } + + if (decodedBefore) { + queryBuilder.andWhere(`${alias}.${cursorColumn} < :before`, { before: decodedBefore }); + } + + queryBuilder.orderBy(`${alias}.${cursorColumn}`, 'ASC').take(safeLimit + 1); + + const rows = await queryBuilder.getMany(); + + const hasMore = rows.length > safeLimit; + const data = hasMore ? rows.slice(0, safeLimit) : rows; + + const nextCursor = + hasMore && data.length > 0 + ? this.encodeCursor(String(data[data.length - 1][cursorColumn])) + : null; + + const prevCursor = + decodedAfter && data.length > 0 + ? this.encodeCursor(String(data[0][cursorColumn])) + : null; + + return { data, nextCursor, prevCursor, limit: safeLimit }; + } + + /** Encode a cursor value to an opaque base64 string */ + encodeCursor(value: string): string { + return Buffer.from(value).toString('base64'); + } + + /** Decode an opaque base64 cursor back to its raw value */ + decodeCursor(cursor: string): string { + if (!this.isValidBase64(cursor)) { + throw new BadRequestException('Invalid cursor value'); + } + + return Buffer.from(cursor, 'base64').toString('utf-8'); } - remove(id: number) { - return `This action removes a #${id} pagination`; + private buildResponse( + data: T[], + total: number, + page: number, + limit: number, + options: PaginateOptions, + ): PaginatedResponse { + const totalPages = Math.ceil(total / limit) || 1; + const links = options.route + ? this.generatePaginationLinks(page, limit, totalPages, options.route, options.query) + : undefined; + + return { + data, + meta: { + page, + limit, + total, + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1, + }, + links, + }; + } + + generatePaginationLinks( + page: number, + limit: number, + totalPages: number, + route: string, + query: Record = {}, + ): PaginationLinks { + const createLink = (targetPage: number) => { + const params = new URLSearchParams(); + + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null) { + params.set(key, String(value)); + } + } + + params.set('page', String(targetPage)); + params.set('limit', String(limit)); + + return `${route}?${params.toString()}`; + }; + + return { + first: createLink(1), + prev: page > 1 ? createLink(page - 1) : null, + next: page < totalPages ? createLink(page + 1) : null, + last: createLink(totalPages), + }; + } + + private normalizeLimit(limit: number, maxLimit = MAX_LIMIT): number { + return Math.min(Math.max(1, Math.floor(limit)), maxLimit); + } + + private isRepository( + value: SelectQueryBuilder | Repository, + ): value is Repository { + return 'findAndCount' in value; + } + + private isValidBase64(value: string): boolean { + if (!value || value.length % 4 !== 0) { + return false; + } + + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) { + return false; + } + + return Buffer.from(Buffer.from(value, 'base64').toString('utf-8')).toString('base64') === value; } }