En esta página

Repositorios, QueryBuilder y paginación

14 min lectura TextoCap. 3 — Datos y persistencia

El Repository de TypeORM en profundidad

El Repository<T> es la interfaz principal para interactuar con la base de datos en TypeORM. Proporciona un conjunto completo de métodos para operaciones CRUD básicas y avanzadas.

Métodos de búsqueda del Repository

find y findBy

// find con opciones detalladas
const productos = await this.repo.find({
  where: { activo: true },
  select: { id: true, nombre: true, precio: true }, // selección de campos
  relations: { categoria: true },                    // joins
  order: { precio: 'ASC', nombre: 'ASC' },          // ordenación múltiple
  skip: 0,                                           // offset
  take: 20,                                          // limit
  cache: 60000,                                      // caché por 60 segundos
});

// findBy — forma abreviada para filtros simples
const productos = await this.repo.findBy({ activo: true, categoriaId: 3 });

findOne y findOneOrFail

// findOne retorna null si no encuentra
const producto = await this.repo.findOne({
  where: { id, activo: true },
  relations: { imagenes: true },
});
if (!producto) throw new NotFoundException();

// findOneOrFail lanza EntityNotFoundError automáticamente
const producto = await this.repo.findOneOrFail({
  where: { id },
}).catch(() => { throw new NotFoundException(`Producto ${id} no encontrado`); });

Operadores de Where avanzados

TypeORM proporciona operadores especiales para condiciones complejas:

import {
  Like, ILike, Between, In, Not, IsNull, LessThan, MoreThan,
  LessThanOrEqual, MoreThanOrEqual, Any, ArrayContains,
} from 'typeorm';

const productos = await this.repo.find({
  where: [
    // OR se hace con array
    { nombre: ILike('%zapato%') },
    { descripcion: ILike('%zapato%') },
  ],
});

const enRango = await this.repo.find({
  where: {
    precio: Between(100, 500),
    categoriaId: In([1, 2, 3]),
    eliminadoEn: IsNull(),
    stock: MoreThan(0),
  },
});

// NOT
const noActivos = await this.repo.findBy({
  estado: Not('activo'),
});

QueryBuilder — Consultas SQL expresivas

Para consultas que van más allá de los filtros simples del find, el QueryBuilder es la herramienta adecuada:

// Consulta con subconsulta y funciones de agregación
const resumen = await this.repo
  .createQueryBuilder('producto')
  .select('categoria.nombre', 'categoria')
  .addSelect('COUNT(producto.id)', 'cantidad')
  .addSelect('AVG(producto.precio)', 'precioPromedio')
  .addSelect('SUM(producto.stock)', 'stockTotal')
  .leftJoin('producto.categoria', 'categoria')
  .where('producto.activo = :activo', { activo: true })
  .groupBy('categoria.id')
  .orderBy('cantidad', 'DESC')
  .getRawMany<{
    categoria: string;
    cantidad: string;
    precioPromedio: string;
    stockTotal: string;
  }>();

Paginación basada en cursor

La paginación por cursor (o keyset pagination) es más eficiente que la paginación por offset en tablas grandes, porque no necesita saltar registros:

export class CursorPaginacionDto {
  @IsOptional()
  @IsString()
  cursor?: string; // ID del último elemento recibido

  @Type(() => Number)
  limite: number = 20;
}

async findAllConCursor(dto: CursorPaginacionDto): Promise<{
  datos: Producto[];
  proximoCursor: string | null;
}> {
  const qb = this.repo.createQueryBuilder('p')
    .orderBy('p.creadoEn', 'DESC')
    .addOrderBy('p.id', 'DESC')
    .take(dto.limite + 1); // pide uno más para saber si hay siguiente página

  if (dto.cursor) {
    // Decodifica el cursor (base64)
    const { fecha, id } = JSON.parse(
      Buffer.from(dto.cursor, 'base64').toString('utf-8')
    ) as { fecha: string; id: string };

    qb.where(
      '(p.creadoEn < :fecha) OR (p.creadoEn = :fecha AND p.id < :id)',
      { fecha, id }
    );
  }

  const resultados = await qb.getMany();
  const tieneSiguiente = resultados.length > dto.limite;
  const datos = tieneSiguiente ? resultados.slice(0, dto.limite) : resultados;

  const ultimoElemento = datos[datos.length - 1];
  const proximoCursor = tieneSiguiente && ultimoElemento
    ? Buffer.from(JSON.stringify({
        fecha: ultimoElemento.creadoEn,
        id: ultimoElemento.id,
      })).toString('base64')
    : null;

  return { datos, proximoCursor };
}

