Saltar al contenido principal

Repositorios y Consultas con TypeORM en NestJS

Una vez modeladas las entidades de nuestra base de datos, el siguiente paso es interactuar con ellas desde la lógica de la aplicación. En lugar de escribir sentencias SQL manuales en los servicios, NestJS y TypeORM utilizan el Patrón Repositorio, una abstracción que permite consultar, insertar, modificar y eliminar datos mediante métodos orientados a objetos.


El Patrón Repositorio y la Arquitectura por Capas

El patrón Repositorio actúa como un mediador entre la capa de negocio y la base de datos relacional. Su objetivo es aislar las consultas a tablas para que los servicios no dependan de detalles técnicos de la base de datos:

El Patrón Repositorio en la Arquitectura por Capas
  • Controlador (Controller): Recibe la solicitud HTTP, valida los datos de entrada con DTOs y delega 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 relacionadas.

Modelo de Datos: Relación Autores y Libros

Para ilustrar las operaciones y tipos de consulta, utilizaremos una relación clásica de uno a muchos (1:N): un autor puede escribir muchos libros, y cada libro pertenece a un único autor.

Diagrama Entidad-Relación de Autores y Libros

Entidades en TypeScript con TypeORM

Definimos las dos entidades utilizando los decoradores relacionales:

src/authors/entities/author.entity.ts
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;

// Relación: Un autor tiene muchos libros
@OneToMany(() => Book, (book) => book.author)
books: Book[];
}
src/books/entities/book.entity.ts
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;

// Relación: Muchos libros pertenecen a un único autor
@ManyToOne(() => Author, (author) => author.books, { onDelete: "CASCADE" })
@JoinColumn({ name: "author_id" }) // Columna física de la llave foránea
author: Author;
}
Ubicación de @JoinColumn

En las relaciones @ManyToOne / @OneToMany, la columna 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.


Inyección de Repositorios en NestJS

Para poder utilizar los repositorios en nuestros servicios, seguimos un proceso de dos pasos:

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 de este módulo:

src/books/books.module.ts
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 {}

2. Inyectar con @InjectRepository() en el constructor del servicio

src/books/books.service.ts
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>,
) {}
}

Operaciones CRUD Básicas con el Repositorio

La clase Repository<T> de TypeORM expone un conjunto de métodos listos para usar:

MétodoPropósitoComportamiento en Base de Datos
create(dto)Crea una instancia de la entidad en memoriaNo ejecuta SQL (solo crea el objeto en JS)
save(entity)Inserta un nuevo registro o actualiza uno existenteEjecuta INSERT o UPDATE
find(options)Retorna todos los registros que cumplan las condicionesEjecuta SELECT * FROM...
findOne(options)Retorna el primer registro coincidente o nullEjecuta SELECT * ... LIMIT 1
findBy(criteria)Atajo de búsqueda por igualdad simple de camposEjecuta SELECT * WHERE campo = valor
findOneBy(criteria)Atajo para obtener un único registro por campos simplesEjecuta SELECT * WHERE ... LIMIT 1
update(id, partial)Actualiza columnas directas por ID sin cargar la entidadEjecuta UPDATE ... WHERE id = ...
delete(id)Elimina el registro físicamente por su IDEjecuta DELETE FROM ... WHERE id = ...
count(options)Retorna la cantidad de registros coincidentesEjecuta SELECT COUNT(*) FROM...
findAndCount(options)Retorna un arreglo [registros, total] para paginaciónEjecuta consulta de datos y de conteo

Diferencia clave: create() frente a save()

// 1. create() solo crea la instancia en memoria (valida tipos en TypeScript)
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 (...)

Consultas Avanzadas con FindOptions

El método find() acepta un objeto de configuración (FindManyOptions) con propiedades 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
});

Operadores de Consulta de TypeORM

Para aplicar filtros más expresivos que la simple igualdad, TypeORM incluye operadores listos para importar desde typeorm:

Operador TypeORMEquivalente SQLCaso 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 maxRango 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 > nValores mayores a un número
MoreThanOrEqual(n)WHERE col >= nValores mayores o iguales
LessThan(n)WHERE col < nLibros publicados antes de un año
LessThanOrEqual(n)WHERE col <= nPrecios menores o iguales a un tope
IsNull()WHERE col IS NULLRegistros sin valor asignado
Not(condicion)WHERE NOT (...)Niega cualquier condición

Ejemplo con Operadores Combinados

Ejemplo de consulta con operadores
import { Between, ILike, In, MoreThan } from "typeorm";

// Buscar libros de Ciencia Ficción o Novela, con precio entre 20 y 70 USD,
// cuyo título contenga la palabra "guía" y con precio superior a 0
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,
});

Implementación Completa del Servicio (BooksService)

A continuación se muestra un servicio de NestJS que aplica estas operaciones de forma limpia y directa:

src/books/books.service.ts
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 entidad relacionada
});

return await this.bookRepository.save(newBook);
}

// 2. Listar libros con paginación y relación de autor 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
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 libros por rango de precio y lista de géneros usando operadores
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}`);
}
}
}

Cuestionario de Autoevaluación

Cargando cuestionario...