From 90347975767256dd4dee4828b232eb124d21db48 Mon Sep 17 00:00:00 2001 From: xaxxoo Date: Mon, 27 Apr 2026 18:10:19 +0100 Subject: [PATCH 1/2] feat: pagination utility service with offset and cursor-based pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement PaginationService.paginate(queryBuilder, page, limit) with skip/take applied automatically; max limit enforced at 100 - Implement paginateRepository for Repository-based queries - Implement cursorPaginate for high-performance cursor-based pagination (opaque base64 cursors, afterCursor/beforeCursor params) - PaginatedResponse includes data + meta (page, limit, total, totalPages, hasNext, hasPrev) - Edge cases handled: page clamped to ≥1, limit clamped to [1,100], page beyond total, empty result sets - Unit tests: 10 cases covering all methods, edge cases, and cursor encode/decode round-trip Closes #384 Co-Authored-By: Claude Sonnet 4.6 --- .../pagination/pagination.service.spec.ts | 115 ++++++++++++ src/modules/pagination/pagination.service.ts | 167 ++++++++++++++++-- 2 files changed, 271 insertions(+), 11 deletions(-) create mode 100644 src/modules/pagination/pagination.service.spec.ts diff --git a/src/modules/pagination/pagination.service.spec.ts b/src/modules/pagination/pagination.service.spec.ts new file mode 100644 index 00000000..caf4db07 --- /dev/null +++ b/src/modules/pagination/pagination.service.spec.ts @@ -0,0 +1,115 @@ +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('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', () => { + // non-base64 input that would fail to decode meaningfully + 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..942ec959 100644 --- a/src/modules/pagination/pagination.service.ts +++ b/src/modules/pagination/pagination.service.ts @@ -1,24 +1,169 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, BadRequestException } from '@nestjs/common'; +import { SelectQueryBuilder, Repository, FindManyOptions } from 'typeorm'; + +/** Default and maximum allowed page sizes */ +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 100; + +export interface PaginationMeta { + page: number; + limit: number; + total: number; + totalPages: number; + hasNext: boolean; + hasPrev: boolean; +} + +export interface PaginatedResponse { + data: T[]; + meta: PaginationMeta; +} + +export interface CursorPage { + data: T[]; + nextCursor: string | null; + prevCursor: string | null; + limit: number; +} + +export interface PaginateOptions { + /** Override the default column used for ordering (default: 'id') */ + orderBy?: string; + orderDirection?: 'ASC' | 'DESC'; +} @Injectable() export class PaginationService { - create() { - return 'This action adds a new 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> { + const safePage = Math.max(1, Math.floor(page)); + const safeLimit = Math.min(Math.max(1, Math.floor(limit)), MAX_LIMIT); + + const [data, total] = await queryBuilder + .skip((safePage - 1) * safeLimit) + .take(safeLimit) + .getManyAndCount(); + + return this.buildResponse(data, total, safePage, safeLimit); + } + + /** + * Offset/limit pagination against a TypeORM Repository. + */ + async paginateRepository( + repo: Repository, + page = 1, + limit = DEFAULT_LIMIT, + findOptions: FindManyOptions = {}, + ): Promise> { + const safePage = Math.max(1, Math.floor(page)); + const safeLimit = Math.min(Math.max(1, Math.floor(limit)), MAX_LIMIT); + + const [data, total] = await repo.findAndCount({ + ...findOptions, + skip: (safePage - 1) * safeLimit, + take: safeLimit, + }); + + return this.buildResponse(data, total, safePage, safeLimit); } - findAll() { - return `This action returns all 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 }; } - findOne(id: number) { - return `This action returns a #${id} pagination`; + /** Encode a cursor value to an opaque base64 string */ + encodeCursor(value: string): string { + return Buffer.from(value).toString('base64'); } - update(id: number) { - return `This action updates a #${id} pagination`; + /** Decode an opaque base64 cursor back to its raw value */ + decodeCursor(cursor: string): string { + try { + return Buffer.from(cursor, 'base64').toString('utf-8'); + } catch { + throw new BadRequestException('Invalid cursor value'); + } } - remove(id: number) { - return `This action removes a #${id} pagination`; + private buildResponse( + data: T[], + total: number, + page: number, + limit: number, + ): PaginatedResponse { + const totalPages = Math.ceil(total / limit) || 1; + return { + data, + meta: { + page, + limit, + total, + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1, + }, + }; } } From 3d942c90d0d0a6cbfc671715249a6c7a163a5573 Mon Sep 17 00:00:00 2001 From: xaxxoo Date: Tue, 28 Apr 2026 09:44:12 +0100 Subject: [PATCH 2/2] feat: finish pagination utility service acceptance --- .../pagination/dto/create-pagination.dto.ts | 48 ++++++- src/modules/pagination/pagination.module.ts | 1 + .../pagination/pagination.service.spec.ts | 17 ++- src/modules/pagination/pagination.service.ts | 132 ++++++++++++++---- 4 files changed, 169 insertions(+), 29 deletions(-) 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 index caf4db07..f8ea32a5 100644 --- a/src/modules/pagination/pagination.service.spec.ts +++ b/src/modules/pagination/pagination.service.spec.ts @@ -53,6 +53,22 @@ describe('PaginationService', () => { 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); @@ -89,7 +105,6 @@ describe('PaginationService', () => { }); it('throws BadRequestException for invalid cursor', () => { - // non-base64 input that would fail to decode meaningfully expect(() => service.decodeCursor('!!invalid!!')).toThrow(BadRequestException); }); diff --git a/src/modules/pagination/pagination.service.ts b/src/modules/pagination/pagination.service.ts index 942ec959..78d31254 100644 --- a/src/modules/pagination/pagination.service.ts +++ b/src/modules/pagination/pagination.service.ts @@ -1,10 +1,12 @@ import { Injectable, BadRequestException } from '@nestjs/common'; -import { SelectQueryBuilder, Repository, FindManyOptions } from 'typeorm'; +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; @@ -14,9 +16,17 @@ export interface PaginationMeta { 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 { @@ -27,13 +37,36 @@ export interface CursorPage { } export interface PaginateOptions { - /** Override the default column used for ordering (default: 'id') */ - orderBy?: string; - orderDirection?: 'ASC' | 'DESC'; + maxLimit?: number; + route?: string; + query?: Record; } @Injectable() export class PaginationService { + 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); + } + /** * Offset/limit pagination against a TypeORM QueryBuilder. * @@ -41,42 +74,29 @@ export class PaginationService { * @param page - 1-indexed page number (default 1) * @param limit - items per page (default 20, max 100) */ - async paginate( + async paginate( queryBuilder: SelectQueryBuilder, page = 1, limit = DEFAULT_LIMIT, options: PaginateOptions = {}, ): Promise> { - const safePage = Math.max(1, Math.floor(page)); - const safeLimit = Math.min(Math.max(1, Math.floor(limit)), MAX_LIMIT); - - const [data, total] = await queryBuilder - .skip((safePage - 1) * safeLimit) - .take(safeLimit) - .getManyAndCount(); - - return this.buildResponse(data, total, safePage, safeLimit); + return this.paginateSource(queryBuilder, page, limit, options); } /** * Offset/limit pagination against a TypeORM Repository. */ - async paginateRepository( + async paginateRepository( repo: Repository, page = 1, limit = DEFAULT_LIMIT, findOptions: FindManyOptions = {}, + options: PaginateOptions = {}, ): Promise> { - const safePage = Math.max(1, Math.floor(page)); - const safeLimit = Math.min(Math.max(1, Math.floor(limit)), MAX_LIMIT); - - const [data, total] = await repo.findAndCount({ - ...findOptions, - skip: (safePage - 1) * safeLimit, - take: safeLimit, + return this.paginateSource(repo, page, limit, { + ...options, + findOptions, }); - - return this.buildResponse(data, total, safePage, safeLimit); } /** @@ -140,11 +160,11 @@ export class PaginationService { /** Decode an opaque base64 cursor back to its raw value */ decodeCursor(cursor: string): string { - try { - return Buffer.from(cursor, 'base64').toString('utf-8'); - } catch { + if (!this.isValidBase64(cursor)) { throw new BadRequestException('Invalid cursor value'); } + + return Buffer.from(cursor, 'base64').toString('utf-8'); } private buildResponse( @@ -152,8 +172,13 @@ export class PaginationService { 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: { @@ -164,6 +189,59 @@ export class PaginationService { 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; } }