Actualizaciones masivas eficientes

Para actualizar múltiples registros a la vez sin cargarlos en memoria:

// Actualización masiva sin cargar entidades
await this.repo.update(
  { categoriaId: 5, activo: true },
  { estado: EstadoProducto.INACTIVO }
);

// Con QueryBuilder para condiciones más complejas
await this.repo
  .createQueryBuilder()
  .update(Producto)
  .set({ stock: () => 'stock - :cantidad', estado: EstadoProducto.AGOTADO })
  .where('id IN (:...ids)', { ids: ['id1', 'id2', 'id3'] })
  .andWhere('stock > 0')
  .setParameter('cantidad', 5)
  .execute();

Consultas de estadísticas con funciones de agregación

async obtenerEstadisticas(): Promise<EstadisticasProducto> {
  const resultado = await this.repo
    .createQueryBuilder('p')
    .select('COUNT(p.id)', 'total')
    .addSelect('COUNT(CASE WHEN p.stock = 0 THEN 1 END)', 'agotados')
    .addSelect('AVG(p.precio)', 'precioPromedio')
    .addSelect('MAX(p.precio)', 'precioMaximo')
    .addSelect('MIN(p.precio)', 'precioMinimo')
    .addSelect('SUM(p.stock * p.precio)', 'valorInventario')
    .where('p.eliminadoEn IS NULL')
    .getRawOne<{
      total: string;
      agotados: string;
      precioPromedio: string;
      precioMaximo: string;
      precioMinimo: string;
      valorInventario: string;
    }>();

  return {
    total: parseInt(resultado?.total ?? '0', 10),
    agotados: parseInt(resultado?.agotados ?? '0', 10),
    precioPromedio: parseFloat(resultado?.precioPromedio ?? '0'),
    precioMaximo: parseFloat(resultado?.precioMaximo ?? '0'),
    precioMinimo: parseFloat(resultado?.precioMinimo ?? '0'),
    valorInventario: parseFloat(resultado?.valorInventario ?? '0'),
  };
}

Búsqueda full-text con PostgreSQL

Para búsquedas de texto más sofisticadas, puedes aprovechar el full-text search nativo de PostgreSQL:

async busquedaFullText(termino: string, pagina: number, limite: number) {
  const tsQuery = termino
    .trim()
    .split(/\s+/)
    .map(t => `${t}:*`)
    .join(' & ');

  return this.repo
    .createQueryBuilder('p')
    .where(
      `to_tsvector('spanish', p.nombre || ' ' || COALESCE(p.descripcion, '')) @@ to_tsquery('spanish', :tsQuery)`,
      { tsQuery }
    )
    .orderBy(
      `ts_rank(to_tsvector('spanish', p.nombre || ' ' || COALESCE(p.descripcion, '')), to_tsquery('spanish', :tsQuery))`,
      'DESC'
    )
    .skip((pagina - 1) * limite)
    .take(limite)
    .getManyAndCount();
}

En la próxima lección agregaremos autenticación JWT completa a nuestra API, combinando todo lo que hemos aprendido sobre guards, servicios y TypeORM en un flujo de login y registro funcional.

Usa siempre parámetros nombrados en QueryBuilder
NUNCA interpolos valores directamente en las cadenas de consulta SQL como `qb.where('nombre = ${nombre}')`. Esto es vulnerable a inyección SQL. Siempre usa parámetros nombrados: `qb.where('nombre = :nombre', { nombre })`. TypeORM escapa automáticamente los valores.
getManyAndCount para paginación eficiente
El método `getManyAndCount()` ejecuta dos consultas: una para los datos y otra para el conteo total. Es más eficiente que hacer dos llamadas separadas porque TypeORM reutiliza la misma query con todos los filtros y solo cambia el SELECT. Úsalo siempre cuando necesites el total para la paginación.