Lección 2 de 312 min
TypeORM
Entidades con decoradores, relaciones, repositorios, QueryBuilder y migraciones.
Tienes una API en TypeScript con autores, libros y géneros. Quieres que cada tabla tenga una clase con sus tipos, que el editor te autocomplete autor.libros y que guardar un objeto sea una línea. Eso es lo que propone TypeORM: describes las tablas con clases y decoradores, y él genera el SQL.
Es uno de los ORM más veteranos de TypeScript, funciona con PostgreSQL, MySQL, MariaDB, SQLite, SQL Server y otras, y es muy habitual en proyectos de NestJS. En esta lección verás su configuración, cómo se definen entidades y relaciones, cómo se consulta y cómo se cambia el esquema sin perder datos.
Configuración y entidades
DataSource
La conexión a la base de datos y la configuración de todo el ORM, en un objeto.
- Bases de datos:
- PostgreSQL, MySQL, SQLite…
- Lenguaje:
- TypeScript
El DataSource reúne todo lo que TypeORM necesita: el tipo de base de datos, cómo conectarse, qué entidades existen y dónde están las migraciones. Lo creas una vez, llamas a initialize() al arrancar la aplicación y, a partir de ahí, obtienes de él los repositorios.
Por dentro mantiene un pool de conexiones: un grupo de conexiones abiertas que se reutilizan entre peticiones, porque abrir una nueva por cada consulta sería lento. La opción logging te muestra en la consola el SQL que genera, muy útil mientras desarrollas.
Úsalo cuando
Siempre, como punto de partida. Puedes tener varios DataSource si tu aplicación trabaja con más de una base de datos.
Ten en cuenta
Las credenciales van en variables de entorno, no en el código. Y TypeORM usa los decoradores «clásicos» de TypeScript: necesitas experimentalDecorators y emitDecoratorMetadata en tsconfig.json, además de importar reflect-metadata al principio.
import "reflect-metadata";
import { DataSource } from "typeorm";
import { Autor } from "./entidades/Autor";
import { Libro } from "./entidades/Libro";
export const AppDataSource = new DataSource({
type: "postgres",
url: process.env.DATABASE_URL,
entities: [Autor, Libro],
migrations: ["src/migraciones/*.ts"],
synchronize: false, // en producción, siempre false
logging: ["query", "error"], // muestra el SQL generado
});
// Al arrancar la aplicación (por ejemplo, en index.ts):
// await AppDataSource.initialize();Entidades y decoradores
Una clase por tabla; los decoradores dicen qué propiedad es cada columna.
- Decoradores clave:
- @Entity, @Column
Una entidad es una clase marcada con @Entity() que corresponde a una tabla. Cada propiedad con @Column() es una columna, y @PrimaryGeneratedColumn() crea una clave primaria que la base de datos numera sola. Los decoradores son esas anotaciones con @ que añaden información a la clase sin cambiar su código.
TypeORM deduce el tipo de columna a partir del tipo de TypeScript (string → varchar, number → integer), pero puedes indicarlo tú: longitud, si admite null, valor por defecto o si debe ser única. También hay columnas especiales, como @CreateDateColumn(), que guarda la fecha de creación automáticamente.
Úsalo cuando
Para cada tabla con la que trabaje tu aplicación. Las entidades son también la fuente a partir de la cual TypeORM genera las migraciones.
Ten en cuenta
Si una propiedad puede ser null (string | null), TypeORM no puede deducir el tipo: indícalo con type. El ! tras el nombre le dice a TypeScript que el valor lo pondrá el ORM, no un constructor.
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from "typeorm";
@Entity("autores")
export class Autor {
@PrimaryGeneratedColumn()
id!: number;
@Column({ length: 100 })
nombre!: string;
@Column({ type: "text", nullable: true })
biografia!: string | null;
@Column({ default: true })
activo!: boolean;
@CreateDateColumn()
creadoEn!: Date;
}Relaciones
Uno a muchos, muchos a uno y muchos a muchos, declarados en las propias clases.
- Clave foránea:
- En el lado @ManyToOne
Un autor tiene muchos libros y cada libro pertenece a un autor. Se declara por los dos lados: @OneToMany en Autor y @ManyToOne en Libro. La clave foránea vive siempre en el lado «muchos»: la tabla libros tendrá una columna autorId.
Para relaciones muchos a muchos, como libros y géneros, usas @ManyToMany en ambos lados y @JoinTable() en uno de ellos: TypeORM crea la tabla intermedia que guarda las parejas. Con opciones como onDelete: "CASCADE" decides qué pasa con los libros si se borra su autor.
Úsalo cuando
Siempre que dos tablas estén unidas por una clave foránea. Declarar la relación te permite cargarla con relations y consultar a través de ella.
Ten en cuenta
Las relaciones no se cargan solas: sin relations, autor.libros llega como undefined. Existen relaciones perezosas (con tipo Promise<Libro[]>), pero facilitan el problema N+1 que viste en la lección anterior.
@Entity("libros")
export class Libro {
@PrimaryGeneratedColumn() id!: number;
@Column() titulo!: string;
// Muchos libros → un autor. Aquí vive la clave foránea (autorId)
@ManyToOne(() => Autor, (autor) => autor.libros, { onDelete: "CASCADE" })
autor!: Autor;
// Muchos a muchos: @JoinTable crea la tabla intermedia
@ManyToMany(() => Genero, (genero) => genero.libros)
@JoinTable()
generos!: Genero[];
}
// En Autor:
// @OneToMany(() => Libro, (libro) => libro.autor)
// libros!: Libro[];Nota
TypeORM admite los dos patrones que viste en la lección anterior. Con Data Mapper usas repositorios (autores.save(autor)), que es lo más habitual. Si tus entidades heredan de BaseEntity, también puedes usar Active Record (autor.save(), Autor.find()).
Consultar y guardar
Repositorios
find, findOne, save y delete: las operaciones habituales sin escribir SQL.
- Se obtiene con:
- getRepository(Entidad)
Un repositorio agrupa las operaciones de una entidad: lo obtienes con AppDataSource.getRepository(Autor). find devuelve una lista y acepta where, order, take (límite) y relations; findOne y findOneBy devuelven un elemento o null; save inserta si el objeto es nuevo o actualiza si ya existe; delete borra.
Para condiciones que no son igualdades, TypeORM trae operadores como Like, In, MoreThan o IsNull. Un where con un array significa «o»: [{ nombre: "Ana" }, { activo: false }] encuentra a Ana o a los inactivos.
Úsalo cuando
Para la mayoría de operaciones de una aplicación: crear, buscar por campos, actualizar y borrar, incluidas las relaciones con relations.
Ten en cuenta
En where, las propiedades con valor undefined se ignoran. Así, findOneBy({ id: undefined }) no da error: devuelve el primer registro de la tabla. Valida los datos de entrada antes de consultar.
import { Like } from "typeorm";
const autores = AppDataSource.getRepository(Autor);
const ana = autores.create({ nombre: "Ana" });
await autores.save(ana); // INSERT (y rellena ana.id)
const conLibros = await autores.find({
where: { nombre: Like("A%"), activo: true },
relations: { libros: true }, // carga anticipada
order: { nombre: "ASC" },
take: 10,
});
const uno = await autores.findOne({ where: { id: 7 }, relations: { libros: true } });
await autores.delete({ id: 7 });QueryBuilder
Consultas complejas con JOIN, agrupaciones y parámetros, sin salir de TypeORM.
- Parámetros:
- :nombre
Cuando find se queda corto (agregaciones, condiciones complicadas, subconsultas), el QueryBuilder te deja construir la consulta paso a paso, muy cerca del SQL: leftJoinAndSelect, where, groupBy, orderBy. Se crea con createQueryBuilder("alias") y se ejecuta con getMany() (entidades) o getRawMany() (filas tal cual, para totales y cálculos).
Los valores se pasan como parámetros con nombre: escribes :desde en la condición y das su valor en un objeto. Igual que en el SQL a mano, nunca metas un valor dentro del texto de la condición.
Úsalo cuando
Para informes, búsquedas con muchos filtros y cualquier consulta que necesite GROUP BY, COUNT o un JOIN con condiciones propias.
Ten en cuenta
Escribir .where("autor.nombre = '" + nombre + "'") reabre la puerta a la inyección SQL. Y cuando algo es muy complejo, AppDataSource.query() permite ejecutar SQL directamente, también con parámetros.
// Autores activos con cuántos libros han publicado desde 2020
const resumen = await AppDataSource.getRepository(Autor)
.createQueryBuilder("autor")
.leftJoin("autor.libros", "libro")
.select("autor.nombre", "nombre")
.addSelect("COUNT(libro.id)", "total")
.where("autor.activo = :activo", { activo: true })
.andWhere("libro.publicadoEn >= :desde", { desde: "2020-01-01" })
.groupBy("autor.id")
.orderBy("total", "DESC")
.getRawMany();
// [{ nombre: "Ana", total: "12" }, …] (en PostgreSQL, COUNT llega como texto)Evolucionar el esquema
Migraciones y synchronize
Generar migraciones a partir de las entidades; synchronize solo en desarrollo.
- Generar:
- migration:generate
- Aplicar:
- migration:run
Con synchronize: true, TypeORM compara tus entidades con la base de datos cada vez que arranca y la modifica para que coincidan. Es cómodo al empezar un proyecto, pero peligroso: si renombras una propiedad, puede borrar la columna antigua, con sus datos, y crear una nueva vacía.
En producción se usan migraciones. El comando migration:generate compara las entidades con la base de datos y escribe una clase con los ALTER TABLE necesarios en up y su inverso en down. La revisas, la subes a Git y la aplicas con migration:run; si algo va mal, migration:revert deshace la última.
Úsalo cuando
Migraciones en cualquier entorno compartido (pruebas, preproducción, producción). synchronize solo en tu base de datos local, y si quieres, ni siquiera ahí.
Ten en cuenta
Revisa siempre la migración generada antes de aplicarla: un renombrado puede aparecer como «borrar columna y crear otra». En ese caso, edítala para usar RENAME COLUMN y no perder los datos.
// npx typeorm-ts-node-commonjs migration:generate src/migraciones/AnadirBiografia -d src/data-source.ts
// npx typeorm-ts-node-commonjs migration:run -d src/data-source.ts
import { MigrationInterface, QueryRunner } from "typeorm";
export class AnadirBiografia1730985600000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "autores" ADD "biografia" text`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "autores" DROP COLUMN "biografia"`);
}
}synchronize en producción
Un solo despliegue con synchronize: true y una propiedad renombrada puede borrar una columna con datos reales. Desactívalo en la configuración de producción y haz que el CI aplique las migraciones antes de arrancar la nueva versión.
Ponlo en práctica
Saber qué es cada cosa es la mitad del trabajo. La otra mitad es reconocer cuándo usarla.
Interactivo
¿Qué usarías?
Lee cada situación y elige la opción que mejor la resuelve. Fallar aquí no cuenta: es para practicar.
Necesitas un informe con cada autor y el número de libros que publicó desde 2020, ordenado de más a menos.
Para recordar
- El DataSource configura la conexión, las entidades y las migraciones; se inicializa una vez al arrancar.
- Las entidades son clases con decoradores:
@Entitypara la tabla,@Columnpara cada columna y@PrimaryGeneratedColumnpara la clave. - En una relación de uno a muchos, la clave foránea vive en el lado
@ManyToOne; las relaciones solo se cargan si las pides conrelations. - Los repositorios cubren el día a día; el QueryBuilder, los informes y las consultas complejas, siempre con parámetros.
synchronize: truesolo en desarrollo: en cualquier entorno compartido, migraciones generadas, revisadas y versionadas.
Comprueba lo aprendido
Cuestionario
Responde las 5 preguntas para completar la lección.
1.¿Por qué es peligroso
synchronize: trueen producción?2.Entre
Autor(con@OneToMany) yLibro(con@ManyToOne), ¿en qué tabla está la clave foránea?3.Haces
autores.find()y luego leesautores[0].libros. ¿Qué obtienes?4.¿Cuál es la forma correcta de filtrar por un texto del usuario en el QueryBuilder?
5.Un endpoint hace
findOneBy({ id: req.body.id })y alguien envía un cuerpo sinid. ¿Qué pasa?