Implementando paginación en NestJS
Introducción
En promedio, cuando arrancamos a programar en Node.js, lo hacemos con la librería de Express. Arrancamos escribiendo un CRUD de usuarios (también conocido como ABM) y probablemente nuestra primera base de datos sea MongoDB. Conforme pasa el tiempo, implementamos alguna base de datos SQL y empezamos a hacer consultas más complejas, donde empezamos a aprender a optimizar. Una de las técnicas de optimización que todos deberían tener en su bolsillo es la paginación, la cual es el tópico de este artículo.
Antes de comenzar, como el título lo indica, voy a estar utilizando NestJS porque es mi framework favorito de backend y porque creo que es muy interesante la forma en la que se implementa la paginación en este mismo.
Por que es util la paginación?
En lugar de darles la definición directa de qué es la paginación y cómo se aplica, prefiero presentarles un problema:
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly repository: Repository<User>,
) { }
public async getAll(): Promise<User[]> {
return this.repository.find()
}
public async getById(id: string): Promise<User> {
const obj = await this.repository.findOne(({ where: { id } }))
if (!obj) throw new NotFoundException('USER_NOT_FOUND')
return obj
}
public async create(dto: CreateUserDto): Promise<User> {
const obj = this.repository.create(dto)
return obj.save()
}
public async update(dto: UpdateUserDto): Promise<User> {
const existingUser = await this.getById(dto.id)
return this.repository.save({
...existingUser,
dto
})
}
public async deleteById(id: string): Promise<void> {
const result = await this.repository.softDelete(id)
if (result.affected === 0) throw new NotFoundException('USER_NOT_FOUND')
}
}
Consideremos este UserService que, en esencia, es un simple CRUD:
- getAll: traer todos los usuarios de la base de datos.
- getById: consultar un usuario por su id.
- create: crear usuarios y guardarlos en la base de datos.
- update: actualizar los datos de un usuario existente.
- deleteById: borrar un usuario por id.
Lo que nos interesa de este service es el método getAll
. Es probable que te resulte normal implementarlo de esta forma, pues la mayoría de los cursos que están en internet te sugieren hacerlo de esta manera. Pero, ¿qué ocurre si la cantidad de usuarios que tenemos en el sistema empieza a crecer? Y no hablo de 100 usuarios, sino de 100.000 o 1.000.000...
Dicho endpoint empezaría a tardar más tiempo y ocupar más memoria RAM, pero por encima de eso, sería una carga muy grande para el servidor y la base de datos, en donde llegaría un punto en el cual no va a ser capaz de completar la petición.
Entonces, qué es la paginación?
Para prevenirnos de esa situación, una solución es implementar paginación; es una técnica mediante la cual se particionan los datos que se quieren traer en páginas donde se indica a la query qué página queremos traer. Imagina que toda tu tabla de usuarios es un libro, y lo intentas dividir en partes iguales a las que llamamos páginas; cada vez que le pegues al método getAll
le estás indicando qué página de ese libro quieres "leer".
Implementando la paginación
Lo primero que necesitamos es definir la interfaz de datos base que vamos a pasar a las queries que queremos paginar.
export interface PaginateQueryRaw {
page?: string,
take?: string,
search?: string
}
- page: La página que queremos consultar
- take: Cuántos elementos queremos que contenga esa página? (Debemos validar este dato para evitar que alguien le pase un número demasiado grande)
- search: Para poder consultar usuarios por nombre
Vamos a definir unas funciones que nos permitan formatear los datos de dicha interfaz
const MAX_TAKE_PER_QUERY = 50
const formatTake = (value: string): number => {
let x = Number(value)
if (x > MAX_TAKE_PER_QUERY || Number.isNaN(x)) {
x = MAX_TAKE_PER_QUERY
}
return x
}
const formatPage = (value: string): number => Number(value) || 1
Luego necesitamos armar la query para la base de datos, para ello vamos a usar el query builder
de typeorm. No recomiendo usar repository para traer datos donde podamos a llegar a usar joins porque tiene problemas de performance.
public async getAll(query: PaginateQueryRaw): Promise<User[]> {
const take = formatTake(query.take)
const page = formatPage(query.page)
const skip = take * page - take
const qb = this.repository.createQueryBuilder('user')
const [rows, count] = await qb
.take(take)
.skip(skip)
.getManyAndCount()
return rows
}
Es así de fácil, pero aún podemos mejorarlo. Vamos a agregarle la lógica para buscar por nombre de usuario.
public async getAll(query: PaginateQueryRaw): Promise<User[]> {
const take = formatTake(query.take)
const page = formatPage(query.page)
const skip = take * page - take
const qb = this.repository.createQueryBuilder('user')
if (query.search) {
qb.where(
`LOWER(user.fullName) Like '%${query.search.toLowerCase()}%'`
)
}
const [rows, count] = await qb
.take(take)
.skip(skip)
.getManyAndCount()
return rows
}
Ahora estaría bueno devolver una metadata que nos dé información de la página que acabamos de solicitar.
export interface Metadata {
totalPages: number
totalItems: number
itemsPerPage: number
currentPage: number
searchTerm: string
nextPage: number
}
export interface PaginatedUser {
metadata: Metadata
rows: User[]
}
- totalPages: en base al
take
que pasamos, en cuantas páginas se divide nuestra tabla - totalItems: el total de usuarios que hay en la tabla
- itemsPerPage: refleja el valor de
take
- currentPage: refleja el valor de
page
- searchTerm: refleja el valor de
search
- nextPage: página siguiente si es que hay
Ahora vamos a calcular estos valores:
public async getAll(query: PaginateQueryRaw): Promise<PaginatedUser> {
const take = formatTake(query.take)
const page = formatPage(query.page)
const skip = take * page - take
const qb = this.repository.createQueryBuilder('user')
if (query.search) {
qb.where(
`LOWER(user.fullName) Like '%${query.search.toLowerCase()}%'`
)
}
const [rows, count] = await qb
.take(take)
.skip(skip)
.getManyAndCount()
const itemsPerPage = formatTake(query.take)
const totalPages = Math.ceil(count / itemsPerPage)
const totalItems = count
const currentPage = formatPage(query.page)
const nextPage = totalPages - currentPage <= 0
? null
: currentPage + 1
const metadata: Metadata = {
itemsPerPage,
totalPages,
totalItems,
currentPage,
nextPage,
searchTerm: query.search || ''
}
return { rows, metadata }
}
Haciendo nuestra paginación reutilizable
Estaría bueno si pudiéramos abstraer todo esto en una función y no tener que hacerlo para cada getAll
de cada resource que tenemos en nuestra app. Spoiler alert: es posible. Para ello necesitamos
- Una función reutilizable a la cual llamaremos
getAllPaginated
- Un tipado genérico que se adapte a los distintos modelos o recursos que tenemos en nuestra app.
Empecemos por definir el tipado de lo que nos va a devolver dicha función:
export interface Paginated<T> {
rows: T[]
metadata: Metadata
}
Abstraemos la logica en la función y usamos el tipado recien creado
const getAllPaginated = async <T>(
qb: SelectQueryBuilder<T>,
query: PaginateQueryRaw
): Promise<Paginated<T>> => {
const take = formatTake(query.take)
const page = formatPage(query.page)
const skip = take * page - take
const [rows, count] = await qb
.take(take)
.skip(skip)
.getManyAndCount()
const itemsPerPage = formatTake(query.take)
const totalPages = Math.ceil(count / itemsPerPage)
const totalItems = count
const currentPage = formatPage(query.page)
const nextPage = totalPages - currentPage <= 0 ? null : currentPage + 1
const metadata: Metadata = {
itemsPerPage,
totalPages,
totalItems,
currentPage,
nextPage,
searchTerm: query.search || ''
}
return { rows, metadata }
Y así es como nos quedaría nuestro getAll
de UserService
public async getAll(query: PaginateQueryRaw): Promise<Paginated<User>> {
const qb = this.repository.createQueryBuilder('user')
if (query.search) {
qb.where(
`LOWER(user.fullName) Like '%${query.search.toLowerCase()}%'`
)
}
return getAllPaginated(qb, query)
}
Conclusión
Implementar paginación es algo sencillo, y una vez que lo tienes implementado utilizarlo es súper simple. Esta misma función que puse de ejemplo la armé en conjunto con mi amigo Javi hace 2 años y desde entonces la he utilizado en todos los proyectos en los que he participado.
Gracias por leer :D