Pagination et Filtres Dynamiques avec NestJS et Prisma : Guide Complet
Mouhamed Lamotte

Introduction
Dans le cadre du développement de Nexcom, une plateforme multi-services, j'ai été confronté à des défis récurrents de pagination et de filtrage à travers différents microservices : facturation (nexcom-invoices), gestion multi-tenant (nexcom-core), ressources humaines (nexcom-rh), et bien d'autres.
Chaque service nécessitait des fonctionnalités de pagination et de filtrage sophistiquées, mais Prisma, bien qu'excellent, présente certaines limitations dans la création de filtres dynamiques complexes. Le code se répétait, la maintenance devenait fastidieuse, et l'ajout de nouveaux filtres nécessitait des modifications dans chaque service.
Le besoin était clair : créer un système de pagination et de filtres dynamiques réutilisable, configurable et type-safe qui pourrait être déployé across tous les services de l'écosystème Nexcom.
Dans cet article, je partage la solution que j'ai développée - un système qui transforme la complexité des filtres Prisma en une configuration déclarative simple, tout en maintenant les performances et la flexibilité nécessaires pour une plateforme d'entreprise.
Vue d'ensemble de l'architecture
Notre solution se compose de plusieurs couches :
- PaginationDto : DTO de base pour la pagination
- PaginationService : Service gérant la logique de pagination
- FilterService : Service pour les filtres dynamiques
- DTOs spécialisés : Extensions du PaginationDto pour des entités spécifiques
Commençons par créer un DTO de base pour la pagination :
export class PaginationDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 10;
@IsOptional()
@IsString()
sortBy?: string;
@IsOptional()
@IsEnum(['asc', 'desc'])
sortOrder?: 'asc' | 'desc' = 'desc';
@IsOptional()
@IsString()
search?: string;
}
Ce DTO définit les paramètres de base pour :
- page : Le numéro de page (par défaut 1)
- limit : Le nombre d'éléments par page (max 100)
- sortBy : Le champ de tri
- sortOrder : L'ordre de tri (asc/desc)
- search : Terme de recherche global
Le service de pagination fournit une méthode générique réutilisable :
@Injectable()
export class PaginationService {
async paginate<T>(
model: any,
options: {
page: number;
limit: number;
where?: any;
include?: any;
orderBy?: any;
select?: any;
},
) {
const { page, limit, where, include, orderBy, select } = options;
const skip = (page - 1) * limit;
const [data, totalItems] = await Promise.all([
model.findMany({
skip,
take: limit,
where,
include,
orderBy,
select,
}),
model.count({ where }),
]);
const totalPages = Math.ceil(totalItems / limit);
return {
data,
pagination: {
currentPage: page,
totalPages,
totalItems,
itemsPerPage: limit,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
};
}
buildOrderBy(sortBy?: string, sortOrder: 'asc' | 'desc' = 'desc') {
if (!sortBy) return { createdAt: sortOrder };
// Support pour les relations (ex: "client.name")
if (sortBy.includes('.')) {
const [relation, field] = sortBy.split('.');
return { [relation]: { [field]: sortOrder } };
}
return { [sortBy]: sortOrder };
}
}
Points clés :
-
Performance : Utilisation de
Promise.all()pour exécuter les requêtes en parallèle - Flexibilité : Support des relations dans le tri
- Métadonnées complètes : Retour d'informations de pagination détaillées
Le véritable pouvoir réside dans le service de filtres dynamiques. Ce service centralise toute la logique de construction des filtres Prisma :
@Injectable()
export class FilterService {
buildDynamicFilters<T>(dto: T, config: FilterConfig): any {
let where = { ...config.baseWhere };
// 1. Filtres simples
if (config.allowedFilters?.length > 0) {
where = this.buildSimpleFilters(dto, config.allowedFilters, where);
}
// 2. Filtres de dates
if (config.dateFields?.length > 0) {
where = this.buildDateFilters(dto, config.dateFields, where);
}
// 3. Filtres numériques
if (config.numericFilters?.length > 0) {
where = this.buildNumericFilters(dto, config.numericFilters, where);
}
// 4. Filtres de relations
if (config.relationFilters?.length > 0) {
where = this.buildRelationFilters(dto, config.relationFilters, where);
}
// 5. Filtres pour les arrays
if (config.arrayFilters?.length > 0) {
where = this.buildArrayFilters(dto, config.arrayFilters, where);
}
// 6. Filtres personnalisés
if (config.customFilters?.length > 0) {
where = this.buildCustomFilters(dto, config.customFilters, where);
}
// 7. Recherche
if (config.searchConfig) {
where = this.buildSearchFilter(dto, config.searchConfig, where);
}
return where;
}
}
3.1 Les Méthodes Spécialisées
Chaque type de filtre dispose de sa propre méthode optimisée :
Filtres Simples (Égalité)
private buildSimpleFilters<T>(dto: T, allowedFilters: string[], where: any): any {
allowedFilters.forEach((filter) => {
const value = dto[filter];
if (this.isValidValue(value)) {
// Support pour les valeurs multiples séparées par virgule
if (typeof value === 'string' && value.includes(',')) {
where[filter] = { in: value.split(',').map((v) => v.trim()) };
} else {
where[filter] = value;
}
}
});
return where;
}
Filtres de Dates
private buildDateFilters<T>(dto: T, dateFields: DateFieldConfig[], where: any): any {
dateFields.forEach(({ field, fromKey, toKey, exactKey }) => {
const dateFilter: any = {};
// Date exacte
if (exactKey && dto[exactKey]) {
where[field] = new Date(dto[exactKey]);
return;
}
// Plage de dates
if (dto[fromKey]) {
dateFilter.gte = new Date(dto[fromKey]);
}
if (dto[toKey]) {
// Ajouter 23:59:59 pour inclure toute la journée
const endDate = new Date(dto[toKey]);
endDate.setHours(23, 59, 59, 999);
dateFilter.lte = endDate;
}
if (Object.keys(dateFilter).length > 0) {
where[field] = dateFilter;
}
});
return where;
}
Filtres Numériques
private buildNumericFilters<T>(dto: T, numericFields: NumericFilterConfig[], where: any): any {
numericFields.forEach(({ field, minKey, maxKey, exactKey }) => {
const numericFilter: any = {};
// Valeur exacte
if (exactKey && dto[exactKey] !== undefined) {
where[field] = Number(dto[exactKey]);
return;
}
// Plage numérique
if (minKey && dto[minKey] !== undefined) {
numericFilter.gte = Number(dto[minKey]);
}
if (maxKey && dto[maxKey] !== undefined) {
numericFilter.lte = Number(dto[maxKey]);
}
if (Object.keys(numericFilter).length > 0) {
where[field] = numericFilter;
}
});
return where;
}
Filtres de Relations
private buildRelationFilters<T>(dto: T, relationFilters: RelationFilterConfig[], where: any): any {
relationFilters.forEach(({ relation, field, filterKey, operator = 'equals' }) => {
const value = dto[filterKey];
if (this.isValidValue(value)) {
if (!where[relation]) {
where[relation] = {};
}
switch (operator) {
case 'in':
const values = typeof value === 'string' ? value.split(',') : [value];
where[relation][field] = { in: values };
break;
case 'contains':
where[relation][field] = { contains: value, mode: 'insensitive' };
break;
default:
where[relation][field] = value;
}
}
});
return where;
}
Recherche Avancée avec Relations
private buildSearchFilter<T>(dto: T, searchConfig: SearchConfig, where: any): any {
const searchValue = dto[searchConfig.searchKey];
if (!this.isValidValue(searchValue)) return where;
const { searchFields, relationSearchFields = [], mode = 'insensitive', operator = 'contains' } = searchConfig;
const searchConditions: any[] = [];
// 1. Recherche dans les champs directs
if (searchFields.length > 0) {
const directConditions = searchFields.map((field) => {
const condition: any = { [operator]: searchValue };
if (mode === 'insensitive') condition.mode = 'insensitive';
return { [field]: condition };
});
searchConditions.push(...directConditions);
}
// 2. Recherche dans les relations
if (relationSearchFields.length > 0) {
const relationConditions = this.buildRelationSearchConditions(
searchValue, relationSearchFields, { mode, operator }
);
searchConditions.push(...relationConditions);
}
// 3. Combiner intelligemment avec les conditions existantes
if (searchConditions.length > 0) {
where.OR = where.OR ? [...where.OR, ...searchConditions] : searchConditions;
}
return where;
}
Filtres Personnalisés
private buildCustomFilters<T>(dto: T, customFilters: CustomFilterConfig[], where: any): any {
customFilters.forEach(({ key, handler }) => {
const value = dto[key];
if (this.isValidValue(value)) {
where = handler(value, where);
}
});
return where;
}
3.2 Méthodes Utilitaires
Le service propose également des méthodes utilitaires pour des cas spécifiques :
// Filtres booléens
buildBooleanFilter(value: any): boolean | undefined {
if (value === 'true' || value === true) return true;
if (value === 'false' || value === false) return false;
return undefined;
}
// Filtres d'énumérations avec validation
buildEnumFilter<T>(value: any, allowedValues: T[]): T | T[] | undefined {
if (!this.isValidValue(value)) return undefined;
if (typeof value === 'string' && value.includes(',')) {
const values = value.split(',').map((v) => v.trim());
const validValues = values.filter((v) => allowedValues.includes(v as T));
return validValues.length > 0 ? (validValues as T[]) : undefined;
}
return allowedValues.includes(value as T) ? (value as T) : undefined;
}
// Combinaison de conditions avec AND/OR
combineWithAnd(...conditions: any[]): any {
const validConditions = conditions.filter(
(condition) => condition && Object.keys(condition).length > 0,
);
if (validConditions.length === 0) return {};
if (validConditions.length === 1) return validConditions[0];
return { AND: validConditions };
}
4. Configuration des Filtres
La configuration des filtres se fait via une interface flexible :
export interface FilterConfig {
baseWhere?: any;
allowedFilters?: string[];
dateFields?: DateFieldConfig[];
searchConfig?: SearchConfig;
relationFilters?: RelationFilterConfig[];
customFilters?: CustomFilterConfig[];
numericFilters?: NumericFilterConfig[];
arrayFilters?: string[];
}
export interface DateFieldConfig {
field: string;
fromKey: string;
toKey: string;
exactKey?: string; // Pour une date exacte
}
export interface SearchConfig {
searchKey: string;
searchFields: string[];
relationSearchFields?: RelationSearchField[];
mode?: 'insensitive' | 'default';
operator?: 'contains' | 'startsWith' | 'endsWith';
}
export interface RelationSearchField {
relation: string;
fields: string[];
nested?: boolean;
isArray?: boolean;
}
export interface RelationFilterConfig {
relation: string;
field: string;
filterKey: string;
operator?: 'equals' | 'in' | 'contains';
}
export interface CustomFilterConfig {
key: string;
handler: (value: any, where: any) => any;
}
export interface NumericFilterConfig {
field: string;
minKey?: string;
maxKey?: string;
exactKey?: string;
}
5. DTO Spécialisé pour les Factures
Voici comment étendre le DTO de base pour une entité spécifique :
export class GetInvoicesDto extends PaginationDto {
@IsOptional()
@IsEnum(['createdAt', 'updatedAt', 'client.displayName', 'invoiceNumber'])
sortBy?: string;
@IsOptional()
@IsEnum(InvoiceStatus)
status?: InvoiceStatus;
@IsOptional()
@IsDateString()
dateFrom?: string;
@IsOptional()
@IsDateString()
dateTo?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
minAmount?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
maxAmount?: number;
@IsOptional()
@IsString()
clientId?: string;
@IsOptional()
@Transform(({ value }) => value === 'true')
@IsBoolean()
isOverdue?: boolean;
}
6. Configuration des Filtres pour les Factures
private getInvoiceFilterConfig(organizationId: string): FilterConfig {
return {
baseWhere: { organizationId },
allowedFilters: ['status', 'discountType', 'clientId'],
dateFields: [
{
field: 'invoiceDate',
fromKey: 'dateFrom',
toKey: 'dateTo',
exactKey: 'invoiceDate',
}
],
numericFilters: [
{
field: 'totalAmount',
minKey: 'minAmount',
maxKey: 'maxAmount',
exactKey: 'totalAmount',
}
],
searchConfig: {
searchKey: 'search',
searchFields: ['invoiceNumber'],
relationSearchFields: [
{
relation: 'client',
fields: ['displayName', 'email', 'phone'],
isArray: false,
}
],
mode: 'insensitive',
operator: 'contains',
},
customFilters: [
{
key: 'isOverdue',
handler: (value: boolean, where: any) => {
if (value === true) {
where.AND = [
...(where.AND || []),
{
dueDate: { lt: new Date() },
status: { not: 'PAID' },
},
];
}
return where;
},
}
],
};
}
7. Utilisation dans le Service
async getAllInvoices(organizationId: string, getInvoicesDto: GetInvoicesDto) {
const filterConfig = this.getInvoiceFilterConfig(organizationId);
const where = this.filter.buildDynamicFilters(getInvoicesDto, filterConfig);
const orderBy = this.pagination.buildOrderBy(
getInvoicesDto.sortBy,
getInvoicesDto.sortOrder,
);
const result = await this.pagination.paginate(this.prisma.invoice, {
page: getInvoicesDto.page,
limit: getInvoicesDto.limit,
where,
orderBy,
include: this.getInvoiceInclude(),
});
return result;
}
8. Controller
@Get('/:organizationId/invoices')
async getAllInvoices(
@Param('organizationId') organizationId: string,
@Query() getInvoiceDto: GetInvoicesDto,
) {
const result = await this.invoicesService.getAllInvoices(
organizationId,
getInvoiceDto,
);
return {
statusCode: HttpStatus.OK,
message: 'Les factures ont été récupérées avec succès.',
...result,
};
}
Avantages de cette Approche
1. Réutilisabilité
-
Le
PaginationServicepeut être utilisé avec n'importe quel modèle Prisma -
Le
FilterServiceest configurable pour différentes entités
- Requêtes parallèles pour les données et le comptage
- Filtres optimisés au niveau de la base de données
- Support des filtres complexes (dates, numériques, relations)
- Recherche dans les relations
- Filtres personnalisés avec des handlers custom
- DTOs fortement typés avec validation
- Configuration typée des filtres
Requête simple avec pagination
GET /invoices?page=1&limit=20&sortBy=createdAt&sortOrder=desc
Recherche avec filtres
GET /invoices?search=John&status=PENDING&minAmount=100&maxAmount=1000
Filtres de dates
GET /invoices?dateFrom=2024-01-01&dateTo=2024-12-31&isOverdue=true
Conclusion
Cette architecture offre une solution complète et scalable pour la pagination et le filtrage dans une API NestJS avec Prisma. Elle combine la simplicité d'utilisation avec la puissance et la flexibilité nécessaires pour des cas d'usage complexes.
Les points clés à retenir :
- Séparation des responsabilités entre pagination et filtrage
- Configuration déclarative des filtres
- Performance optimisée avec des requêtes parallèles
- Type safety avec TypeScript et les DTOs