Vamos con el octavo post de la serie NestJS. En el post anterior montamos PostgreSQL con Docker Compose, configuramos TypeORM con variables de entorno y creamos nuestra primera entidad User con todos sus decoradores. Pero la entidad por si sola es una vaga, no hace nada, así que hoy vamos a darle trabajo.
Vamos a hacer un CRUD completito: crear, leer, actualizar y eliminar usuarios. Pero no un CRUD cutrecito e ya, sino un CRUD con paginación tipada, filtros dinámicos, soft delete, respuestas consistentes y transacciones. Vamos que va a ser un CRUD pata negra.
Vamos a ver todo lo que hemos aprendido hasta ahora, cómo los DTOs validados del post 6, la entidad del post 7, la inyección de dependencias del post 3 y los módulos del post 4.
EA, amo al lío.

1. Repository Pattern: qué es y por qué usarlo
El 💡 Repository Pattern Patrón de diseño que abstrae el acceso a datos detrás de una interfaz tipo colección. En vez de escribir SQL o queries directas en los services, usas métodos como find(), findOne(), save(), remove(). El repository encapsula toda la lógica de acceso a datos.
Más info →
es una capa de abstracción entre tu lógica de negocio y la base de datos. TypeORM implementa este patrón con la clase Repository<Entity>, que te da métodos tipados para interactuar con la tabla.
¿Y por qué usar un Repository y no hacer queries directas en el service?
El service no sabe si los datos vienen de PostgreSQL, MySQL o un mock. Cambia el origen de datos sin tocar la lógica de negocio.
En tests, sustituyes el repository real por un mock. No necesitas base de datos para testear la lógica del service.
Repository
find, findOne, save, remove, count, exists, createQueryBuilder... No reimplementas operaciones básicas.
2. @InjectRepository: inyectar el repositorio
En el post 7 registramos la entidad con TypeOrmModule.forFeature([User]). Esto hace que NestJS cree automáticamente un Repository<User> que podemos inyectar con @InjectRepository():
// src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
) {}
}
Repository<User> es genérico y todos sus métodos saben que trabajan con la entidad User. Si intentas hacer this.usersRepository.find({ where: { campoQueNoExiste: 'x' } }) TypeScript te va a gritar.
3. Create: crear un registro
// src/users/users.service.ts
import { Injectable, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { type CreateUserDto } from './dto/create-user.dto';
import * as bcrypt from 'bcrypt';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
) {}
async create(createUserDto: CreateUserDto): Promise<User> {
const existingUser = await this.usersRepository.findOne({
where: { email: createUserDto.email },
});
if (existingUser) {
throw new ConflictException(`El email ${createUserDto.email} ya está registrado`);
}
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
const user = this.usersRepository.create({
...createUserDto,
password: hashedPassword,
});
return this.usersRepository.save(user);
}
}
Dos pasos importantes aquí:
create() vs save(): No son lo mismo
this.usersRepository.create()— Crea una instancia deUseren memoria y no toca la base de datos. Aplica valores por defecto de la entidad y transforma el objeto plano a una instancia de clase.this.usersRepository.save()— Persiste la instancia en la base de datos, ejecutando unINSERTsi es nueva o unUPDATEsi ya existe (lo decide por la presencia delid).
// ❌ Esto funciona pero salta la instanciación de la entidad
await this.usersRepository.save(createUserDto);
// ✅ Esto crea la instancia primero y luego la guarda
const user = this.usersRepository.create(createUserDto);
await this.usersRepository.save(user);
Siempre create() + save(). El create() aplica los valores por defecto de la entidad (active: true, role: UserRole.USER), convierte el objeto plano a instancia, y ejecuta los entity listeners entity listeners Métodos de la entidad decorados con @BeforeInsert, @AfterInsert, @BeforeUpdate, etc. que se ejecutan automáticamente en ciertos puntos del ciclo de vida. Solo funcionan con instancias creadas con create() o new Entity(). como @BeforeInsert. Con solo save() te saltas todo eso.
Hashear el password
Usamos bcrypt para hashear el password antes de guardarlo. Nunca, nunca, nunca almacenes contraseñas en texto plano, que si luego un tercero logra el acceso a la base de datos o consigue filtrar algo todo son risas.
4. Read: leer registros
4.1. Buscar uno por ID
async findOne(id: string): Promise<User> {
const user = await this.usersRepository.findOne({
where: { id },
});
if (!user) {
throw new NotFoundException(`Usuario con id ${id} no encontrado`);
}
return user;
}
findOne() devuelve User | null. Siempre comprueba el null y lanza NotFoundException. Nunca devuelvas null desde un endpoint, mejor, devuelve un 404 con un mensaje.
4.2. Buscar todo
async findAll(): Promise<User[]> {
return this.usersRepository.find({
order: { createdAt: 'DESC' },
});
}
find() sin where devuelve todos los registros. El order define la ordenación, como se puede presuponer. Pero en una API real nunca devuelves todos los registros, es una locura y se necesita paginación, que lo veremos un poquito más adelante, en el punto 6.
4.3. Buscar por condiciones
// Buscar por email
const user = await this.usersRepository.findOne({
where: { email: 'domin@domin.es' },
});
// Buscar varios por condición
const activeAdmins = await this.usersRepository.find({
where: { active: true, role: UserRole.ADMIN },
});
// OR condition: activos O administradores
const usersOrAdmins = await this.usersRepository.find({
where: [
{ active: true },
{ role: UserRole.ADMIN },
],
});
Las condiciones dentro de un mismo objeto son AND y un array de objetos es OR. TypeScript valida que las propiedades que usas en where existan en la entidad User.
4.4. FindOperators: queries avanzadas tipadas
TypeORM trae operadores de comparación para queries más complejas:
import { Like, ILike, In, Between, IsNull, Not, LessThan, MoreThanOrEqual } from 'typeorm';
// ILIKE (case-insensitive) — buscar por nombre
const users = await this.usersRepository.find({
where: { name: ILike(`%domin%`) },
});
// IN — buscar varios IDs a la vez
const specificUsers = await this.usersRepository.find({
where: { id: In(['uuid-1', 'uuid-2', 'uuid-3']) },
});
// BETWEEN — rango de fechas
const recentUsers = await this.usersRepository.find({
where: { createdAt: Between(startDate, endDate) },
});
// NOT — negación
const nonAdmins = await this.usersRepository.find({
where: { role: Not(UserRole.ADMIN) },
});
// Combinados
const filteredUsers = await this.usersRepository.find({
where: {
active: true,
name: ILike(`%${search}%`),
createdAt: MoreThanOrEqual(sinceDate),
},
});
Todos estos operadores son tipados. ILike solo acepta columnas string, Between requiere dos valores del mismo tipo que la columna, In requiere un array del tipo correcto.
5. Update para actualizar un registro
async update(id: string, updateUserDto: UpdateUserDto): Promise<User> {
const user = await this.findOne(id); // Reutilizamos findOne que ya lanza 404
if (updateUserDto.password) {
updateUserDto = {
...updateUserDto,
password: await bcrypt.hash(updateUserDto.password, 10),
};
}
const updatedUser = this.usersRepository.merge(user, updateUserDto);
return this.usersRepository.save(updatedUser);
}
merge() + save()es el patrón correcto para updates
merge(entity, dto): Copia las propiedades del DTO sobre la entidad existente y solo sobreescribe las propiedades que vienen en el DTO (perfecto conPartialTypedel post 6).save(entity): Como la entidad ya tieneid, TypeORM ejecuta unUPDATEen vez de unINSERT.
Por qué NO usar update() directamente
// ⚠️ Funciona pero tiene limitaciones
await this.usersRepository.update(id, updateUserDto);
update() ejecuta el SQL directamente, sin pasar por las instancias de la entidad y eso significa que:
- No ejecuta
@BeforeUpdateni@AfterUpdate. - No actualiza
@UpdateDateColumnautomáticamente (depende del driver). - No devuelve la entidad actualizada, solo un
UpdateResult.
- merge() + save() para updates que necesitan entity listeners, hooks o devolver la entidad completa
- update() para updates masivos o cuando el rendimiento prima sobre las features (actualizar 10.000 filas)
- update() cuando necesitas la entidad actualizada de vuelta (no la devuelve)
- Hacer findOne + modificar propiedades a mano en vez de merge(). merge() es más limpio y declarativo
6. Paginación tipada: no devuelvas 10.000 registros
Toda API real necesita paginación, vamos a ver un ejemplo:
6.1. Interfaz de respuesta paginada
// src/common/interfaces/paginated-result.interface.ts
export interface PaginatedResult<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
Esta interfaz es genérica (<T>). Funciona para User, Product, Order o cualquier entidad, así podemos tener un solo tipo para toda la API.
6.2. DTO de paginación
// src/common/dto/pagination.dto.ts
import { IsOptional, IsNumber, Min, Max } from 'class-validator';
export class PaginationDto {
@IsOptional()
@IsNumber()
@Min(1)
readonly page: number = 1;
@IsOptional()
@IsNumber()
@Min(1)
@Max(100)
readonly limit: number = 10;
}
6.3. Implementación en el service
async findAll(paginationDto: PaginationDto): Promise<PaginatedResult<User>> {
const { page, limit } = paginationDto;
const skip = (page - 1) * limit;
const [data, total] = await this.usersRepository.findAndCount({
order: { createdAt: 'DESC' },
skip,
take: limit,
});
const totalPages = Math.ceil(total / limit);
return {
data,
meta: {
total,
page,
limit,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
};
}
findAndCount() es la clave porque ejecuta dos queries en paralelo: un SELECT con LIMIT y OFFSET para los datos, y un COUNT(*) para el total. Devuelve una tupla [entities[], count].
El frontend tiene todo lo que necesita para construir la paginación: total de registros, página actual, si hay siguiente/anterior, total de páginas, sin tener que calcular nada.
7. Filtros dinámicos: queries flexibles
En una API real, el endpoint de listado acepta filtros: buscar por nombre, filtrar por estado, por rol… Vamos a montar un sistema de filtros que escale:
7.1. DTO de filtros
// src/users/dto/filter-users.dto.ts
import { IsOptional, IsString, IsBoolean, IsEnum } from 'class-validator';
import { IntersectionType } from '@nestjs/mapped-types';
import { PaginationDto } from '../../common/dto/pagination.dto';
import { UserRole } from '../enums/user-role.enum';
class FilterFieldsDto {
@IsOptional()
@IsString()
readonly search?: string;
@IsOptional()
@IsBoolean()
readonly active?: boolean;
@IsOptional()
@IsEnum(UserRole)
readonly role?: UserRole;
}
export class FilterUsersDto extends IntersectionType(PaginationDto, FilterFieldsDto) {}
Usamos IntersectionType del post 6 para combinar los filtros de paginación con los filtros específicos de usuarios, así tenemos un solo DTO para todo.
7.2. Construir el where dinámicamente
import { ILike, type FindOptionsWhere } from 'typeorm';
async findAll(filterDto: FilterUsersDto): Promise<PaginatedResult<User>> {
const { page, limit, search, active, role } = filterDto;
const skip = (page - 1) * limit;
const where: FindOptionsWhere<User> = {};
if (search) {
where.name = ILike(`%${search}%`);
}
if (active !== undefined) {
where.active = active;
}
if (role) {
where.role = role;
}
const [data, total] = await this.usersRepository.findAndCount({
where,
order: { createdAt: 'DESC' },
skip,
take: limit,
});
const totalPages = Math.ceil(total / limit);
return {
data,
meta: {
total,
page,
limit,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
};
}
FindOptionsWhere<User> es el tipo que TypeORM usa para el where, y es genérico porque solo te deja poner propiedades que existan en User, con los tipos correctos. Si intentas where.email = 42, TypeScript te grita.
Construimos el where condicionalmente y solo añadimos las propiedades que vienen en el DTO. Si search es undefined, no se filtra por nombre. Si active no viene, no se filtra por estado. Así el mismo endpoint sirve para GET /users, GET /users?active=true y GET /users?search=domin&role=admin.
7.3. Búsqueda en múltiples campos
¿Qué pasa si quieres buscar por nombre o email? Con el where de Find Options necesitas un array:
if (search) {
const baseWhere: FindOptionsWhere<User> = {};
if (active !== undefined) baseWhere.active = active;
if (role) baseWhere.role = role;
const [data, total] = await this.usersRepository.findAndCount({
where: [
{ ...baseWhere, name: ILike(`%${search}%`) },
{ ...baseWhere, email: ILike(`%${search}%`) },
],
order: { createdAt: 'DESC' },
skip,
take: limit,
});
// ...
}
Cada objeto del array es una condición OR. Así search=domin encuentra usuarios que tengan “domin” en el nombre o en el email, manteniendo el resto de filtros (active, role) en ambas condiciones.
8. Soft delete: borrar sin borrar
En el post 7 añadimos @DeleteDateColumn() a la entidad User, eso habilita el 💡 Soft Delete Borrado lógico. En vez de ejecutar DELETE (que elimina la fila permanentemente), se marca el registro con una fecha en la columna deletedAt. Los SELECT normales lo ignoran automáticamente, pero el registro sigue existiendo para auditoría, recuperación o cumplimiento legal.
Más info →
.
8.1. Borrado lógico
async remove(id: string): Promise<void> {
const user = await this.findOne(id);
await this.usersRepository.softRemove(user);
}
softRemove() no ejecuta DELETE. Ejecuta un UPDATE que pone la fecha actual en deletedAt. El registro sigue en la tabla pero se vuelve “invisible” para las queries normales.
8.2. ¿Cómo funciona la invisibilidad?
Cuando tu entidad tiene @DeleteDateColumn(), TypeORM añade automáticamente WHERE "deletedAt" IS NULL a todos los find(), findOne(), findAndCount(), etc. Los registros borrados de manera soft no van a aparecer en los resultados, sin hacer nada más.
8.3. Recuperar un registro borrado
async restore(id: string): Promise<User> {
const result = await this.usersRepository.restore(id);
if (result.affected === 0) {
throw new NotFoundException(`Usuario con id ${id} no encontrado o no está eliminado`);
}
return this.findOne(id);
}
restore() pone deletedAt a NULL y el registro vuelve a ser visible.
8.4. Consultar registros borrados
Si necesitas ver los registros borrados (panel de admin, auditoría), usa withDeleted:
// Todos los registros, incluyendo los borrados
const allUsers = await this.usersRepository.find({
withDeleted: true,
});
// Solo los borrados
const deletedUsers = await this.usersRepository.find({
withDeleted: true,
where: { deletedAt: Not(IsNull()) },
});
- Soft delete para datos que necesitas auditar, recuperar o que tienen implicaciones legales (GDPR, facturas)
- Hard delete (remove()) para datos temporales, sesiones, tokens expirados, colas procesadas
- Soft delete en todas las tablas "por si acaso". Las tablas con soft delete crecen para siempre
- Olvidar que los registros soft-deleted siguen contando para índices únicos. Si borras un user con email X e intentas crear otro con el mismo email, falla
8.5. Soft delete e índices únicos: la trampa
Si tienes un índice único en email y haces soft delete de un usuario con domin@domin.es, ese email sigue ocupando el índice. Si alguien intenta registrarse con el mismo email, falla con un error de unique constraint.
La solución es un partial unique index que solo considere registros no borrados:
@Entity('users')
@Index(['email'], { unique: true, where: '"deletedAt" IS NULL' })
export class User {
// ...
}
Ahora el índice único solo aplica a registros activos. Un registro borrado libera el email.
9. Transacciones con QueryRunner
Cuando necesitas ejecutar varias operaciones que deben ser todo o nada (crear un usuario y su perfil, transferir dinero entre cuentas…), usas una 💡 Transaction Grupo de operaciones de base de datos que se ejecutan como una unidad atómica. Si todas tienen éxito, se confirman (COMMIT). Si alguna falla, se revierten todas (ROLLBACK). Garantiza la consistencia de los datos. Más info → .
9.1. QueryRunner: control total
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { User } from './entities/user.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
private readonly dataSource: DataSource,
) {}
async createWithProfile(
createUserDto: CreateUserDto,
createProfileDto: CreateProfileDto,
): Promise<User> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const user = queryRunner.manager.create(User, {
...createUserDto,
password: await bcrypt.hash(createUserDto.password, 10),
});
const savedUser = await queryRunner.manager.save(user);
const profile = queryRunner.manager.create(Profile, {
...createProfileDto,
userId: savedUser.id,
});
await queryRunner.manager.save(profile);
await queryRunner.commitTransaction();
return savedUser;
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
}
Paso a paso:
createQueryRunner(): Crea un runner que controla una conexión dedicada.connect(): Obtiene una conexión del pool.startTransaction(): Inicia la transacción (BEGIN).queryRunner.manager: Usa esteEntityManageren vez del repository normal, todas las operaciones pasan por la misma transacción.commitTransaction(): Si todo va bien, confirma los cambios (COMMIT).rollbackTransaction(): Si algo falla, revierte todo (ROLLBACK), ni el usuario ni el perfil se crean.release(): Devuelve la conexión al pool, siempre enfinallypara evitar leaks.
9.2. El patrón try/catch/finally es obligatorio
Si no haces release(), la conexión se queda abierta. Después de unas cuantas peticiones fallidas sin hacer release, agotarás el pool de conexiones y tu app dejará de funcionar. El finally garantiza que siempre se libera, haya error o no.
9.3. Cuándo usar transacciones
- Cuando creas/actualizas registros en MÚLTIPLES tablas que deben ser consistentes entre sí
- Transferencias de saldo, creación de pedido + items, registro de usuario + perfil
- Cualquier operación donde un fallo parcial dejaría datos inconsistentes
- Para operaciones en una sola tabla. Un save() individual ya es atómico por sí mismo
- Para queries de lectura (SELECT). Las transacciones tienen overhead y no aportan nada en lecturas
- Transacciones muy largas que bloquean filas durante segundos. Mantén las transacciones cortas
10. select: controlar qué campos devuelves
No siempre vas a querer devolver todas las columnas. Especialmente cuando hay passwords, tokens internos o campos pesados:
// Solo algunos campos
const users = await this.usersRepository.find({
select: ['id', 'name', 'email', 'role', 'createdAt'],
});
// En findOne
const user = await this.usersRepository.findOne({
where: { id },
select: ['id', 'name', 'email', 'active', 'role'],
});
Recuerda del post 7: la columna password tiene select: false, así que ya se excluye automáticamente de los find() normales. Pero si tienes otros campos sensibles o pesados (un campo bio de tipo text, por ejemplo), select explícito te da control fino filipino.
11. Existencia y conteo: exists() y count()
Dos métodos que parecen una castaña pero que se usan constantemente:
// ¿Existe un usuario con este email?
const emailTaken = await this.usersRepository.exists({
where: { email: 'domin@domin.es' },
});
// emailTaken: boolean (true/false)
// ¿Cuántos admins activos hay?
const adminCount = await this.usersRepository.count({
where: { role: UserRole.ADMIN, active: true },
});
// adminCount: number
exists() es más eficiente que findOne() cuando solo necesitas saber si el registro existe. Ejecuta SELECT 1 ... LIMIT 1 en vez de cargar toda la entidad.
12. El service completo
Vamos a fijarnos en los detalles:
// src/users/users.service.ts
import {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, ILike, type FindOptionsWhere } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from './entities/user.entity';
import { type CreateUserDto } from './dto/create-user.dto';
import { type UpdateUserDto } from './dto/update-user.dto';
import { type FilterUsersDto } from './dto/filter-users.dto';
import { type PaginatedResult } from '../common/interfaces/paginated-result.interface';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
) {}
async create(createUserDto: CreateUserDto): Promise<User> {
const emailTaken = await this.usersRepository.exists({
where: { email: createUserDto.email },
});
if (emailTaken) {
throw new ConflictException(
`El email ${createUserDto.email} ya está registrado`,
);
}
const user = this.usersRepository.create({
...createUserDto,
password: await bcrypt.hash(createUserDto.password, 10),
});
const savedUser = await this.usersRepository.save(user);
// No devolvemos el password hasheado
const { password: _, ...result } = savedUser;
return result as User;
}
async findAll(filterDto: FilterUsersDto): Promise<PaginatedResult<User>> {
const { page, limit, search, active, role } = filterDto;
const skip = (page - 1) * limit;
const where: FindOptionsWhere<User> = {};
if (search) {
where.name = ILike(`%${search}%`);
}
if (active !== undefined) {
where.active = active;
}
if (role) {
where.role = role;
}
const [data, total] = await this.usersRepository.findAndCount({
where,
order: { createdAt: 'DESC' },
skip,
take: limit,
});
const totalPages = Math.ceil(total / limit);
return {
data,
meta: {
total,
page,
limit,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
};
}
async findOne(id: string): Promise<User> {
const user = await this.usersRepository.findOne({
where: { id },
});
if (!user) {
throw new NotFoundException(`Usuario con id ${id} no encontrado`);
}
return user;
}
async update(id: string, updateUserDto: UpdateUserDto): Promise<User> {
const user = await this.findOne(id);
if (updateUserDto.email && updateUserDto.email !== user.email) {
const emailTaken = await this.usersRepository.exists({
where: { email: updateUserDto.email },
});
if (emailTaken) {
throw new ConflictException(
`El email ${updateUserDto.email} ya está en uso`,
);
}
}
if (updateUserDto.password) {
updateUserDto = {
...updateUserDto,
password: await bcrypt.hash(updateUserDto.password, 10),
};
}
const updatedUser = this.usersRepository.merge(user, updateUserDto);
return this.usersRepository.save(updatedUser);
}
async remove(id: string): Promise<void> {
const user = await this.findOne(id);
await this.usersRepository.softRemove(user);
}
async restore(id: string): Promise<User> {
const result = await this.usersRepository.restore(id);
if (result.affected === 0) {
throw new NotFoundException(
`Usuario con id ${id} no encontrado o no está eliminado`,
);
}
return this.findOne(id);
}
}
Fíjate en los detalles:
- Validación de unicidad de email tanto en
createcomo enupdate(solo si el email cambia). - Hasheo de password en
createyupdate. - Reutilización de
findOne()enupdate,removeyrestorepara centralizar el 404. exists()en vez defindOne()para comprobar el email (más eficiente).- Soft delete con
softRemove()y restauración conrestore().
13. El controller completo
// src/users/users.controller.ts
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { FilterUsersDto } from './dto/filter-users.dto';
import { type User } from './entities/user.entity';
import { type PaginatedResult } from '../common/interfaces/paginated-result.interface';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
@HttpCode(HttpStatus.CREATED)
create(@Body() createUserDto: CreateUserDto): Promise<User> {
return this.usersService.create(createUserDto);
}
@Get()
findAll(@Query() filterDto: FilterUsersDto): Promise<PaginatedResult<User>> {
return this.usersService.findAll(filterDto);
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<User> {
return this.usersService.findOne(id);
}
@Patch(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateUserDto: UpdateUserDto,
): Promise<User> {
return this.usersService.update(id, updateUserDto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
return this.usersService.remove(id);
}
@Patch(':id/restore')
restore(@Param('id', ParseUUIDPipe) id: string): Promise<User> {
return this.usersService.restore(id);
}
}
Detallitos:
ParseUUIDPipeen todos los@Param('id'). Valida que el ID sea un UUID válido antes de que llegue al service, por si alguien mandaGET /users/hola, recibe un 400 sin tocar la base de datos.@Patchpara updates parciales (no@Put). ConPartialTypetodo es opcional, encaja perfecto con PATCH.@HttpCode(HttpStatus.NO_CONTENT)en delete, un 204 sin body es la respuesta estándar para un delete succesfully.@Patch(':id/restore')para restaurar soft deletes, ruta clara y RESTful.
14. Probando el CRUD completo
15. Errores comunes y debugging
”Cannot query across many-to-one/one-to-many”
Estás intentando filtrar por una relación sin hacer join. Lo veremos en el post 9.
El password aparece en las respuestas
Comprueba que la columna password tiene select: false en la entidad. Si usas select explícito en alguna query, asegúrate de no incluir password.
save() hace INSERT en vez de UPDATE
save() decide por la presencia de id. Si haces save({ name: 'Domin' }) sin id, siempre es un INSERT. Asegúrate de que la entidad que pasas a save() tiene el id rellenado (por eso usamos merge() que copia sobre la entidad existente).
”EntityMetadataNotFoundError”
La entidad no está registrada. Comprueba:
TypeOrmModule.forFeature([User])en el módulo.autoLoadEntities: trueenTypeOrmModule.forRootAsync().
El soft delete no filtra los registros borrados
Comprueba que la entidad tiene @DeleteDateColumn(). Sin este decorador, softRemove() funciona pero las queries normales no filtran los registros borrados automáticamente.
16. Recapitulando
@InjectRepository(User) te da un repositorio tipado. find, findOne, save, remove, count, exists... todo built-in.
Siempre create() para instanciar y save() para persistir. Nunca save() directo con un objeto plano.
Para updates: merge() copia las props del DTO sobre la entidad existente. save() detecta el id y hace UPDATE.
findAndCount() + PaginatedResult
softRemove() + @DeleteDateColumn(). Los registros "borrados" se ocultan automáticamente. restore() para recuperar.
QueryRunner con connect/start/commit/rollback/release. try/catch/finally obligatorio. Para operaciones multi-tabla.
En el próximo post completamos el bloque de base de datos con Relaciones, Migraciones y Seeders. Vamos a conectar entidades entre sí con @OneToMany, @ManyToOne, @ManyToMany, montar migraciones con TypeORM CLI para dejar de depender de synchronize, y crear scripts de seed para tener datos de prueba.
EA, nos vemos en los bares!! 🍺
Pon a prueba lo aprendido
1. ¿Cuál es la diferencia entre create() y save() del Repository?
2. ¿Qué método del Repository devuelve los datos Y el total de registros para paginación?
3. ¿Qué hace softRemove() a diferencia de remove()?
4. En una transacción con QueryRunner, ¿por qué es CRÍTICO el bloque finally con release()?
5. ¿Por qué se usa merge() + save() para updates en vez de update() directo?