Saltar al contenido principal

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:

El Patrón Repositorio en la Arquitectura por Capas
  • 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.

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;

// Un autor puede tener 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;

// 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;
}
Ubicación de @JoinColumn

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:

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 {}

Paso 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>,
) {}
}

4. 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 memoria)
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 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 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

src/books/queries-example.ts
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:

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 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}`);
}
}
}

Autoevaluación

Cargando cuestionario...