Repositorios y Consultas con TypeORM
Una vez modeladas las entidades y configurados los providers en NestJS, el paso fundamental es interactuar con los datos persistidos. En lugar de escribir sentencias SQL manuales en los servicios, NestJS y TypeORM implementan el Patrón Repositorio, una abstracción orientada a objetos que permite consultar, insertar, modificar y eliminar registros mediante métodos seguros y fuertemente tipados.
1. El Patrón Repositorio y la Arquitectura por Capas
El patrón Repositorio actúa como un mediador entre la capa de reglas del negocio y la base de datos relacional. Su propósito es desacoplar las operaciones sobre las tablas para que los servicios no dependan de la sintaxis SQL cruda:
- Controlador (
Controller): Recibe la solicitud HTTP, valida los datos con DTOs y delega la ejecución al servicio. - Servicio (
Service): Aplica las reglas del negocio e invoca los métodos provistos por los repositorios inyectados. - Repositorio (
Repository<T>): Provee métodos de TypeORM (find,save,update,delete) y traduce las operaciones a SQL seguro. - Base de Datos (PostgreSQL): Almacena físicamente los registros en tablas y garantiza la integridad relacional.
2. Modelo de Datos de Ejemplo: Relación Autores y Libros
Para ilustrar las operaciones y tipos de consulta con llaves foráneas, utilizaremos una relación de uno a muchos (1:N): un autor puede escribir múltiples libros, y cada libro pertenece a un único autor.
Entidades en TypeScript con TypeORM
Definimos las dos entidades utilizando los decoradores relacionales:
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
import { Book } from '../../books/entities/book.entity';
@Entity('authors')
export class Author {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 150 })
name: string;
@Column({ length: 80, nullable: true })
nationality: string;
@Column({ name: 'birth_year', type: 'int', nullable: true })
birthYear: number;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
// Un autor puede tener muchos libros
@OneToMany(() => Book, (book) => book.author)
books: Book[];
}
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { Author } from '../../authors/entities/author.entity';
@Entity('books')
export class Book {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 200 })
title: string;
@Column({ unique: true, length: 20 })
isbn: string;
@Column({ type: 'decimal', precision: 8, scale: 2 })
price: number;
@Column({ name: 'publication_year', type: 'int' })
publicationYear: number;
@Column({ length: 60 })
genre: string;
// Muchos libros pertenecen a un único autor
@ManyToOne(() => Author, (author) => author.books, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'author_id' }) // Columna física de llave foránea en PostgreSQL
author: Author;
}
En las relaciones @ManyToOne / @OneToMany, la columna física de llave foránea (author_id) reside en la tabla del lado "muchos" (books). Por esta razón, el decorador @JoinColumn se ubica siempre en la entidad Book.
3. Inyección de Repositorios en NestJS
Para poder utilizar los repositorios en nuestros servicios, seguimos un proceso de dos pasos:
Paso 1: Registrar las entidades en el módulo (TypeOrmModule.forFeature)
El método TypeOrmModule.forFeature() registra automáticamente los proveedores de repositorio (Repository<Book> y Repository<Author>) en el contenedor IoC del módulo:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Book } from './entities/book.entity';
import { Author } from '../authors/entities/author.entity';
import { BooksService } from './books.service';
import { BooksController } from './books.controller';
@Module({
imports: [TypeOrmModule.forFeature([Book, Author])],
controllers: [BooksController],
providers: [BooksService],
exports: [BooksService],
})
export class BooksModule {}
Paso 2: Inyectar con @InjectRepository() en el constructor del servicio
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Book } from './entities/book.entity';
import { Author } from '../authors/entities/author.entity';
@Injectable()
export class BooksService {
constructor(
@InjectRepository(Book)
private readonly bookRepository: Repository<Book>,
@InjectRepository(Author)
private readonly authorRepository: Repository<Author>,
) {}
}
4. Operaciones CRUD Básicas con el Repositorio
La clase Repository<T> de TypeORM expone un conjunto de métodos listos para usar:
| Método | Propósito | Comportamiento en Base de Datos |
|---|---|---|
create(dto) | Crea una instancia de la entidad en memoria | No ejecuta SQL (solo crea el objeto en memoria) |
save(entity) | Inserta un nuevo registro o actualiza uno existente | Ejecuta INSERT o UPDATE |
find(options) | Retorna todos los registros que cumplan las condiciones | Ejecuta SELECT * FROM... |
findOne(options) | Retorna el primer registro coincidente o null | Ejecuta SELECT * ... LIMIT 1 |
findBy(criteria) | Atajo de búsqueda por igualdad simple de campos | Ejecuta SELECT * WHERE campo = valor |
findOneBy(criteria) | Atajo para obtener un único registro por campos simples | Ejecuta SELECT * WHERE ... LIMIT 1 |
update(id, partial) | Actualiza columnas directas por ID sin cargar la entidad | Ejecuta UPDATE ... WHERE id = ... |
delete(id) | Elimina el registro físicamente por su ID | Ejecuta DELETE FROM ... WHERE id = ... |
count(options) | Retorna la cantidad de registros coincidentes | Ejecuta SELECT COUNT(*) FROM... |
findAndCount(options) | Retorna un arreglo [registros, total] para paginación | Ejecuta consulta de datos y de conteo |
Diferencia clave: create() frente a save()
// 1. create() solo instancia el objeto en memoria TypeScript (valida tipos)
const bookInstance = this.bookRepository.create({
title: 'Cien años de soledad',
isbn: '978-0307474728',
price: 45000,
publicationYear: 1967,
genre: 'Realismo Mágico',
author: authorInstance,
});
// En este punto NO se ha ejecutado ninguna sentencia en la base de datos
// 2. save() persiste físicamente la entidad en PostgreSQL
const savedBook = await this.bookRepository.save(bookInstance);
// Ejecuta: INSERT INTO books (title, isbn, price...) VALUES (...)
5. Consultas Declarativas con FindOptions
El método find() acepta un objeto de configuración (FindManyOptions) con opciones declarativas para controlar la consulta:
const books = await this.bookRepository.find({
// 1. select: Proyección de columnas específicas (evita SELECT *)
select: {
id: true,
title: true,
price: true,
},
// 2. where: Condiciones de igualdad y filtros
where: {
genre: 'Fantasía',
},
// 3. relations: Carga las entidades relacionadas (JOIN)
relations: {
author: true,
},
// 4. order: Criterios de ordenamiento
order: {
price: 'DESC',
title: 'ASC',
},
// 5. Paginación: Cantidad de registros y desplazamiento
take: 10, // LIMIT 10
skip: 0, // OFFSET 0
});
6. Operadores de Consulta de TypeORM
Para aplicar filtros más expresivos que la igualdad simple, TypeORM incluye operadores listos para importar desde typeorm:
| Operador TypeORM | Equivalente SQL | Caso de Uso |
|---|---|---|
ILike('%cadena%') | WHERE col ILIKE '%cadena%' | Búsqueda aproximada insensible a mayúsculas |
Like('%cadena%') | WHERE col LIKE '%cadena%' | Búsqueda aproximada sensible a mayúsculas |
Between(min, max) | WHERE col BETWEEN min AND max | Rango de precios, fechas o años |
In([a, b, c]) | WHERE col IN (a, b, c) | Coincidencia con una lista de valores |
MoreThan(n) | WHERE col > n | Valores mayores a un número |
MoreThanOrEqual(n) | WHERE col >= n | Valores mayores o iguales |
LessThan(n) | WHERE col < n | Libros publicados antes de un año |
LessThanOrEqual(n) | WHERE col <= n | Precios menores o iguales a un tope |
IsNull() | WHERE col IS NULL | Registros sin valor asignado |
Not(condicion) | WHERE NOT (...) | Niega cualquier condición |
Ejemplo con Operadores Combinados
import { Between, ILike, In } from 'typeorm';
// Buscar libros de Ciencia Ficción o Novela, con precio entre 20 y 70 USD,
// y cuyo título contenga la palabra "guía"
const results = await this.bookRepository.find({
where: {
genre: In(['Ciencia Ficción', 'Novela']),
price: Between(20, 70),
title: ILike('%guía%'),
},
relations: {
author: true,
},
order: {
price: 'ASC',
},
take: 10,
});
7. Implementación Completa del Servicio (BooksService)
A continuación se muestra un servicio de NestJS que consolida las operaciones CRUD y consultas relacionales:
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, ILike, Between, In } from 'typeorm';
import { Book } from './entities/book.entity';
import { Author } from '../authors/entities/author.entity';
export interface CreateBookDto {
title: string;
isbn: string;
price: number;
publicationYear: number;
genre: string;
authorId: number;
}
@Injectable()
export class BooksService {
constructor(
@InjectRepository(Book)
private readonly bookRepository: Repository<Book>,
@InjectRepository(Author)
private readonly authorRepository: Repository<Author>,
) {}
// 1. Crear un libro asociándolo a un autor existente
async create(dto: CreateBookDto): Promise<Book> {
const author = await this.authorRepository.findOneBy({ id: dto.authorId });
if (!author) {
throw new NotFoundException(`El autor con ID ${dto.authorId} no existe`);
}
const existingBook = await this.bookRepository.findOneBy({ isbn: dto.isbn });
if (existingBook) {
throw new ConflictException(`Ya existe un libro con el ISBN ${dto.isbn}`);
}
const newBook = this.bookRepository.create({
...dto,
author, // Asignamos la relación cargada
});
return await this.bookRepository.save(newBook);
}
// 2. Listar libros con paginación y relación cargada
async findAll(page: number = 1, limit: number = 10): Promise<{ data: Book[]; total: number }> {
const [data, total] = await this.bookRepository.findAndCount({
relations: {
author: true,
},
order: {
id: 'DESC',
},
take: limit,
skip: (page - 1) * limit,
});
return { data, total };
}
// 3. Buscar libro por ID con relación de autor
async findOne(id: number): Promise<Book> {
const book = await this.bookRepository.findOne({
where: { id },
relations: { author: true },
});
if (!book) {
throw new NotFoundException(`Libro con ID ${id} no encontrado`);
}
return book;
}
// 4. Filtrar por rango de precio y lista de géneros
async findByPriceAndGenres(min: number, max: number, genres: string[]): Promise<Book[]> {
return await this.bookRepository.find({
where: {
price: Between(min, max),
genre: In(genres),
},
relations: {
author: true,
},
order: {
price: 'ASC',
},
});
}
// 5. Búsqueda por título aproximado
async searchByTitle(term: string): Promise<Book[]> {
return await this.bookRepository.find({
where: {
title: ILike(`%${term}%`),
},
relations: {
author: true,
},
});
}
// 6. Actualización de precio
async updatePrice(id: number, newPrice: number): Promise<Book> {
const book = await this.findOne(id);
book.price = newPrice;
return await this.bookRepository.save(book);
}
// 7. Eliminación física
async remove(id: number): Promise<void> {
const result = await this.bookRepository.delete(id);
if (result.affected === 0) {
throw new NotFoundException(`No fue posible eliminar el libro con ID ${id}`);
}
}
}