16. Consultas, QueryBuilder, paginación y el problema N+1
Un ORM no se juzga por lo bonito que queda el código de lectura, sino por el SQL que acaba llegando a la base de datos. Este capítulo enseña a leer consultas en dos direcciones a la vez: del TypeScript al SQL, para anticipar el coste de cada línea, y del SQL al TypeScript, para diagnosticar un endpoint lento sin adivinar. Aquí están las tres formas de consultar en MikroORM, el catálogo completo de operadores, el QueryBuilder, la paginación que aguanta en producción y el patrón que más veces se cuela en un despliegue: el problema N+1.
16.1 Qué vas a poder hacer al terminar
- Elegir con criterio entre la API del
EntityManager, el QueryBuilder y el SQL nativo, y justificar la elección en una revisión de código con argumentos de tipado, mantenimiento y rendimiento. - Predecir el SQL que genera cada consulta: cuántas sentencias, qué joins, qué parámetros y en qué orden se ejecutan.
- Escribir un
endpoint de listado real con filtros opcionales, ordenación estable, proyección y paginación, construyendo el
FilterQueryde forma dinámica y tipada. - Reconocer un N+1
leyendo el log de consultas, corregirlo con la estrategia adecuada (
select-inojoined) y escribir un test que impida su regreso. - Implementar paginación por offset y por cursor, explicar por qué la primera se degrada y por qué la segunda necesita un desempate único.
- Construir informes con agregados, subconsultas y
EXISTSmediante el QueryBuilder, sin caer en SQL nativo por costumbre. - Diagnosticar un endpoint lento con una lista de comprobación reproducible: log de SQL, conteo de
consultas,
EXPLAIN ANALYZEy revisión de índices. - Serializar resultados sin filtrar datos sensibles ni acoplar la API al modelo de persistencia.
Todos los ejemplos usan las seis entidades que venimos construyendo desde el capítulo 15: User, Team, Project, Task, Tag y
Comment. Conviene tener presente la topología, porque de ella depende el SQL:
User ──M:N── Team ──1:N── Project ──1:N── Task ──M:N── Tag
│ │
│ 1:N (owner) │ 1:N
└──────────────► Project ▼
│ Comment
└── 1:N (assignee) ────────────────────► Task
│
Tablas: "user", "team", "project", "task", "tag",
"comment", "task_tags", "team_members"
Claves foráneas: project.team_id, project.owner_id,
task.project_id, task.assignee_id,
comment.task_id, comment.author_id
El SQL de este capítulo está formateado a mano para que se lea bien y corresponde al dialecto de PostgreSQL. Los alias que genera MikroORM (t0, p1, u2…) y el
formato exacto pueden variar entre versiones del ORM y del driver; lo que no varía es la forma de la consulta, que es lo que debes aprender a predecir.
16.2 Las tres formas de consultar y cuándo usar cada una
MikroORM ofrece tres niveles de abstracción para leer datos. No son alternativas excluyentes ni etapas de una evolución: son herramientas para problemas distintos que conviven en el mismo proyecto y a menudo en el mismo repositorio.
┌──────────────────────────────────────────────────────────────────────┐
│ 1) API del EntityManager: em.find / findOne / findAndCount ... │
│ Tipado fuerte · entidades gestionadas · Identity Map · filtros │
│ Cubre ~90 % de las lecturas de una aplicación │
└───────────────────────────────┬──────────────────────────────────────┘ se traduce a…
┌───────────────────────────────▼──────────────────────────────────────┐
│ 2) QueryBuilder: em.createQueryBuilder(Task, 't') │
│ Joins explícitos · GROUP BY · HAVING · agregados · subconsultas │
│ El ~9 %: informes, cuadros de mando, búsquedas complejas │
└───────────────────────────────┬──────────────────────────────────────┘ construye sobre…
┌───────────────────────────────▼──────────────────────────────────────┐
│ 3) Knex (query builder de bajo nivel) → driver → SQL │
│ qb.getKnexQuery() como escotilla de emergencia │
└───────────────────────────────┬──────────────────────────────────────┘
┌───────────────────────────────▼──────────────────────────────────────┐
│ 4) SQL nativo: em.getConnection().execute(sql, params) │
│ El ~1 %: funciones del motor, CTE recursivas, operaciones masivas │
└──────────────────────────────────────────────────────────────────────┘
Lo importante del diagrama es que todo desemboca en el mismo sitio. La API del EntityManager no es «más lenta» que el QueryBuilder: para la misma consulta genera el mismo SQL.
La diferencia está en lo que puedes expresar y en lo que el compilador puede verificar por ti.
| Criterio | API del EntityManager | QueryBuilder | SQL nativo |
|---|---|---|---|
| Tipado | Fuerte y completo: campos, operadores, rutas de populate y tipo de retorno derivado | Parcial: los alias y las expresiones son cadenas que el compilador no verifica | Ninguno: una cadena de texto |
| Resultado | Entidades gestionadas por el Identity Map | Entidades con getResult(), objetos planos con execute() | Filas planas con nombres de columna de la base de datos |
| Unit of Work | Integrado: los cambios se detectan y se persisten en el flush | Integrado si mapeas a entidades; ignorado si ejecutas en crudo | Al margen por completo |
Agregados y GROUP BY | Muy limitado (groupBy/having existen, pero el resultado sigue siendo entidades) | Su terreno natural | Sin límites |
| Complejidad que admite | Condiciones anidadas, operadores lógicos, filtros sobre relaciones | Joins con condiciones extra, subconsultas, ventanas, expresiones crudas | Cualquier cosa que entienda el motor |
| Coste de refactorizar | Bajo: renombras un campo y el compilador señala todos los usos | Medio: los alias en cadenas se rompen en silencio | Alto: nada avisa hasta que falla en ejecución |
| Portabilidad de motor | Alta | Alta salvo en las expresiones crudas | Nula |
| Rendimiento máximo alcanzable | Suficiente para casi todo si usas fields y populate con cabeza | Igual que el SQL nativo en la práctica | Techo teórico |
| Riesgo de inyección SQL | Prácticamente nulo: todo va parametrizado | Bajo, salvo si interpolas cadenas en raw() | Alto si concatenas; nulo si parametrizas |
em.find(). Sube al QueryBuilder cuando necesites algo que la API declarativa no expresa: un agregado, un
GROUP BY, una subconsulta correlacionada, un join con condición adicional. Baja a SQL nativo solo cuando el QueryBuilder te obligue a contorsiones peores que el propio SQL. Y documenta en
un comentario por qué bajaste: el siguiente que lo lea agradecerá no tener que reconstruir tu razonamiento. EntityManager es la mise en place: el 90 % del servicio sale de ahí, con recetas repetibles y
control de calidad. El QueryBuilder es el trabajo a cuchillo para un plato concreto: más libertad, más responsabilidad. El SQL nativo es el soplete: resuelve en dos segundos lo que de otro modo es
imposible, y quema la cocina si lo dejas encendido sin vigilancia. 16.3 em.find() y familia
16.3.1 La firma completa
Toda la familia de métodos de lectura comparte la misma estructura de tres argumentos: qué entidad, qué condición y cómo traerlo.
// Firma simplificada (los genéricos se infieren; nunca los escribes a mano)
em.find<Entity, Hint, Fields, Excludes>(
entityName: EntityName<Entity>, // la clase: Task, User, Project…
where: FilterQuery<Entity>, // el objeto de condiciones (sección 16.4)
options?: FindOptions<Entity, Hint, Fields, Excludes>,
): Promise<Loaded<Entity, Hint, Fields, Excludes>[]>;El tipo de retorno merece un párrafo entero. Loaded<Task, 'assignee'> no es lo mismo que Task: es un Task en el que TypeScript sabe que la
relación assignee ya está cargada, y por tanto te deja acceder a task.assignee.email sin comprobaciones. Si no declaras el populate, el acceso a la relación no
compila (con la configuración estricta recomendada) o devuelve una referencia sin inicializar. El sistema de tipos está impidiendo un N+1 en tiempo de compilación, y esa es una de las mejores
ideas de MikroORM v5/v6.
import { EntityManager, NotFoundError, QueryOrder } from '@mikro-orm/core';
// 1 · find → lista (array vacío si no hay nada, nunca null)
const abiertas = await em.find(Task, { status: 'doing' });
// 2 · findOne → entidad o null
const tarea = await em.findOne(Task, { id: 42 });
if (!tarea) { /* decide tú qué significa */ }
// 3 · findOneOrFail → entidad o excepción (NotFoundError por defecto)
const obligatoria = await em.findOneOrFail(Task, { id: 42 });
// 4 · findAndCount → [página, total] en DOS consultas
const [items, total] = await em.findAndCount(Task, { status: 'todo' },
{ limit: 20, offset: 40, orderBy: { createdAt: QueryOrder.DESC } });
// 5 · findAll → sin argumento de condición; el where va dentro de las opciones
const todas = await em.findAll(Task, { orderBy: { id: 'asc' }, limit: 100 });
// 6 · count → solo el número, sin materializar entidades
const pendientes = await em.count(Task, { status: { $ne: 'done' } });
// 7 · findByCursor → paginación por keyset (sección 16.9)
const pagina = await em.findByCursor(Task, { status: 'todo' },
{ first: 20, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }] });-- 1 · find
select "t0".* from "task" as "t0" where "t0"."status" = $1;
-- 4 · findAndCount: SIEMPRE dos sentencias
select "t0".* from "task" as "t0" where "t0"."status" = $1
order by "t0"."created_at" desc limit $2 offset $3;
select count(*) as "count" from "task" as "t0" where "t0"."status" = $1;
-- 6 · count
select count(*) as "count" from "task" as "t0" where "t0"."status" != $1;findAndCount no es gratis Son dos viajes a la base de datos, y el count(*) sobre una tabla grande con filtros poco selectivos puede costar
más que la propia página de datos. Si el cliente no necesita el total exacto (una lista con «cargar más» no lo necesita), usa find con limit + 1 para saber si hay página
siguiente, o pasa a paginación por cursor. Ver 16.9. 16.3.2 findOneOrFail y el failHandler
La diferencia entre findOne y findOneOrFail no es de comodidad, es de diseño. Cuando la ausencia del registro es un caso de negocio previsto (¿existe ya un usuario
con este email?), usa findOne y trata el null. Cuando la ausencia es un error (el cliente pide la tarea 42 y no existe), usa findOneOrFail: expresa la
intención, elimina una rama muerta y evita que un null viaje sin control por tus capas.
Por defecto lanza NotFoundError, de @mikro-orm/core, que en NestJS se traduciría a un 500 si nadie lo captura. Hay tres formas de arreglarlo, de la más local a la más
global:
import { NotFoundException } from '@nestjs/common';
async findOneForUser(id: number, userId: number): Promise<Task> {
return this.em.findOneOrFail(
Task,
{ id, assignee: userId },
{
populate: ['project', 'tags'],
// failHandler recibe el nombre de la entidad y la condición usada.
// Devuelve (no lanza) el Error que MikroORM lanzará por ti.
failHandler: (entityName, where) =>
new NotFoundException(`No se encontró ${entityName} con ${JSON.stringify(where)}`),
},
);
}JSON.stringify(where) es cómodo en desarrollo y peligroso en producción: revela nombres de
columna, identificadores internos y, si la condición incluye datos de otro usuario, información ajena. En producción devuelve un mensaje neutro («Tarea no encontrada») y registra la condición completa
en el log del servidor con el identificador de correlación. Es exactamente la misma disciplina del capítulo 12. import { defineConfig } from '@mikro-orm/postgresql';
import { NotFoundException } from '@nestjs/common';
export default defineConfig({
// ...
// Se aplica a TODOS los findOneOrFail que no traigan su propio failHandler.
findOneOrFailHandler: (entityName) =>
new NotFoundException(`${entityName} no encontrado`),
// Variante para findOneOrFail con { strict: true }, que además falla
// si la condición devuelve MÁS de una fila.
findExactlyOneOrFailHandler: (entityName) =>
new NotFoundException(`${entityName} no identifica un único registro`),
});La tercera opción, y la que este libro prefiere en aplicaciones grandes, es no acoplar el ORM al framework HTTP: deja el NotFoundError nativo y captúralo en un exception
filter de Nest que lo traduzca a 404 (capítulo 10). Así el dominio no importa @nestjs/common y el mismo servicio sirve para un comando de consola o para un consumidor de cola.
@Get(':id')
async detail(@Param('id') id: number) {
// findOneOrFail lanza NotFoundError de MikroORM.
// Nadie lo captura → 500 Internal Server Error
// y un registro de error espurio en el log.
return this.em.findOneOrFail(Task, { id });
}@Get(':id')
async detail(@Param('id', ParseIntPipe) id: number) {
// El servicio traduce el error de dominio a error HTTP
// (o lo hace un exception filter global).
const task = await this.tasks.findOneOrThrow(id);
return TaskDetailDto.from(task); // DTO, no entidad (16.11)
}16.3.3 FindOptions: el catálogo completo
Aquí está el verdadero poder de la API declarativa. Merece la pena leer esta tabla entera una vez y volver a ella como referencia: la mitad de los problemas de rendimiento que verás en tu carrera se resuelven con dos o tres de estas opciones bien puestas.
| Opción | Tipo | Qué hace y cuándo usarla |
|---|---|---|
populate | string[] | false | Rutas de relaciones a cargar (['project', 'project.owner', 'tags']). Tipado con AutoPath: el
editor autocompleta las rutas válidas. La herramienta principal contra el N+1. Admite comodines ('*') que conviene evitar en producción. |
fields | string[] | Proyección parcial: solo esas columnas viajan. Admite rutas de relación (['id', 'title', 'assignee.email']). Ver 16.10
y sus consecuencias. |
exclude | string[] | El complemento de fields: trae todo menos lo indicado. Útil para excluir una columna grande
(description, un bytea) sin enumerar las otras veinte. |
orderBy | objeto | objeto[] | Ordenación. Con array se garantiza el orden de los criterios. Admite relaciones anidadas ({ project: { name: 'asc'
} }), lo que genera un join implícito. Constantes útiles: QueryOrder.ASC, DESC, y variantes con NULLS FIRST/LAST. |
limit / offset | number | Paginación por desplazamiento. Cuidado con las relaciones a-muchos cargadas con joined: el
limit se aplica a filas, no a entidades raíz (16.8 y 16.9). |
filters | boolean | string[] | objeto | Activa, desactiva o parametriza los filtros globales declarados con @Filter (borrado lógico,
multi-tenant…). filters: false los desactiva todos: úsalo solo en tareas administrativas. Ver capítulo 17. |
strategy | LoadStrategy | SELECT_IN (varias consultas con IN) o JOINED (una sola con LEFT JOIN). Es
la decisión de rendimiento del capítulo: 16.8. |
flushMode | FlushMode | Controla si la consulta provoca un flush previo de los cambios pendientes. Explica el clásico «he creado la
entidad y la consulta no la encuentra»: 16.13. |
lockMode | LockMode | Bloqueo pesimista (PESSIMISTIC_READ, PESSIMISTIC_WRITE → FOR UPDATE) u optimista por
versión. Requiere transacción activa para los bloqueos pesimistas. Ver capítulo 17. |
cache | boolean | number | [string, number] | Caché de resultados del ORM: activarla, con TTL en milisegundos, o con clave explícita para poder invalidarla. Ver 16.12. |
disableIdentityMap | boolean | Ejecuta la consulta en un contexto desechable: las entidades no quedan registradas en el Identity Map y no se pueden actualizar. Para lecturas masivas de solo lectura (16.10). |
refresh | boolean | Vuelve a leer de la base de datos y sobrescribe el estado de las entidades ya presentes en el Identity Map. Sin él, MikroORM devuelve la instancia que ya tenía en memoria. |
convertCustomTypes | boolean | Controla la conversión de los tipos personalizados (Type) en las condiciones y en el resultado. Solo se
toca en escenarios de bajo nivel, cuando pasas valores ya convertidos. |
comment / hintComment | string | string[] | Inserta un comentario SQL antes de la sentencia o como hint del optimizador. Oro puro
para trazabilidad: te permite localizar en pg_stat_statements o en el log del motor qué endpoint ha lanzado esa consulta. |
groupBy / having · flags | string[] · QueryFlag[] | Agrupación (el resultado sigue siendo entidades: para informes
de verdad, QueryBuilder) y modificadores de bajo nivel. El más útil de estos: QueryFlag.PAGINATE, que envuelve la consulta en una subconsulta para paginar bien con joins a colecciones. |
populateWhere / populateOrderBy | objeto / PopulateHint | Condición y orden aplicados a las relaciones cargadas, no a la raíz.
PopulateHint.INFER propaga la condición principal al join; PopulateHint.ALL trae la colección completa. El valor por defecto ha cambiado entre versiones: compruébalo
en la documentación de la tuya. |
logging · schema · connectionType · ctx | varios | Log de esa consulta concreta, esquema (multi-tenant), réplica de lectura y transacción explícita. |
const [tasks, total] = await this.em.findAndCount(
Task,
{ project: { team: { members: { id: userId } } }, status: { $ne: 'done' } },
{
populate: ['assignee', 'tags'],
fields: ['id', 'title', 'status', 'priority', 'dueDate', 'assignee.name', 'tags.name'],
orderBy: [{ priority: 'desc' }, { dueDate: 'asc' }, { id: 'asc' }],
limit: 25, offset: 0,
strategy: LoadStrategy.SELECT_IN,
cache: 5_000, // 5 s de caché de resultado
comment: 'GET /api/tasks · listado del panel', // aparece en el log del motor
},
);/* GET /api/tasks · listado del panel */
select "t0"."id", "t0"."title", "t0"."status", "t0"."priority", "t0"."due_date", "t0"."assignee_id"
from "task" as "t0"
inner join "project" as "p1" on "t0"."project_id" = "p1"."id"
inner join "team" as "t2" on "p1"."team_id" = "t2"."id"
inner join "team_members" as "t3" on "t2"."id" = "t3"."team_id"
where "t3"."user_id" = $1 and "t0"."status" != $2
order by "t0"."priority" desc, "t0"."due_date" asc, "t0"."id" asc limit $3;
-- populate de assignee (SELECT_IN), solo los campos pedidos
select "u0"."id", "u0"."name" from "user" as "u0" where "u0"."id" in ($1, $2, $3);
-- populate de tags a través de la tabla puente
select "t1"."name", "t0"."task_id" as "fk__task_id", "t0"."tag_id" as "fk__tag_id"
from "tag" as "t1" inner join "task_tags" as "t0" on "t1"."id" = "t0"."tag_id"
where "t0"."task_id" in ($1, $2, $3);Tres sentencias para una pantalla completa con dos relaciones cargadas y proyección parcial. Ese es el objetivo: un número de consultas constante, independiente del número de filas. Todo el capítulo gira alrededor de esa idea.
comment desde el primer día Un comentario con el método y la ruta del endpoint cuesta una línea y convierte el log del motor en un mapa navegable.
Cuando a las tres de la mañana veas una consulta de 4 segundos en pg_stat_statements, sabrás exactamente qué controlador la lanzó sin tener que reproducirla. 16.4 El objeto de condiciones: FilterQuery al completo
FilterQuery<T> es un tipo condicional recursivo que describe «cualquier condición válida sobre la entidad T». Su sintaxis está inspirada en la de MongoDB, y eso
desconcierta a quien viene de otros ORM de SQL. La ventaja es enorme: las condiciones son datos, no cadenas. Se pueden construir, combinar, guardar, pasar como parámetro y probar unitariamente
sin tocar la base de datos.
16.4.1 Igualdad, claves primarias y relaciones
// Igualdad simple: la forma más común
await em.find(Task, { status: 'todo' });
// varias claves = AND implícito
await em.find(Task, { status: 'todo', priority: 3 });
// Por clave primaria: tres formas equivalentes
await em.findOne(Task, 42); // atajo: el escalar es la PK
await em.findOne(Task, { id: 42 }); // explícito
await em.findOne(Task, { id: [1, 2, 3] }); // un array en la PK equivale a $in
// Por relación: con la ENTIDAD, con el IDENTIFICADOR (sin cargar nada) o de forma explícita
await em.find(Task, { project: em.getReference(Project, 7) });
await em.find(Task, { project: 7 });
await em.find(Task, { project: { id: 7 } });
// Relación nula: tareas sin responsable asignado
await em.find(Task, { assignee: null });select "t0".* from "task" as "t0" where "t0"."status" = $1 and "t0"."priority" = $2;
select "t0".* from "task" as "t0" where "t0"."id" in ($1, $2, $3);
select "t0".* from "task" as "t0" where "t0"."project_id" = $1; -- las tres formas por relación
select "t0".* from "task" as "t0" where "t0"."assignee_id" is null;{ project: 7 } no hace ningún join Cuando filtras por el identificador de una relación M:1, la columna ya está en la tabla de la
entidad (task.project_id): MikroORM compara directamente y no necesita unir con project. Solo cuando filtras por un campo no clave de la relación ({ project: {
name: 'X' } }) aparece el join. Esta distinción, que parece trivial, es la diferencia entre una consulta de 2 ms y una de 200 ms en tablas grandes. 16.4.2 El catálogo completo de operadores
| Operador | SQL equivalente | Ejemplo | Notas |
|---|---|---|---|
$eq | = | { status: { $eq: 'done' } } | Redundante con la igualdad simple; útil al construir filtros de forma programática. |
$ne | != | { status: { $ne: 'done' } } | Ojo: en SQL, columna != valor no devuelve las filas con
NULL. |
$in | in (…) | { status: { $in: ['todo', 'doing'] } } | Con array vacío genera una condición siempre falsa; compruébalo antes de construirla. |
$nin | not in (…) | { id: { $nin: [1, 2] } } | Mismo problema con NULL que $ne. |
$gt $gte | > >= | { priority: { $gte: 3 } } | Válidos en números, fechas y cadenas. |
$lt $lte | < <= | { dueDate: { $lt: new Date() } } | Un rango se expresa combinando ambos en el mismo objeto. |
$like | like | { title: { $like: '%api%' } } | Sensible a mayúsculas en PostgreSQL. Un comodín inicial impide usar el índice B-tree. |
$ilike | ilike | { title: { $ilike: '%api%' } } | Insensible a mayúsculas. Específico de PostgreSQL. |
$re | regexp / ~ | { title: { $re: '^\\[urgente\\]' } } | Potente y caro. Riesgo de retroceso catastrófico si la expresión viene del cliente. |
$fulltext | to_tsquery, match…against | { title: { $fulltext: 'servidor rápido' } } | Requiere que la propiedad se haya declarado con índice de texto completo en la entidad. La sintaxis exacta depende del motor. |
$overlap | && | { labels: { $overlap: ['a', 'b'] } } | Arrays de PostgreSQL: «tienen algún elemento en común». |
$contains | @> | { labels: { $contains: ['a'] } } | «Contiene todos los elementos». También sobre jsonb. |
$contained | <@ | { labels: { $contained: ['a', 'b', 'c'] } } | El inverso: «está contenido en». |
$exists | is not null | { assignee: { $exists: true } } | No es el EXISTS de SQL: es una comprobación de nulidad.
$exists: false genera is null. |
$and | and | { $and: [{ a: 1 }, { b: 2 }] } | Implícito entre las claves de un mismo objeto; explícito cuando repites campo. |
$or | or | { $or: [{ status: 'todo' }, { priority: 5 }] } | Se agrupa entre paréntesis automáticamente. |
$not | not (…) | { $not: { status: 'done' } } | Niega el bloque completo, no un campo. |
$some | exists (…) | { tags: { $some: { name: 'urgente' } } } | Sobre colecciones: «al menos un elemento cumple». |
$none | not exists (…) | { comments: { $none: {} } } | «Ningún elemento cumple». Con {}: la colección está
vacía. |
$every | not exists (… not …) | { tags: { $every: { name: { $ne: 'spam' } } } } | «Todos cumplen». Cuidado: por lógica de conjuntos, una colección vacía cumple siempre. |
$null Es una confusión frecuente. El nulo se expresa con el valor null: { assignee: null } genera
is null, y { assignee: { $ne: null } } genera is not null. Como alternativa legible existe $exists: true | false, que traduce literalmente a
is not null / is null. Si ves $null en algún ejemplo de internet, viene de otra librería. Y recuerda la trampa de SQL de tres valores: status !=
'done' descarta las filas donde status es NULL, porque NULL != 'done' no es cierto, es desconocido. 16.4.3 Combinar condiciones: $and, $or, $not
// Rango: dos operadores sobre el MISMO campo, en el mismo objeto
await em.find(Task, { dueDate: { $gte: inicio, $lte: fin } });
// $or de bloques completos (con AND implícito dentro de cada rama)
await em.find(Task, { $or: [{ status: 'doing' }, { priority: 5, dueDate: { $lt: new Date() } }] });
// Anidamiento: (proyecto = 7) AND (título contiene 'api' OR etiqueta = 'api')
await em.find(Task, {
project: 7,
$or: [{ title: { $ilike: '%api%' } }, { tags: { name: 'api' } }],
});
// $and explícito: obligatorio cuando repites el mismo campo con distintas condiciones
await em.find(Task, {
$and: [{ title: { $ilike: '%api%' } }, { title: { $not: { $ilike: '%deprecated%' } } }],
});
// $not niega un bloque entero
await em.find(Task, { $not: { status: { $in: ['done', 'archived'] } } });select "t0".*
from "task" as "t0"
left join "task_tags" as "t2" on "t0"."id" = "t2"."task_id"
left join "tag" as "t1" on "t2"."tag_id" = "t1"."id"
where "t0"."project_id" = $1
and ("t0"."title" ilike $2 or "t1"."name" = $3);left join a una colección puede duplicar filas Si una tarea tiene tres etiquetas y el título también encaja, el join produce tres
filas para la misma tarea. MikroORM las deduplica al hidratar las entidades (el Identity Map garantiza una instancia por clave primaria), pero el count(*) y el limit
operan sobre filas, no sobre entidades. De ahí vienen los totales inflados y las páginas de 17 elementos cuando pediste 20. Las soluciones están en 16.8 y 16.9: $some en lugar del
join, distinct, o QueryFlag.PAGINATE. 16.4.4 Condiciones sobre relaciones anidadas y el join implícito
Esta es, probablemente, la característica más productiva de FilterQuery: puedes escribir una condición sobre cualquier punto del grafo de entidades y MikroORM construye los joins
necesarios. Sin escribir un solo JOIN.
// Tareas cuyos proyectos pertenecen a un equipo del que es miembro este usuario,
// y cuyo propietario tiene un email de la organización.
const tasks = await em.find(Task, {
project: {
owner: { email: { $ilike: '%@acme.com' } },
team: { members: { id: userId } },
},
status: { $ne: 'done' },
});select "t0".*
from "task" as "t0"
inner join "project" as "p1" on "t0"."project_id" = "p1"."id"
inner join "user" as "u2" on "p1"."owner_id" = "u2"."id"
inner join "team" as "t3" on "p1"."team_id" = "t3"."id"
inner join "team_members" as "t4" on "t3"."id" = "t4"."team_id"
where "u2"."email" ilike $1
and "t4"."user_id" = $2
and "t0"."status" != $3;tasks[0].project.owner.email no está disponible: si accedes a él sin populate, o recibes undefined, o provocas una carga
perezosa por cada tarea, que es precisamente el N+1. Filtrar es where; cargar es populate. Son dos decisiones independientes. 16.4.5 Consultas sobre colecciones: $some, $none, $every
Cuando la condición es de cuantificación («las tareas que tengan alguna etiqueta urgente», «las que no tengan ningún comentario»), el join implícito es la herramienta equivocada: duplica
filas y complica el conteo. MikroORM ofrece tres operadores que se traducen a subconsultas EXISTS, que son además lo que el planificador de PostgreSQL sabe optimizar mejor.
// $some: al menos una etiqueta llamada 'urgente'
await em.find(Task, { tags: { $some: { name: 'urgente' } } });
// $some sin condición: la colección tiene AL MENOS un elemento
await em.find(Task, { comments: { $some: {} } });
// $none: ninguna etiqueta 'interna' · y con {}: colección VACÍA
await em.find(Task, { tags: { $none: { name: 'interna' } } });
await em.find(Task, { comments: { $none: {} } }); // tareas sin comentarios
// $every: TODAS las etiquetas están en la lista permitida
await em.find(Task, { tags: { $every: { name: { $in: ['api', 'bug', 'ux'] } } } });-- $some
select "t0".* from "task" as "t0"
where exists (
select 1 from "task_tags" as "tt"
inner join "tag" as "tg" on "tt"."tag_id" = "tg"."id"
where "tt"."task_id" = "t0"."id" and "tg"."name" = $1
);
-- $none con {} → tareas sin ningún comentario
select "t0".* from "task" as "t0"
where not exists (select 1 from "comment" as "c" where "c"."task_id" = "t0"."id");
-- $every → "no existe ningún elemento que INCUMPLA"
select "t0".* from "task" as "t0"
where not exists (
select 1 from "task_tags" as "tt" inner join "tag" as "tg" on "tt"."tag_id" = "tg"."id"
where "tt"."task_id" = "t0"."id" and not ("tg"."name" in ($1, $2, $3))
);// Join implícito a una colección + findAndCount:
// el total viene inflado y la página trae menos
// elementos de los pedidos.
const [items, total] = await em.findAndCount(
Task, { tags: { name: 'urgente' } }, { limit: 20 },
);
// total = 137 filas del join, no 54 tareas// $some → EXISTS: una fila por tarea.
// El total y el limit cuentan tareas, como esperas.
const [items, total] = await em.findAndCount(
Task, { tags: { $some: { name: 'urgente' } } },
{ limit: 20, populate: ['tags'] },
);
// total = 54 tareas16.4.6 Condiciones sobre campos JSON
Supongamos que Task tiene una propiedad meta declarada como @Property({ type: 'json' }) con contenido libre ({ source: 'slack', slaHours: 8, labels:
['api'] }). MikroORM permite navegar el documento con la misma notación de punto de las relaciones.
// Igualdad sobre una clave del documento
await em.find(Task, { meta: { source: 'slack' } });
// Comparación numérica dentro del JSON
await em.find(Task, { meta: { slaHours: { $lte: 8 } } });
// Anidamiento y operadores de contención (PostgreSQL, jsonb)
await em.find(Task, { meta: { client: { tier: 'gold' } } });
await em.find(Task, { meta: { $contains: { labels: ['api'] } } });select "t0".* from "task" as "t0" where "t0"."meta"->>'source' = $1;
select "t0".* from "task" as "t0" where ("t0"."meta"->>'slaHours')::numeric <= $1;
select "t0".* from "task" as "t0" where "t0"."meta"->'client'->>'tier' = $1;
select "t0".* from "task" as "t0" where "t0"."meta" @> $1;Tres advertencias que debes interiorizar. Primera: el operador ->> devuelve texto, así que la comparación numérica requiere una conversión, y una conversión en el
WHERE impide usar un índice normal. Necesitas un índice de expresión (create index … on task (((meta->>'slaHours')::int))) o un índice GIN para jsonb.
Capítulo 19.
Segunda: el tipado. TypeScript solo sabe lo que declares en el tipo de meta; si es Record<string, unknown>, no hay verificación de las claves que
consultas.
Tercera: si consultas un campo JSON con frecuencia, probablemente debería ser una columna. El JSON es para datos genuinamente heterogéneos, no para posponer el modelado.
16.4.7 Construcción dinámica y segura de filtros
Aquí está el patrón que vas a escribir docenas de veces en tu vida profesional: un endpoint de listado con filtros opcionales. Casi todo el mundo lo hace mal la primera vez, y el resultado es un
objeto any lleno de if anidados en el que se cuelan comparaciones que nunca aciertan.
La disciplina es doble. Uno: los parámetros llegan validados y con el tipo correcto desde un DTO (class-validator + class-transformer, capítulo 10). Dos: el
filtro se declara como FilterQuery<Task>, de modo que el compilador verifique cada rama.
import { Type } from 'class-transformer';
import { IsArray, IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export type TaskStatus = 'todo' | 'doing' | 'done' | 'archived';
const SORTABLE = ['createdAt', 'dueDate', 'priority', 'title'] as const;
export class ListTasksQuery {
@IsOptional() @IsString() q?: string;
@IsOptional() @IsArray() @IsEnum(['todo', 'doing', 'done', 'archived'], { each: true }) status?: TaskStatus[];
// CRÍTICO: sin @Type, el valor llega como la CADENA "3" y la comparación falla o hace un cast implícito
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(5) minPriority?: number;
@IsOptional() @Type(() => Number) @IsInt() projectId?: number;
@IsOptional() @Type(() => Date) dueBefore?: Date;
@IsOptional() @IsArray() @IsString({ each: true }) tags?: string[];
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) limit = 20;
@IsOptional() @Type(() => Number) @IsInt() @Min(0) offset = 0;
// Lista BLANCA de campos ordenables: nunca aceptes un nombre de columna arbitrario del cliente
@IsOptional() @IsEnum(SORTABLE) sort: (typeof SORTABLE)[number] = 'createdAt';
@IsOptional() @IsEnum(['asc', 'desc']) dir: 'asc' | 'desc' = 'desc';
}import { EntityManager, FilterQuery, FindOptions } from '@mikro-orm/core';
export class TasksRepository {
constructor(private readonly em: EntityManager) {}
/** Traduce la consulta HTTP ya validada a una condición de MikroORM. Función PURA: testeable sin base de datos. */
buildFilter(q: ListTasksQuery, viewerId: number): FilterQuery<Task> {
// La primera condición SIEMPRE se aplica: el alcance del usuario no es opcional.
const and: FilterQuery<Task>[] = [{ project: { team: { members: { id: viewerId } } } }];
if (q.q) and.push({ title: { $ilike: `%${q.q}%` } });
// Comprueba la LONGITUD, no solo la existencia: [] generaría "in ()" y no devolvería nada
if (q.status?.length) and.push({ status: { $in: q.status } });
// !== undefined, no truthy: el 0 es un valor legítimo
if (q.minPriority !== undefined) and.push({ priority: { $gte: q.minPriority } });
if (q.projectId !== undefined) and.push({ project: q.projectId }); // por id: sin join
if (q.dueBefore) and.push({ dueDate: { $lte: q.dueBefore } });
if (q.tags?.length) and.push({ tags: { $some: { name: { $in: q.tags } } } }); // EXISTS, no join
return and.length === 1 ? and[0] : { $and: and };
}
buildOptions(q: ListTasksQuery): FindOptions<Task, 'assignee' | 'tags'> {
return {
populate: ['assignee', 'tags'],
// Desempate por clave primaria: sin él, el orden entre iguales no es estable (16.9)
orderBy: [{ [q.sort]: q.dir }, { id: 'asc' }],
limit: q.limit, offset: q.offset, comment: 'GET /api/tasks',
};
}
async list(q: ListTasksQuery, viewerId: number) {
const [items, total] = await this.em.findAndCount(Task, this.buildFilter(q, viewerId), this.buildOptions(q));
return { items, total };
}
}// 1. any: ni un solo campo verificado
// 2. Recibe req.query en crudo, sin validar ni convertir
// 3. Truthy: minPriority = 0 se ignora en silencio
// 4. sort del cliente → cualquier columna, incluso privada
// 5. Sin restricción de tenant: cualquiera lo ve todo
async list(query: any) {
const where: any = {};
if (query.status) where.status = query.status;
if (query.minPriority) {
// "3" >= 3 en SQL puede fallar o hacer un cast implícito
where.priority = { $gte: query.minPriority };
}
return this.em.find(Task, where, {
orderBy: { [query.sort]: query.dir },
limit: query.limit, // "1000000" como cadena
});
}// 1. DTO validado y transformado por el ValidationPipe
// 2. FilterQuery<Task>: el compilador verifica campos
// y operadores
// 3. !== undefined: respeta el 0 y la cadena vacía
// 4. sort restringido por lista blanca en el DTO
// 5. El alcance del usuario se añade SIEMPRE
async list(
@Query() q: ListTasksQuery,
viewerId: number,
) {
const where = this.repo.buildFilter(q, viewerId);
const opts = this.repo.buildOptions(q);
const [items, total] =
await this.em.findAndCount(Task, where, opts);
return { items: items.map(TaskListDto.from), total };
}buildFilter arranca con la condición de pertenencia al equipo y las demás se
acumulan encima. No es un detalle estético: si el alcance del usuario se añade con un if al final, cualquier refactor que reordene el código puede dejarlo fuera y convertir el listado en
una fuga de datos entre clientes. Los filtros globales de MikroORM (@Filter, capítulo 17) automatizan esto y son la solución estructural en aplicaciones multi-tenant. buildFilter no toca la base de datos: recibe un DTO y devuelve un objeto. Eso permite escribir veinte
pruebas unitarias de milisegundos que verifican la traducción de cada combinación de parámetros, sin levantar PostgreSQL. Es uno de los mejores retornos de inversión en pruebas de todo el backend.
16.5 Escritura: del Unit of Work al SQL directo
Este capítulo trata de consultas, pero la escritura pertenece aquí por una razón: MikroORM ofrece dos caminos radicalmente distintos para modificar datos, y confundirlos produce los errores más difíciles de diagnosticar de toda la Parte IV. Un camino pasa por el Unit of Work; el otro lo esquiva.
CAMINO GESTIONADO (Unit of Work) CAMINO DIRECTO (native)
──────────────────────────────── ───────────────────────────
em.create / em.assign em.insert / insertMany
entity.prop = valor em.nativeUpdate
em.remove em.nativeDelete · em.upsert
▼ Identity Map + instantánea ▼
▼ em.flush() ▼
┌──────────────────────────┐ ┌────────────────────────────┐
│ · calcula los changesets │ │ · una sentencia, ya │
│ · ordena por dependencia │ │ · SIN hooks de ciclo vida │
│ · ejecuta hooks │ │ · SIN Identity Map │
│ · comprueba la versión │ │ · SIN control de versión │
│ · todo en una transacción│ │ · SIN validación de la ent.│
└────────────┬─────────────┘ └─────────────┬──────────────┘
└──────────────► SQL ◄──────────────────┘
16.5.1 em.create frente a new
Se puede crear una entidad con new Task() y asignar propiedades a mano. Funciona, y en algunos proyectos es la convención. Pero em.create() hace cuatro cosas más que
conviene conocer antes de descartarlo:
- Resuelve las relaciones a partir de identificadores:
{ project: 7 }se convierte en una referencia aProjectsin consultar la base de datos. - Aplica los valores por defecto declarados en la entidad y respeta los tipos personalizados.
- Marca la entidad como
persistautomáticamente (sipersistOnCreateestá activo, que es lo habitual), de modo que el siguienteflushla inserta. - Comprueba en tiempo de compilación que le pasas todos los campos
obligatorios: si a
Taskle faltatitle, no compila. Connew+ asignaciones, ese olvido aparece como un error de restricciónNOT NULLen ejecución.
const task = new Task();
task.title = dto.title;
task.status = dto.status;
// Olvidamos project: la restricción NOT NULL
// no se detecta hasta el flush, en ejecución.
// Y para la relación hace falta cargar el proyecto:
task.project = await em.findOneOrFail(Project, dto.projectId);
em.persist(task);
await em.flush();// El compilador exige title, status y project.
// La relación se resuelve por identificador:
// cero consultas adicionales.
const task = em.create(Task, {
title: dto.title,
status: dto.status ?? 'todo',
project: dto.projectId,
assignee: dto.assigneeId ?? null,
});
await em.flush(); // create ya hizo el persistinsert into "task" ("title", "status", "project_id", "assignee_id", "created_at")
values ($1, $2, $3, $4, $5)
returning "id", "created_at";16.5.2 em.assign y sus opciones
em.assign(entity, data, options?) es la herramienta para las actualizaciones parciales (PATCH): copia sobre una entidad ya gestionada los campos presentes en
data, resuelve relaciones por identificador y deja que el Unit of Work calcule el UPDATE mínimo.
| Opción | Efecto | Cuándo activarla |
|---|---|---|
mergeObjectProperties(en v5 se llamaba mergeObjects) | Fusiona los objetos anidados (JSON, embeddables) en lugar de reemplazarlos por completo | En un PATCH sobre un campo JSON donde el cliente envía solo algunas claves. Sin esta opción, enviar { meta: { source: 'web' } }
borra el resto del documento. |
updateNestedEntities | Permite actualizar entidades relacionadas ya existentes con los datos anidados, en vez de solo reasignar la referencia | Con cuidado: es cómodo para formularios maestro-detalle y peligroso como superficie de ataque, porque el cliente podría modificar entidades que no le corresponden. |
updateByPrimaryKey | Al asignar colecciones, empareja los elementos por clave primaria en vez de por posición | Al sincronizar una colección completa que llega del cliente. |
onlyOwnProperties | Ignora las propiedades de data que no estén declaradas en los metadatos de la entidad, en lugar de copiarlas tal cual |
Siempre que data venga del exterior. Es la defensa contra el mass assignment. |
ignoreUndefined | Trata undefined como «no enviado» en lugar de como «pon a null» | En semántica PATCH, para distinguir «no lo toques» de
«bórralo». |
async update(id: number, dto: UpdateTaskDto): Promise<Task> {
const task = await this.em.findOneOrFail(Task, id);
this.em.assign(task, dto, {
mergeObjectProperties: true, // meta se fusiona, no se reemplaza
onlyOwnProperties: true, // descarta claves que no son propiedades de Task
});
await this.em.flush(); // UPDATE solo de lo que realmente cambió
return task;
}em.assign(entity, req.body) Es la vulnerabilidad de mass assignment de manual. Si Task tiene un campo
project o User tiene role, un cliente malicioso puede enviarlo en el cuerpo de la petición y escalar privilegios o mover datos entre clientes. Pasa siempre por
un DTO con lista blanca de campos y activa whitelist: true y forbidNonWhitelisted: true en el ValidationPipe de Nest (capítulo 10).
onlyOwnProperties es una segunda barrera, no la primera. mergeObjects en MikroORM v5 y se renombró a
mergeObjectProperties en v6, que añadió además mergeEmbeddedProperties. Si el compilador te marca la opción como desconocida, es esto: consulta la firma de
AssignOptions en la documentación de tu versión exacta en lugar de probar a ciegas. 16.5.3 Operaciones directas: insert, nativeUpdate, nativeDelete
Estas operaciones traducen a una sola sentencia SQL y se ejecutan de inmediato, sin esperar al flush. Son rapidísimas y, por eso mismo, tentadoras. El precio es que se saltan
todo lo que hace valioso al ORM.
| Lo que se pierde | Consecuencia práctica |
|---|---|
Hooks del ciclo de vida (@BeforeUpdate, @AfterCreate…) | No se recalculan campos derivados, no se emiten eventos de dominio, no se actualizan
updatedAt gestionados por el ORM. |
| Actualización del Identity Map | Si la entidad ya estaba cargada en el contexto, sigue con el valor viejo en memoria. Un flush posterior puede reescribir la fila con
datos obsoletos. |
| Bloqueo optimista por versión | Se ignora la columna de versión: dos operaciones concurrentes se pisan sin que nadie se entere. |
| Validación y tipos personalizados | Los valores viajan casi tal cual; conversiones que darías por hechas pueden no aplicarse. |
| Propagación en cascada | nativeDelete no aplica cascade: [Cascade.REMOVE]: solo actúa la integridad referencial de la base de datos, si la has
definido. |
// insert: devuelve la clave primaria generada. No pasa por el Unit of Work.
const id = await em.insert(Task, { title: 'Importada', status: 'todo', project: 7 });
// insertMany: UNA sentencia con muchos VALUES. Ideal para importaciones masivas.
const ids = await em.insertMany(Task, rows); // rows: 5.000 objetos planos
// nativeUpdate: devuelve el número de filas afectadas
const afectadas = await em.nativeUpdate(
Task,
{ project: 7, status: 'doing' },
{ status: 'todo', assignee: null },
);
// nativeDelete: borrado en bloque
const borradas = await em.nativeDelete(Task, { status: 'archived', updatedAt: { $lt: hace1Ano } });insert into "task" ("title", "status", "project_id") values ($1, $2, $3) returning "id";
-- insertMany: una sola sentencia, no 5.000
insert into "task" ("title", "status", "project_id")
values ($1, $2, $3), ($4, $5, $6), ($7, $8, $9) /* … */ returning "id";
update "task" set "status" = $1, "assignee_id" = $2 where "project_id" = $3 and "status" = $4;
delete from "task" where "status" = $1 and "updated_at" < $2;const task = await em.findOneOrFail(Task, id);
console.log(task.status); // 'doing'
// Actualización directa a espaldas del Unit of Work
await em.nativeUpdate(Task, { id }, { status: 'done' });
console.log(task.status); // 'doing' ← ¡el objeto en memoria miente!
task.title = 'Nuevo título';
await em.flush();
// El changeset se calcula contra la instantánea original,
// que sigue diciendo status = 'doing'. Según lo que
// MikroORM considere modificado, puedes acabar
// revirtiendo el status recién guardado.// Opción A · una entidad concreta: usa el Unit of Work
const task = await em.findOneOrFail(Task, id);
task.status = 'done'; // hooks, versión, cascadas
await em.flush();
// Opción B · miles de filas: nativeUpdate, pero
// dejando el contexto coherente después
const n = await em.nativeUpdate(
Task, { project: 7, status: 'doing' }, { status: 'done' },
);
em.clear(); // vacía el Identity Map del contexto
// o, si solo te interesa una entidad concreta:
// await em.refresh(task);nativeUpdate compensa de verdad Cuando la operación afecta a muchas filas y la lógica de negocio no necesita cada entidad: cerrar todas las tareas
de un proyecto archivado, anonimizar registros antiguos, poner un contador a cero, migrar un valor de enumeración. Cargar 50.000 entidades para modificar una columna es un despilfarro de memoria y de
tiempo, y el Unit of Work generaría 50.000 UPDATE. Regla práctica: una entidad, camino gestionado; muchas filas y lógica trivial, camino directo, y limpia el contexto al terminar.
16.5.4 upsert y upsertMany
upsert resuelve el problema de «insértalo si no existe, actualízalo si existe» en una sola sentencia atómica, delegando en la cláusula ON CONFLICT de PostgreSQL (o
ON DUPLICATE KEY UPDATE en MySQL). Es la operación correcta para sincronizaciones e importaciones idempotentes, y elimina la condición de carrera del patrón «busca, y si no está,
inserta».
// Un solo registro. Devuelve la entidad gestionada.
const tag = await em.upsert(Tag, { name: 'urgente', color: '#f00' });
// Con conflicto sobre una clave única que no es la primaria
const user = await em.upsert(User, { email: 'ana@acme.com', name: 'Ana' }, {
onConflictFields: ['email'], // qué columnas definen el conflicto
onConflictAction: 'merge', // 'merge' actualiza · 'ignore' no hace nada
onConflictMergeFields: ['name'], // y actualiza SOLO estas al haber conflicto
});
// Lote: una sola sentencia para N filas. La herramienta de las importaciones.
const tags = await em.upsertMany(Tag, [
{ name: 'api', color: '#08f' },
{ name: 'bug', color: '#f80' },
{ name: 'ux', color: '#8f0' },
], { onConflictFields: ['name'], onConflictAction: 'merge' });insert into "user" ("email", "name") values ($1, $2)
on conflict ("email") do update set "name" = excluded."name"
returning "id";
-- upsertMany: una sentencia para las tres etiquetas
insert into "tag" ("name", "color") values ($1, $2), ($3, $4), ($5, $6)
on conflict ("name") do update set "color" = excluded."color"
returning "id";
-- con onConflictAction: 'ignore'
insert into "tag" ("name", "color") values ($1, $2)
on conflict ("name") do nothing
returning "id";upsert
Uno. El conflicto tiene que apoyarse en una restricción única real en la base de datos. Si tag.name no tiene unique, la sentencia falla o inserta duplicados. Revisa
la migración, no solo el decorador.
Dos. Con onConflictAction: 'ignore', en PostgreSQL la cláusula do nothing no devuelve fila, así que la entidad resultante puede no traer la clave primaria del
registro preexistente. Si necesitas el identificador, usa merge o haz una lectura posterior. El comportamiento concreto depende del driver: verifícalo con un test antes de confiar en
él.
16.5.5 em.remove frente a nativeDelete
em.remove(entity) + flush | em.nativeDelete(Entity, where) | |
|---|---|---|
| Requiere la entidad cargada | Sí (o una referencia con getReference) | No: basta la condición |
| Número de sentencias | Una por entidad (agrupadas por lotes en el flush) | Una para todas las filas |
Hooks @BeforeDelete / @AfterDelete | Sí | No |
| Cascadas del ORM | Sí (cascade, orphanRemoval) | No: solo las de la base de datos |
| Filtros globales (borrado lógico) | Se respetan | Se respetan en el where, pero no convierten el borrado en lógico |
| Uso recomendado | Borrado de una entidad con reglas de negocio | Purgas, limpieza de datos temporales, mantenimiento |
// Camino gestionado: sin cargar la entidad completa, usando una referencia
em.remove(em.getReference(Task, id));
await em.flush();
// → delete from "task" where "id" = $1
// Purga nocturna: una sentencia, sin materializar nada
const n = await em.nativeDelete(Comment, { createdAt: { $lt: hace2Anos } });
// → delete from "comment" where "created_at" < $116.6 QueryBuilder
16.6.1 Cuándo hace falta y qué se pierde
El QueryBuilder es una API fluida que construye SQL pieza a pieza. Se usa cuando la consulta deja de ser «dame entidades que cumplan X» y pasa a ser «dame una forma de datos que no es una entidad».
Lo necesitas cuando…
- Hay agregados:
count,sum,avg,min,max. - Hay
GROUP BYoHAVINGy el resultado es un informe, no entidades. - Necesitas un join con condición adicional: «los comentarios de los últimos 7 días», no todos.
- Necesitas
subconsultas en el
WHEREo columnas calculadas en elSELECT. - Quieres funciones del motor:
date_trunc, funciones de ventana, coalescencias complejas. - Quieres control absoluto sobre el número y la forma de las sentencias.
A cambio pierdes…
- Tipado de los alias.
't.titel'compila perfectamente y falla en ejecución. - Tipado del resultado. Con agregados obtienes objetos cuya forma el compilador no conoce; hay que declararla a mano.
- Garantía de entidad gestionada. Según cómo ejecutes, recibes entidades o objetos planos.
- Aplicación automática de algunos filtros globales: en QueryBuilder conviene comprobar explícitamente si el filtro se aplica en tu versión, en lugar de suponerlo.
- Seguridad frente a renombrados. Un cambio de nombre de propiedad no rompe la compilación, rompe la producción.
tasksByStatusReport()), con un
tipo de retorno declarado explícitamente. Así el resto de la aplicación consume una función tipada y el SQL frágil queda confinado en un único archivo, que además es el que cubres con pruebas de
integración. 16.6.2 Anatomía de una consulta
import { QueryOrder } from '@mikro-orm/core';
const qb = em.createQueryBuilder(Task, 't'); // alias 't' para la tabla raíz
const tasks = await qb
.select(['t.id', 't.title', 't.status', 't.priority'])
.addSelect('t.dueDate') // añade sin reemplazar la selección anterior
.where({ status: { $ne: 'done' } }) // objeto: mismo FilterQuery de 16.4
.andWhere('t.priority >= ?', [3]) // SQL con parámetro posicional ligado
.orWhere({ dueDate: { $lt: new Date() } })
.orderBy({ 't.priority': QueryOrder.DESC, 't.id': QueryOrder.ASC })
.limit(20)
.offset(40)
.getResultList();select "t"."id", "t"."title", "t"."status", "t"."priority", "t"."due_date"
from "task" as "t"
where ("t"."status" != $1 and "t"."priority" >= $2) or "t"."due_date" < $3
order by "t"."priority" desc, "t"."id" asc
limit $4 offset $5;orWhere es la trampa clásica Fíjate en los paréntesis del SQL: orWhere aplica el OR a todo lo acumulado
hasta ese momento, no al último andWhere. Si querías status != 'done' AND (priority >= 3 OR dueDate < hoy), esta consulta hace algo distinto y devolverá tareas
cerradas. Cuando mezcles AND y OR, no encadenes: escribe un único .where() con un objeto { $and: [...], $or: [...] }, que es explícito e
inequívoco. Y verifica siempre con getFormattedQuery(). | Método | Qué añade | Notas |
|---|---|---|
select(campos) | Lista de columnas o '*' | Reemplaza la selección; los campos van con alias ('t.title'). |
addSelect(campo) | Añade a la selección existente | Útil para añadir una columna calculada a una selección de entidad. |
where(cond) | Condición: objeto FilterQuery o cadena SQL + parámetros | Llamarlo dos veces reemplaza; para acumular usa
andWhere. |
andWhere / orWhere | Acumulan con AND / OR | Cuidado con la precedencia (aviso anterior). |
orderBy(obj) | ORDER BY | Acepta objeto o array de objetos para fijar el orden de los criterios. |
groupBy(campos) | GROUP BY | Todo lo no agregado del SELECT debe estar aquí. |
having(cond) | HAVING | Filtra después de agrupar; el where filtra antes (y es más barato). |
limit(n, offset?) | LIMIT y opcionalmente OFFSET | También existe offset(n) por separado. |
distinct() | SELECT DISTINCT | Parche habitual para la duplicación por join; casi siempre hay una solución mejor. |
setFlag(flag) | Activa un QueryFlag | QueryFlag.PAGINATE, QueryFlag.DISTINCT… |
16.6.3 Joins
La diferencia entre join y joinAndSelect es la que más confusión genera, y es simple: join une para poder filtrar u ordenar; joinAndSelect une
y además trae las columnas para hidratar la relación. Si solo haces join y luego accedes a la relación, tendrás un N+1.
const qb = em.createQueryBuilder(Task, 't');
const tasks = await qb
.select('t.*')
// INNER JOIN: filtra. Solo tareas CON responsable.
.innerJoin('t.assignee', 'u')
// LEFT JOIN + SELECT: trae el proyecto y lo hidrata en la entidad
.leftJoinAndSelect('t.project', 'p')
// Join con CONDICIÓN ADICIONAL: solo los comentarios recientes
.leftJoinAndSelect('t.comments', 'c', { 'c.createdAt': { $gte: hace7Dias } })
// Alias explícitos para poder filtrar por columnas de las tablas unidas
.where({ 'u.email': { $ilike: '%@acme.com' }, 'p.archived': false })
.orderBy({ 'p.name': 'asc', 't.priority': 'desc' })
.getResultList();select "t".*,
"p"."id" as "p__id", "p"."name" as "p__name", "p"."archived" as "p__archived",
"c"."id" as "c__id", "c"."body" as "c__body", "c"."created_at" as "c__created_at"
from "task" as "t"
inner join "user" as "u" on "t"."assignee_id" = "u"."id"
left join "project" as "p" on "t"."project_id" = "p"."id"
left join "comment" as "c" on "t"."id" = "c"."task_id" and "c"."created_at" >= $1
where "u"."email" ilike $2 and "p"."archived" = $3
order by "p"."name" asc, "t"."priority" desc;p__ no es decorativo MikroORM renombra las columnas de las tablas unidas con el patrón alias__columna para poder reconstruir el
grafo de objetos a partir de una fila plana. Cuando veas esos alias en el log, sabes que ese join es un joinAndSelect: está trayendo datos para hidratar, no solo para filtrar. Es la señal
más rápida para distinguir ambos casos leyendo el SQL. where Poner c.createdAt >= … en el ON de un LEFT JOIN conserva las tareas
sin comentarios recientes (con la colección vacía). Ponerlo en el WHERE las elimina, porque NULL >= fecha no es cierto, y el LEFT JOIN se degrada de
hecho a un INNER JOIN. Es un error sutil que produce informes con datos ausentes y nadie detecta hasta que un cliente se queja. 16.6.4 Agregados, raw() y el literal sql
import { raw, sql } from '@mikro-orm/core';
// count con distinct: cuenta tareas, no filas del join
const total = await em.createQueryBuilder(Task, 't')
.leftJoin('t.tags', 'tg')
.where({ 'tg.name': 'urgente' })
.count('t.id', true) // true = distinct
.execute('get');
// Varios agregados a la vez: declara el tipo del resultado, el QB no lo infiere
interface StatsRow { total: number; media: number; maximo: number; minimo: number; suma: number }
const [stats] = await em.createQueryBuilder(Task, 't')
.select([raw('count(*) as total'), raw('avg(t.priority) as media'), raw('max(t.priority) as maximo'),
raw('min(t.priority) as minimo'), raw('sum(coalesce(t.estimate_hours, 0)) as suma')])
.where({ project: projectId })
.execute<StatsRow[]>('all');
// El literal `sql` parametriza de forma segura lo que se interpola
const recientes = await em.createQueryBuilder(Task, 't')
.select('t.*').where(sql`t.created_at >= now() - interval '7 days'`).getResultList();select count(distinct "t"."id") as "count"
from "task" as "t" left join "task_tags" as "t1" on "t"."id" = "t1"."task_id"
left join "tag" as "tg" on "t1"."tag_id" = "tg"."id"
where "tg"."name" = $1;
select count(*) as total, avg(t.priority) as media, max(t.priority) as maximo,
min(t.priority) as minimo, sum(coalesce(t.estimate_hours, 0)) as suma
from "task" as "t" where "t"."project_id" = $1;raw() es una puerta abierta: nunca interpoles datos del usuario Todo lo que pasa por raw() va al SQL sin escapar. Es correcto para
nombres de columna y expresiones que tú controlas; es una vulnerabilidad si construyes la cadena con datos del cliente. Cuando necesites valores dinámicos, usa parámetros: raw('t.priority >
?', [n]) o el literal sql con interpolación, que parametriza lo interpolado. Y en la revisión de código, trata cada raw() con concatenación como un hallazgo de
seguridad de gravedad alta. raw y sql en tu versión MikroORM v6 introdujo el ayudante raw() y el literal etiquetado
sql, con soporte en condiciones, selecciones, ordenaciones y datos de actualización. La firma y los sitios admitidos han ido ampliándose en las versiones menores. Si algo no funciona
donde esperas, consulta la sección de expresiones crudas de la documentación de tu versión antes de dar por buena una forma alternativa. 16.6.5 Subconsultas
// 1 · Subconsulta en el WHERE: tareas que tienen al menos 3 comentarios
const conMuchos = em.createQueryBuilder(Comment, 'c').select('c.task').groupBy('c.task').having('count(*) >= ?', [3]);
const tasks = await em.createQueryBuilder(Task, 't')
.select('t.*').where({ id: { $in: conMuchos.getKnexQuery() } }).getResultList();
// 2 · Subconsulta en el SELECT: contador correlacionado por fila
const contador = em.createQueryBuilder(Comment, 'c2')
.count('c2.id').where({ task: raw('t.id') }); // raw('t.id') correlaciona con la consulta externa
interface FilaConContador { id: number; title: string; comentarios: number }
const filas = await em.createQueryBuilder(Task, 't')
.select(['t.id', 't.title', contador.as('comentarios')])
.where({ project: projectId })
.execute<FilaConContador[]>('all');
// 3 · EXISTS: el propio FilterQuery ya lo genera, y es la forma más eficiente de «tiene alguno»
const conUrgentes = await em.createQueryBuilder(Task, 't')
.select('t.*').where({ tags: { $some: { name: 'urgente' } } }).getResultList();-- 1 · IN con subconsulta agrupada
select "t".* from "task" as "t"
where "t"."id" in (
select "c"."task_id" from "comment" as "c"
group by "c"."task_id" having count(*) >= $1
);
-- 2 · subconsulta correlacionada en el SELECT
select "t"."id", "t"."title",
(select count("c2"."id") from "comment" as "c2" where "c2"."task_id" = t.id) as "comentarios"
from "task" as "t" where "t"."project_id" = $1;getKnexQuery(): la escotilla de emergencia MikroORM se apoya en Knex para el SQL. qb.getKnexQuery() devuelve el objeto de Knex subyacente,
que puedes incrustar como valor de un operador o manipular directamente para casos que la API del QueryBuilder no cubra: CTE, funciones de ventana, UNION. Es el último eslabón antes del
SQL literal y conserva la parametrización, que es su gran ventaja frente a concatenar cadenas. Úsalo con la misma disciplina que el SQL nativo: encapsulado, comentado y con pruebas. 16.6.6 Ejecución y depuración
| Método | Devuelve | ¿Entidades gestionadas? | Uso típico |
|---|---|---|---|
getResult() / getResultList() | Array de entidades | Sí | La consulta selecciona entidades completas (o campos suficientes de ellas). |
getSingleResult() | Una entidad o null | Sí | Cuando esperas una sola fila. Añade limit(1) tú mismo si procede. |
getCount() | number | — | Sustituye el SELECT por un count conservando los filtros y joins. |
getResultAndCount() | [entidades, total] | Sí | El equivalente de findAndCount en QueryBuilder: dos sentencias. |
execute('all') | Array de objetos planos | No | Informes y agregados. La forma que usas con GROUP BY. |
execute('get') | Un objeto plano | No | Una sola fila de un agregado. |
execute('run') | Metadatos de la operación | No | INSERT, UPDATE, DELETE construidos con QueryBuilder. |
getQuery() | SQL con marcadores | — | Depuración y pruebas de la forma del SQL. |
getParams() | Array de parámetros | — | Depuración; comprobar el orden y el tipo. |
getFormattedQuery() | SQL con los valores incrustados | — | Copiar y pegar en el cliente de base de datos. Solo para depurar. |
const qb = em.createQueryBuilder(Task, 't')
.select('t.*')
.where({ status: 'todo', priority: { $gte: 3 } })
.orderBy({ 't.createdAt': 'desc' })
.limit(10);
console.log(qb.getQuery());
// select "t".* from "task" as "t" where "t"."status" = ? and "t"."priority" >= ?
// order by "t"."created_at" desc limit ?
console.log(qb.getParams()); // [ 'todo', 3, 10 ]
console.log(qb.getFormattedQuery());
// select "t".* from "task" as "t" where "t"."status" = 'todo' and "t"."priority" >= 3
// order by "t"."created_at" desc limit 10
const tasks = await qb.getResultList(); // el QB se puede reutilizar tras inspeccionarlogetQuery() permite una clase de test muy valiosa y muy barata: expect(qb.getQuery()).toContain('inner
join "project"'). No necesita base de datos y detecta regresiones silenciosas, como que un cambio de populate haya convertido un join en cuatro consultas. Es el complemento
perfecto del test anti-N+1 de la sección 16.8. 16.6.7 getResult() y el Identity Map
Esta es la sutileza que separa a quien usa el QueryBuilder de quien lo entiende. El mismo QueryBuilder puede devolver dos cosas distintas:
qb.getResultList() qb.execute('all')
│ │
▼ ▼
hidratación completa filas planas del driver
│ │
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────────┐
│ instancia de Task en el │ │ { id: 1, title: '…', │
│ Identity Map │ │ comentarios: '7' } │
│ · cambios detectados │ │ · claves = alias del SELECT │
│ en el flush │ │ · sin métodos de la entidad │
│ · relaciones navegables │ │ · números como cadena │
│ · deduplicada por PK │ │ · filas DUPLICADAS con join │
└──────────────────────────┘ └──────────────────────────────┘
getResultList()hidrata entidades y las registra en el Identity Map. Si esa fila ya estaba cargada en el contexto, obtienes la instancia existente, no una copia. Puedes modificarla y hacerflush.execute('all')devuelve lo que envía el driver: objetos planos con las claves delSELECT. No hay Identity Map, no hay deduplicación, no hay métodos de la entidad.- Si seleccionas solo algunas columnas y usas
getResultList(), obtienes entidades parcialmente cargadas: instancias reales con propiedades aundefined. No las uses para escribir. - Si el
SELECTmezcla columnas de entidad con agregados, la hidratación no sabe dónde colocar el agregado: para informes,execute()y un tipo declarado a mano.
execute() El driver de PostgreSQL devuelve count(*) y bigint como cadena para no perder
precisión. Un row.total + 1 te dará "71" en lugar de 8. Convierte explícitamente (Number(row.total)) en el borde de tu capa de datos, no en la
plantilla de Angular. Y si el valor puede exceder el rango seguro de number, trátalo como string o bigint de principio a fin. 16.6.8 Cuatro consultas de producción
Ejemplo 1 · Informe de tareas por estado y por usuario. El clásico cuadro de mando: una fila por responsable y estado, con recuentos y horas estimadas.
export interface TasksByUserRow { userId: number; userName: string; status: TaskStatus; total: number; horas: number }
async tasksByStatusAndUser(projectId: number): Promise<TasksByUserRow[]> {
const rows = await this.em.createQueryBuilder(Task, 't')
.select(['u.id as "userId"', 'u.name as "userName"', 't.status as status',
raw('count(*) as total'), raw('coalesce(sum(t.estimate_hours), 0) as horas')])
.join('t.assignee', 'u')
.where({ project: projectId })
.groupBy(['u.id', 'u.name', 't.status'])
.having('count(*) > ?', [0])
.orderBy({ 'u.name': 'asc', 't.status': 'asc' })
.execute<Array<Record<string, unknown>>>('all');
// Frontera explícita: los tipos se normalizan aquí y en ningún otro sitio
return rows.map((r) => ({
userId: Number(r.userId), userName: String(r.userName), status: r.status as TaskStatus,
total: Number(r.total), horas: Number(r.horas),
}));
}select "u"."id" as "userId", "u"."name" as "userName", "t"."status" as status,
count(*) as total, coalesce(sum(t.estimate_hours), 0) as horas
from "task" as "t"
inner join "user" as "u" on "t"."assignee_id" = "u"."id"
where "t"."project_id" = $1
group by "u"."id", "u"."name", "t"."status"
having count(*) > $2
order by "u"."name" asc, "t"."status" asc;Ejemplo 2 · Ranking de usuarios por tareas completadas. Aquí aparece un patrón importante: el LEFT JOIN con la condición en el ON para que los usuarios sin tareas
cerradas también salgan, con un cero.
interface RankingRow { userId: number; name: string; completadas: number; posicion: number }
async rankingCompletadas(teamId: number, desde: Date): Promise<RankingRow[]> {
const rows = await this.em.createQueryBuilder(User, 'u')
.select(['u.id as "userId"', 'u.name as name', raw('count(t.id) as completadas'),
raw('rank() over (order by count(t.id) desc) as posicion')])
.join('u.teams', 'tm')
// La condición va en el ON, no en el WHERE: así los usuarios con 0 aparecen
.leftJoin('u.assignedTasks', 't', { 't.status': 'done', 't.updatedAt': { $gte: desde } })
.where({ 'tm.id': teamId })
.groupBy(['u.id', 'u.name'])
.orderBy({ completadas: 'desc' })
.execute<Array<Record<string, unknown>>>('all');
return rows.map((r) => ({
userId: Number(r.userId), name: String(r.name),
completadas: Number(r.completadas), posicion: Number(r.posicion),
}));
}Ejemplo 3 · Búsqueda con filtros opcionales. El equivalente en QueryBuilder del patrón de 16.4.7: acumular condiciones sin caer en el problema de precedencia.
async search(q: ListTasksQuery, viewerId: number) {
const qb = this.em.createQueryBuilder(Task, 't')
.select('t.*')
.leftJoinAndSelect('t.assignee', 'u')
.join('t.project', 'p')
.join('p.team', 'tm')
.join('tm.members', 'm');
// UN solo where con un objeto: sin ambigüedad de precedencia
const and: FilterQuery<Task>[] = [{ 'm.id': viewerId } as FilterQuery<Task>];
if (q.q) and.push({ $or: [{ title: { $ilike: `%${q.q}%` } }, { description: { $ilike: `%${q.q}%` } }] });
if (q.status?.length) and.push({ status: { $in: q.status } });
if (q.minPriority !== undefined) and.push({ priority: { $gte: q.minPriority } });
if (q.tags?.length) and.push({ tags: { $some: { name: { $in: q.tags } } } });
qb.where({ $and: and })
.orderBy([{ [`t.${q.sort}`]: q.dir }, { 't.id': 'asc' }])
.limit(q.limit, q.offset);
// getResultAndCount reutiliza los filtros y joins para el count
const [items, total] = await qb.getResultAndCount();
return { items, total };
}Ejemplo 4 · EXISTS con subconsulta escrita a mano. Cuando la condición de existencia es más compleja que lo que expresa $some (por ejemplo, involucra dos tablas y
una función de fecha), la subconsulta explícita es la respuesta correcta y sigue siendo eficiente: el motor se detiene en la primera coincidencia.
/** Tareas «doing» sin ningún comentario en los últimos 14 días: candidatas a revisión. */
async estancadas(projectId: number): Promise<Task[]> {
const actividad = this.em.createQueryBuilder(Comment, 'c')
.select('1')
.where({ task: raw('t.id') })
.andWhere(sql`c.created_at >= now() - interval '14 days'`);
return this.em.createQueryBuilder(Task, 't')
.select('t.*')
.leftJoinAndSelect('t.assignee', 'u')
.where({ project: projectId, status: 'doing' })
.andWhere(`not exists (${actividad.getQuery()})`, actividad.getParams())
.orderBy({ 't.updatedAt': 'asc' })
.getResultList();
}select "t".*, "u"."id" as "u__id", "u"."name" as "u__name"
from "task" as "t"
left join "user" as "u" on "t"."assignee_id" = "u"."id"
where "t"."project_id" = $1 and "t"."status" = $2
and not exists (
select 1 from "comment" as "c"
where "c"."task_id" = t.id and c.created_at >= now() - interval '14 days'
)
order by "t"."updated_at" asc;getQuery() exige cuidado con los parámetros Al concatenar el SQL de una subconsulta debes pasar también sus parámetros, y
en el orden correcto: los marcadores se numeran de forma global en la sentencia final. Si la consulta externa ya tiene parámetros antes del not exists, el desfase produce errores de tipo
o, peor, resultados incorrectos. Es otra razón para preferir $some/$none siempre que basten, y para probar estas consultas con un test de integración real. 16.7 SQL nativo
Cuando ni la API declarativa ni el QueryBuilder llegan, queda el SQL literal. No es una derrota: hay consultas que en SQL se leen en diez segundos y en cualquier constructor fluido resultan ilegibles. La regla es que sea una decisión, tomada y documentada, no una costumbre.
// execute(sql, params, method): 'all' → filas · 'get' → una fila · 'run' → metadatos
const rows = await this.em.getConnection().execute<Array<{ semana: string; total: string }>>(
`select date_trunc('week', t.created_at) as semana, count(*) as total
from task t
join project p on p.id = t.project_id
where p.team_id = ? and t.created_at >= ?
group by 1 order by 1 desc`,
[teamId, desde], // parámetros LIGADOS: el driver los envía aparte del SQL
'all',
);
return rows.map((r) => ({ semana: new Date(r.semana), total: Number(r.total) }));// Concatenación directa de entrada del usuario.
const sql = `select * from task
where title like '%${termino}%'`;
const rows = await conn.execute(sql, [], 'all');
// Si termino = "x' union select * from \"user\" --"
// el atacante lee la tabla de usuarios completa.
// Con "'; drop table task; --" (según el driver
// y si permite varias sentencias) la destruye.// El SQL es una plantilla FIJA; el dato viaja aparte.
// El motor nunca interpreta el valor como código.
const rows = await conn.execute(
'select * from task where title like ?',
[`%${termino}%`], 'all',
);
// Los comodines se añaden al PARÁMETRO, no al SQL.16.7.1 Mapear filas a entidades con em.map()
El resultado de execute() son objetos planos con nombres de columna de la base de datos (created_at, project_id). Si la consulta selecciona todas las columnas
de una tabla, em.map() convierte cada fila en una entidad gestionada, con sus tipos convertidos y registrada en el Identity Map.
const filas = await em.getConnection().execute<Array<Record<string, unknown>>>(
`select t.* from task t
where t.id in (select task_id from comment group by task_id having count(*) > ?)`,
[10], 'all',
);
// De fila plana a entidad: hidrata, convierte tipos y registra en el Identity Map
const tasks: Task[] = filas.map((fila) => em.map(Task, fila));
tasks[0].status = 'done'; // ya es una entidad gestionada: el flush la persistirá
await em.flush();em.map() La fila debe contener todas las columnas que la entidad espera, incluida la clave primaria y las claves foráneas. Si la
consulta proyecta solo algunas, obtendrás una entidad incompleta que puede provocar un UPDATE con nulos al hacer flush. Para proyecciones parciales usa fields
con em.find() (16.10) o quédate con los objetos planos y constrúyete un DTO. | El SQL nativo es la respuesta correcta cuando… | Motivo |
|---|---|
Informes analíticos con funciones de ventana, grouping sets, pivot o CTE recursivas | El QueryBuilder no las expresa con naturalidad y el SQL resultante sería el mismo. |
Operaciones masivas: insert … select, update … from, copy | Mueven millones de filas dentro del motor, sin que los datos viajen a Node. |
Funciones específicas del motor: búsqueda de texto avanzada, PostGIS, jsonb_path_query | Son la razón por la que elegiste ese motor; renunciar a ellas por purismo es absurdo. |
Mantenimiento: vacuum, reindex, explain, consultas al catálogo | No tienen nada que ver con el modelo de entidades. |
| Una consulta crítica que el ORM genera de forma medible peor | Con la medición delante, nunca «por si acaso». |
16.8 El problema N+1
16.8.1 Definición exacta y cómo se produce
El problema N+1 aparece cuando el código ejecuta una consulta para obtener una colección de N elementos y después, al recorrerla, ejecuta una consulta más por cada elemento para obtener un dato relacionado. Total: 1 + N consultas donde bastaba con una o dos. No es un fallo del ORM: es la consecuencia lógica de la carga perezosa cuando nadie declara qué se va a necesitar.
Lo grave no es el trabajo del motor, que resuelve cada consulta en microsegundos, sino la latencia de ida y vuelta. Con 1 ms de red por consulta, 200 tareas son 200 ms de espera pura; con la base de datos en otra zona de disponibilidad y 5 ms de latencia, un segundo entero. Y escala mal justamente cuando el producto tiene éxito: funciona con 10 registros de prueba y se hunde con 500 en producción.
const tasks = await em.find(Task, { project: 7 });
for (const t of tasks) {
// Cada acceso a una relación NO cargada
// dispara su propia consulta.
console.log(t.assignee?.name);
// Y cada colección, otra más:
for (const tag of t.tags) { /* … */ }
}
// 200 tareas → 1 + 200 + 200 = 401 consultasconst tasks = await em.find(Task, { project: 7 }, {
// Declaras por adelantado lo que vas a usar
populate: ['assignee', 'tags'],
});
for (const t of tasks) {
console.log(t.assignee?.name); // ya en memoria
for (const tag of t.tags) { /* … */ }
}
// 200 tareas → 3 consultas, sean 200 o 20.000[query] select "t0".* from "task" as "t0" where "t0"."project_id" = 7 [took 3 ms]
[query] select "u0".* from "user" as "u0" where "u0"."id" = 12 [took 1 ms]
[query] select "u0".* from "user" as "u0" where "u0"."id" = 8 [took 1 ms]
[query] select "u0".* from "user" as "u0" where "u0"."id" = 12 [took 1 ms] -- ¡repetida!
[query] select "u0".* from "user" as "u0" where "u0"."id" = 31 [took 1 ms]
-- … 196 líneas más, casi idénticas, con el id como único cambio N+1 (carga perezosa) populate (carga declarada)
──────────────────────────── ─────────────────────────────
App BD App BD
│──── select tasks ───►│ │──── select tasks ───►│
│◄─── 200 filas ───────│ │◄─── 200 filas ───────│
│──── select user 12 ─►│ ┐ │──── select user │
│◄──────────────────── │ │ │ where id in │
│──── select user 8 ──►│ │ 200 idas │ (12,8,31,…) ──►│
│◄──────────────────── │ │ y vueltas │◄─── 40 filas ────────│
│ (…198 más…) │ │ = 200 ms │──── select tags │
│──── select user 31 ─►│ │ de latencia │ where task_id │
│◄──────────────────── │ ┘ │ in (…) ───────►│
│ │ │◄─── 350 filas ───────│
Total: 401 consultas Total: 3 consultas
Tiempo ≈ 401 × latencia Tiempo ≈ 3 × latencia
16.8.2 Las cuatro soluciones
Solución 1 · populate con estrategia SELECT_IN. Una consulta adicional por relación, con un IN que agrupa todas las claves. El número de consultas
depende del número de relaciones, no del de filas.
import { LoadStrategy } from '@mikro-orm/core';
const tasks = await em.find(Task, { project: 7 },
{ populate: ['assignee', 'tags'], strategy: LoadStrategy.SELECT_IN });select "t0".* from "task" as "t0" where "t0"."project_id" = $1;
select "u0".* from "user" as "u0" where "u0"."id" in ($1, $2, $3, $4);
select "t1".*, "t0"."task_id" as "fk__task_id" from "tag" as "t1"
inner join "task_tags" as "t0" on "t1"."id" = "t0"."tag_id" where "t0"."task_id" in ($1, $2, /* … */);Solución 2 · populate con estrategia JOINED. Una sola consulta con LEFT JOIN. Una sola ida y vuelta, pero el resultado es un producto cartesiano
parcial: si una tarea tiene 5 etiquetas y 3 comentarios, esa tarea ocupa 15 filas y sus columnas se repiten en todas.
const tasks = await em.find(Task, { project: 7 },
{ populate: ['assignee', 'project'], strategy: LoadStrategy.JOINED });select "t0".*,
"a1"."id" as "a1__id", "a1"."name" as "a1__name", "a1"."email" as "a1__email",
"p2"."id" as "p2__id", "p2"."name" as "p2__name"
from "task" as "t0"
left join "user" as "a1" on "t0"."assignee_id" = "a1"."id"
left join "project" as "p2" on "t0"."project_id" = "p2"."id"
where "t0"."project_id" = $1;Solución 3 · QueryBuilder con leftJoinAndSelect. Equivalente a JOINED, pero con control total: puedes añadir condiciones al ON, elegir qué columnas
traer de cada tabla unida y combinarlo con agregados. Es la vía cuando necesitas cargar parte de una colección («los tres últimos comentarios») o filtrar la relación cargada.
const tasks = await em.createQueryBuilder(Task, 't')
.select('t.*')
.leftJoinAndSelect('t.assignee', 'u')
.leftJoinAndSelect('t.comments', 'c', { 'c.createdAt': { $gte: hace7Dias } })
.where({ project: 7 })
.getResultList();Solución 4 · Desnormalización y contadores. Cuando lo único que necesitas de una colección es su tamaño (o un máximo, o una suma), cargarla entera es un despilfarro. Un contador mantenido en la tabla padre convierte N consultas en cero: el dato ya viaja con la fila.
// Opción a) columna desnormalizada, actualizada por un hook o un disparador
@Property({ default: 0 }) commentCount!: number;
const tasks = await em.find(Task, { project: 7 }); // commentCount ya viene: 1 consulta
// Opción b) subconsulta en el SELECT si el contador no se puede mantener (16.6.5)
// Opción c) loadCount() cuando solo necesitas el número de UNA colección concreta
const n = await tasks[0].comments.loadCount(); // select count(*) … where task_id = ?nativeDelete, una migración manual o un error en un hook lo dejan mintiendo para siempre. Mantenlo en la misma transacción que la operación que lo modifica, prefiere un disparador de base
de datos a lógica de aplicación cuando existan varios escritores, y programa una tarea periódica de reconciliación. No lo apliques «por si acaso»: solo cuando la medición demuestre que hace falta.
16.8.3 Comparación: cuál elegir
| Criterio | SELECT_IN | JOINED |
|---|---|---|
| Número de sentencias | 1 + una por relación | 1 |
| Idas y vueltas de red | Varias (pero constantes) | Una |
| Volumen transferido con colecciones (1:N, M:N) | Mínimo: cada fila viaja una vez | Alto: las columnas de la raíz se repiten por cada fila hija |
| Relaciones M:1 y 1:1 | Correcto, pero una consulta extra evitable | Mejor opción: el join no multiplica filas |
Compatible con limit/offset en la raíz | Sí, de forma natural | Problemático: requiere QueryFlag.PAGINATE |
| Varias colecciones a la vez | Escala bien: N + M filas | Explota: N × M filas |
| Trabajo del motor | Varios accesos por índice, muy baratos | Un plan con joins; puede requerir ordenación o hash |
| Recomendación del libro | Colecciones (1:N, M:N) | Relaciones a-uno (M:1, 1:1) y detalles de un solo registro |
SELECT_IN suele ganar con 1:N: la duplicación de filas Imagina 100 tareas con 10 etiquetas y 20 comentarios cada una. Con JOINED y
ambas colecciones, el producto es 100 × 10 × 20 = 20.000 filas, cada una repitiendo el título y la descripción de la tarea: decenas de megabytes por la red para representar 100 tareas. Con
SELECT_IN son 100 + 1.000 + 2.000 = 3.100 filas sin un solo byte duplicado, en tres viajes. Por eso el valor por defecto sensato es select-in para colecciones, y
joined se reserva para relaciones a-uno o para la vista de detalle de una entidad concreta. loadStrategy en mikro-orm.config.ts y se
puede sobrescribir por consulta con strategy, por relación con @ManyToMany({ strategy: … }), y el comportamiento por defecto ha cambiado entre versiones mayores. Antes de
razonar sobre rendimiento, verifica cuál está activa en tu proyecto: la forma más fiable no es leer la documentación, es mirar el log de SQL. 16.8.4 Cómo detectarlo
debug: trueen desarrollo, siempre. Con el log de consultas a la vista, un N+1 se detecta en el mismo momento en que se escribe. Es la medida más rentable de todo el capítulo. Para afinar:debug: ['query', 'query-params'].- Contar consultas en un test de integración. La única forma de que el N+1 no vuelva. Un test que fija el número exacto de consultas de un endpoint falla en cuanto alguien añade un acceso perezoso, y el mensaje de error es inequívoco.
- APM y trazas distribuidas. Datadog, New Relic, Sentry Performance o cualquier exportador de OpenTelemetry muestran la traza de una petición como una cascada: doscientos tramos idénticos y estrechos son un N+1 dibujado. Es la forma de encontrarlos en producción, en código que nadie ha tocado en meses (capítulo 13).
- Revisión de código. Regla simple y mecánica: todo bucle que accede a una propiedad de relación es sospechoso.
Busca la declaración de
populatecorrespondiente; si no está, hay un N+1 o una entidad que no debería estar ahí.
describe('GET /api/tasks · presupuesto de consultas', () => {
let queries: string[];
beforeEach(async () => {
queries = [];
// MikroORM acepta un logger propio: aquí lo usamos como contador
orm = await MikroORM.init({ ...testConfig, debug: ['query'], logger: (msg) => queries.push(msg) });
await seed(orm, { tasks: 50, tagsPerTask: 3, comments: 5 }); // datos suficientes para que el N+1 se note
});
it('resuelve el listado con un número CONSTANTE de consultas', async () => {
const res = await request(app.getHttpServer()).get('/api/tasks?limit=50').expect(200);
expect(res.body.items).toHaveLength(50);
// 1 lista + 1 count + 1 assignee + 1 tags = 4. Importa menos el número exacto
// que el hecho de que NO crezca con el número de filas.
expect(queries.filter((q) => q.includes('select'))).toHaveLength(4);
});
});16.9 Paginación
16.9.1 Offset/limit: cómo funciona y por qué se degrada
La paginación por desplazamiento es la que todo el mundo escribe primero porque encaja con la interfaz mental de «página 1, 2, 3». El cliente envía page y size, el
servidor traduce a offset = (page - 1) * size y devuelve la porción junto al total.
const size = Math.min(q.size ?? 20, 100); // techo obligatorio: nunca confíes en el cliente
const page = Math.max(q.page ?? 1, 1);
const [items, total] = await em.findAndCount(Task, { project: projectId }, {
limit: size, offset: (page - 1) * size,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], // el desempate por id es obligatorio
populate: ['assignee'],
});
return { items, total, page, size, pages: Math.ceil(total / size) };select "t0".* from "task" as "t0" where "t0"."project_id" = $1
order by "t0"."created_at" desc, "t0"."id" desc limit 20 offset 9980;
select count(*) as "count" from "task" as "t0" where "t0"."project_id" = $1;El problema está en la palabra offset. El motor no puede saltar a la fila 9.980: no existe un índice «por número de fila». Tiene que localizar, ordenar y descartar las
9.980 primeras filas para devolver las 20 siguientes. El coste crece linealmente con el número de página: la página 1 es instantánea, la 500 tarda cincuenta veces más, y la 5.000 provoca tiempos de
espera agotados. Es un coste que además se paga íntegro para tirar el resultado a la basura.
offset 20 ahora apunta tres posiciones más atrás, así que vuelve a ver tres elementos que ya había
leído y nunca verá los que quedaron desplazados si navega hacia atrás. En una lista de mensajes o de un feed activo esto no es una molestia estética: produce duplicados en exportaciones y saltos
de registros en procesos de sincronización por lotes. La paginación por desplazamiento es correcta solo si el conjunto está congelado. 16.9.2 Paginación por cursor (keyset)
La paginación por cursor cambia la pregunta. En lugar de «dame los 20 elementos a partir del número 9.980», pide «dame los 20 elementos posteriores a este». La condición se expresa sobre los propios valores de ordenación, de modo que el motor usa el índice para posicionarse y lee exactamente 20 filas, sin descartar ninguna. El coste es constante: la página 5.000 tarda lo mismo que la primera.
OFFSET/LIMIT · página 500 CURSOR (keyset) · misma página
───────────────────────────── ──────────────────────────────
índice (created_at desc) índice (created_at desc, id desc)
┌───┬───┬───┬─── … ───┬───┬───┐ ┌───┬───┬───┬─── … ───┬───┬───┐
│ 1 │ 2 │ 3 │ …9980… │ … │ … │ │ │ │ │ │ │ │
└─┬─┴─┬─┴─┬─┴─────────┴─┬─┴───┘ └───┴───┴───┴────┬────┴─┬─┴───┘
▼ ▼ ▼ leídas ▼ │ │
✗ ✗ ✗ y TIRADAS ✓ ✓ (20 útiles) salto directo ───►│ ✓ ✓ ✓│ (20 útiles)
por el índice └──────┘
Filas leídas: 10.000 · útiles: 20 Filas leídas: 20 · útiles: 20
Coste: O(offset + limit) Coste: O(limit)
Total exacto: sí (count aparte) Total exacto: opcional y caro
Salto a página N: sí Salto a página N: NO (solo ant./sig.)
Estable con inserciones: NO Estable con inserciones: SÍ
El fundamento es una comparación lexicográfica sobre la clave de ordenación. Con un solo campo bastaría createdAt < ultimo, pero las fechas se repiten, y dos filas con la
misma fecha harían que una de ellas se perdiera o se repitiera. De ahí la regla de oro: la clave de ordenación debe ser única, y se consigue añadiendo la clave primaria como último criterio de
desempate.
interface Cursor { createdAt: Date; id: number }
async function pagina(em: EntityManager, projectId: number, size: number, after?: Cursor) {
// (createdAt, id) < (cursor.createdAt, cursor.id) en orden descendente:
// o la fecha es estrictamente menor, o es igual y el id es menor.
const keyset: FilterQuery<Task> | undefined = after && {
$or: [
{ createdAt: { $lt: after.createdAt } },
{ createdAt: after.createdAt, id: { $lt: after.id } },
],
};
const items = await em.find(Task, { project: projectId, ...(keyset ?? {}) }, {
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], // DEBE coincidir con la condición
limit: size + 1, // una fila extra: ¿hay página siguiente?
populate: ['assignee'],
});
const hasNext = items.length > size;
const page = hasNext ? items.slice(0, size) : items;
const last = page.at(-1);
return { items: page, hasNext, nextCursor: last ? encode({ createdAt: last.createdAt, id: last.id }) : null };
}select "t0".* from "task" as "t0"
where "t0"."project_id" = $1
and ("t0"."created_at" < $2 or ("t0"."created_at" = $2 and "t0"."id" < $3))
order by "t0"."created_at" desc, "t0"."id" desc limit 21;
-- Con un índice sobre (project_id, created_at desc, id desc) el motor se posiciona
-- directamente y lee 21 filas, sea la página 1 o la 5.000.MikroORM implementa este patrón con em.findByCursor(), que codifica y descodifica el cursor, calcula si hay páginas anterior y siguiente y devuelve un objeto iterable. Es la forma
recomendada porque elimina el código de codificación, que es donde se cometen los errores.
const cursor = await em.findByCursor(Task, { project: projectId }, {
first: 20, // hacia delante (last + before para ir hacia atrás)
after: q.after, // cursor opaco recibido del cliente, o undefined en la primera página
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], // obligatorio y con desempate único
populate: ['assignee'],
});
return {
items: cursor.items, // las entidades de la página
total: cursor.totalCount, // total exacto (implica un count: úsalo solo si lo necesitas)
endCursor: cursor.endCursor, // cadena opaca para la siguiente petición
hasNextPage: cursor.hasNextPage,
};findByCursor que conviene verificar El orderBy es obligatorio y debe incluir un desempate único, porque el cursor se construye
con los valores de esos campos. Los argumentos after/before aceptan tanto la cadena opaca como una entidad o un objeto con los campos de orden, y totalCount
implica una consulta de recuento adicional. Estos detalles y la forma exacta del objeto Cursor han ido evolucionando: consulta la firma en la documentación de tu versión antes de diseñar
el contrato de la API alrededor de ellos. | Offset/limit | Cursor/keyset | |
|---|---|---|
| Coste por página profunda | Crece linealmente | Constante |
| Salto a una página arbitraria | Sí | No: solo anterior y siguiente |
| Total de elementos | Natural con findAndCount | Posible pero caro; a menudo se omite |
| Estabilidad con escrituras concurrentes | No: duplica y omite elementos | Sí: la referencia es un valor, no una posición |
| Requisitos de índice | Índice por la ordenación | Índice por la clave de orden completa, en el mismo sentido |
| Complejidad de implementación | Trivial | Media (la resuelve findByCursor) |
| Caso de uso ideal | Tablas de administración con navegador de páginas y pocos datos | Feeds, listas infinitas, exportaciones, sincronización, APIs públicas |
16.9.3 Ordenación estable y contrato de respuesta
// priority tiene solo 5 valores distintos: el
// orden DENTRO de cada grupo no está definido.
// El motor puede devolverlo distinto en cada
// ejecución, y lo hará al cambiar el plan o al
// añadirse una réplica de lectura.
await em.find(Task, {}, {
orderBy: { priority: 'desc' }, limit: 20, offset: 20,
});
// Resultado: elementos en dos páginas a la vez
// y otros que no aparecen en ninguna.// Desempate por una columna ÚNICA: el orden
// total queda completamente determinado.
await em.find(Task, {}, {
orderBy: [{ priority: 'desc' }, { id: 'asc' }],
limit: 20, offset: 20,
});
// Regla general: el último criterio de un
// ORDER BY paginado debe ser único. Y si el
// campo es anulable, decide dónde van los
// nulos: { dueDate: QueryOrder.ASC_NULLS_LAST }Por último, la forma de la respuesta. Un contrato paginado coherente en toda la API (capítulo 10) evita que cada endpoint invente el suyo y permite que Angular tenga un solo componente de tabla y un solo tipo genérico. El tipo se define una vez en la biblioteca compartida del monorepo y lo importan las dos partes: si el backend cambia el contrato, el frontend deja de compilar, que es exactamente lo que queremos.
/** Paginación por desplazamiento: cuando el cliente necesita saltar a una página concreta. */
export interface OffsetPage<T> {
items: T[];
meta: { total: number; page: number; size: number; pages: number };
}
/** Paginación por cursor: para listas largas, infinitas o sincronización incremental. */
export interface CursorPage<T> {
items: T[];
meta: { endCursor: string | null; hasNextPage: boolean; total?: number };
}import { CursorPage } from '@acme/shared';
private readonly cursor = signal<string | null>(null);
readonly items = signal<TaskListDto[]>([]);
cargarMas(): void {
const params = { limit: 20, ...(this.cursor() ? { after: this.cursor()! } : {}) };
this.http.get<CursorPage<TaskListDto>>('/api/tasks', { params }).subscribe((page) => {
this.items.update((actual) => [...actual, ...page.items]); // nueva referencia: la vista reacciona
this.cursor.set(page.meta.hasNextPage ? page.meta.endCursor : null);
});
}?size=1000000 es el ataque de denegación de servicio más fácil del mundo, y no requiere ningún conocimiento técnico. 16.10 Proyecciones y rendimiento de lectura
Una entidad completa con veinte columnas, de las cuales la lista solo muestra tres, es un desperdicio en cuatro sitios a la vez: disco, memoria del motor, red y memoria de Node. La proyección parcial es la optimización de lectura con mejor relación entre esfuerzo y resultado, y casi nadie la usa.
// fields: solo estas columnas viajan. Admite rutas de relación.
const filas = await em.find(Task, { project: 7 }, {
fields: ['id', 'title', 'status', 'assignee.name'],
orderBy: { id: 'asc' },
});
// → select "t0"."id", "t0"."title", "t0"."status", "t0"."assignee_id", "a1"."name" …
// exclude: el complemento, cuando lo que sobra es una columna grande
const sinDescripcion = await em.find(Task, { project: 7 }, { exclude: ['description', 'meta'] });fields El objeto que recibes es una instancia de la entidad, pero parcialmente cargada: las propiedades no seleccionadas
valen undefined y no se rellenan solas al accederlas. Nunca uses una entidad parcial para escribir: según cómo el Unit of Work interprete esos undefined, un
flush puede acabar escribiendo nulos sobre datos buenos. El criterio seguro es tratar el resultado de una proyección como datos de solo lectura y, si además vas a serializarlo,
convertirlo a un DTO explícito. | Técnica | Qué resuelve | Coste o riesgo |
|---|---|---|
fields / exclude | Reduce columnas leídas y transferidas; permite index-only scans | Entidad parcial, no apta para escritura |
| DTO de lectura dedicado | Contrato explícito con el cliente, independiente del modelo de persistencia | Código de mapeo que hay que mantener |
| Vista o vista materializada | Encapsula un informe complejo; la materializada precalcula el resultado | Refresco periódico y datos potencialmente desactualizados; se mapea como entidad de solo lectura |
collection.loadCount() | Un count(*) en lugar de cargar toda la colección | Una consulta por colección: no lo pongas en un bucle |
| Contador desnormalizado | Cero consultas adicionales para el tamaño de una colección | Riesgo de incoherencia (16.8.2) |
disableIdentityMap: true | Lecturas masivas sin retener las entidades en el contexto | Las entidades no son gestionadas: no se pueden modificar ni comparar por identidad |
// Exportar 500.000 filas: el Identity Map las retendría TODAS en memoria hasta el final
// de la petición, con la instantánea original de cada una. Fuga de memoria garantizada.
for (let offset = 0; ; offset += 5_000) {
const lote = await em.find(Task, {}, {
fields: ['id', 'title', 'status'],
limit: 5_000, offset,
disableIdentityMap: true, // no se registran en el contexto
orderBy: { id: 'asc' },
});
if (!lote.length) break;
escribirCsv(lote);
}
// Alternativa aún mejor si el driver lo soporta: una consulta con cursor de servidor
// (streaming) para no cargar ni un lote completo en memoria.16.11 Serialización de resultados
Serializar es convertir un grafo de entidades en JSON. Suena trivial y es el punto exacto donde se filtran contraseñas, se producen recursiones infinitas y se acopla la API al esquema de la base de datos para siempre.
import { serialize, wrap } from '@mikro-orm/core';
// wrap(entity) da acceso al envoltorio interno de la entidad
const plano = wrap(task).toObject(); // objeto plano, según los metadatos de serialización
const json = wrap(task).toJSON(); // lo que usaría JSON.stringify
// serialize() da control fino y funciona también con arrays
const dto = serialize(task, {
populate: ['assignee', 'tags'], // qué relaciones incluir
exclude: ['description', 'assignee.email'], // qué quitar, con rutas anidadas
forceObject: true, // relaciones SIEMPRE como objeto, nunca como id suelto
skipNull: true,
});@Entity()
export class User {
@PrimaryKey() id!: number;
@Property() email!: string;
// hidden: nunca aparece en toObject()/toJSON(). Red de seguridad, NO la única defensa.
@Property({ hidden: true }) passwordHash!: string;
// persist: false → propiedad calculada que no existe como columna
@Property({ persist: false }) get initials(): string { return this.name.slice(0, 2).toUpperCase(); }
// serializer/serializedName: transforma el valor o renombra la clave en la salida
@Property({ serializer: (v: Date) => v.toISOString().slice(0, 10), serializedName: 'alta' })
createdAt!: Date;
}hidden: true es útil, pero es una lista negra: el día que alguien añada una columna sensible
y olvide el decorador, se publicará automáticamente. Un DTO es una lista blanca: solo sale lo que has escrito explícitamente. Además desacopla el contrato público del modelo interno (puedes renombrar
una columna sin romper a los clientes), evita las referencias circulares Task → Comment → Task que producen Maximum call stack size exceeded, y te da un lugar natural donde
documentar la API. El coste es el código de mapeo; el beneficio es no tener que auditar tus entidades cada vez que tocas el esquema. export class TaskListDto {
id!: number; title!: string; status!: TaskStatus; assignee!: { id: number; name: string } | null; tags!: string[];
// El tipo Loaded exige en COMPILACIÓN que las relaciones estén cargadas:
// es imposible construir este DTO provocando un N+1 sin que el compilador se queje.
static from(t: Loaded<Task, 'assignee' | 'tags'>): TaskListDto {
return {
id: t.id, title: t.title, status: t.status,
assignee: t.assignee ? { id: t.assignee.id, name: t.assignee.name } : null,
tags: t.tags.getItems().map((g) => g.name),
};
}
}16.12 Caché de resultados
MikroORM puede memorizar el resultado de una consulta durante un tiempo. Es una caché de resultados de consulta: se activa por consulta y se almacena en el adaptador configurado.
// mikro-orm.config.ts
resultCache: { expiration: 5_000 /* ms por defecto */ /*, adapter: RedisCacheAdapter, options: {…} */ },
// 1 · activar con el TTL global
await em.find(Task, { status: 'todo' }, { cache: true });
// 2 · TTL explícito para esta consulta
await em.find(Task, { status: 'todo' }, { cache: 30_000 });
// 3 · clave explícita: la única forma de poder INVALIDARLA después
await em.find(Task, { status: 'todo' }, { cache: ['tasks:todo', 60_000] });
// invalidación al escribir
await em.flush();
await em.clearCache('tasks:todo');16.13 Consultas dentro de transacciones y flushMode
Este es el caso que hace perder tardes enteras: creas una entidad, consultas justo después y la consulta no la encuentra. No es un fallo: es el Unit of Work funcionando como está diseñado. Los
cambios viven en memoria hasta el flush, y una consulta SQL solo ve lo que hay en la base de datos.
| Modo | Comportamiento | Cuándo elegirlo |
|---|---|---|
FlushMode.AUTO | Antes de una consulta, vuelca los cambios pendientes si son de entidades que la consulta podría afectar | El valor por defecto y el más seguro: la consulta ve tus cambios |
FlushMode.COMMIT | Solo vuelca al confirmar la transacción (o con un flush explícito) | Procesos por lotes donde quieres controlar el momento exacto de la escritura |
FlushMode.ALWAYS | Vuelca antes de cada consulta, sin comprobar si hace falta | Depuración y casos con SQL nativo que debe ver todo lo pendiente |
await em.transactional(async (em) => {
em.create(Tag, { name: 'nueva' });
// Con flushMode COMMIT, o con SQL nativo (que NUNCA
// dispara el flush automático), esta consulta no ve
// la etiqueta pendiente: encontrada = null.
const encontrada = await em.getConnection()
.execute('select * from tag where name = ?', ['nueva'], 'get');
if (!encontrada) {
em.create(Tag, { name: 'nueva' }); // ¡duplicado!
}
});await em.transactional(async (em) => {
em.create(Tag, { name: 'nueva' });
// Opción A: fuerza el volcado antes de consultar
await em.flush();
const encontrada = await em.findOne(Tag, { name: 'nueva' });
// Opción B (mejor): no consultes para decidir.
// Deja que la restricción única resuelva la carrera:
await em.upsert(Tag, { name: 'nueva' }, {
onConflictFields: ['name'], onConflictAction: 'ignore',
});
});await em.flush() antes: es explícito y no
depende de la configuración. El SQL nativo y muchas operaciones de bajo nivel no participan en el volcado automático, así que ahí el flush manual es obligatorio. Y desconfía del
patrón «consulto para ver si existe y si no lo creo»: entre la consulta y la inserción cabe otra petición. La solución robusta es una restricción única en la base de datos más upsert o el
tratamiento del error de duplicado. 16.14 Diagnóstico de rendimiento
export default defineConfig({
// Log de consultas. En desarrollo, siempre. En producción, nunca 'query-params': imprime datos personales.
debug: process.env.NODE_ENV === 'development' ? ['query', 'query-params'] : false,
// Registra en WARN cualquier consulta que tarde más de 300 ms: tu detector de problemas en producción
logger: (msg) => logger.debug(msg),
// (el nombre y la forma de las opciones de umbral han cambiado entre versiones:
// consulta LoggerOptions en la documentación de la tuya antes de fijarlas)
});const qb = em.createQueryBuilder(Task, 't').select('t.*').where({ status: 'todo' }).limit(20);
// getFormattedQuery() incrusta los valores: imprescindible para EXPLAIN, prohibido para ejecutar
// consultas normales (es exactamente el patrón de la inyección SQL).
const plan = await em.getConnection()
.execute<Array<Record<string, string>>>(`explain (analyze, buffers) ${qb.getFormattedQuery()}`, [], 'all');
console.log(plan.map((r) => Object.values(r)[0]).join('\n'));Limit (cost=0.00..1842.31 rows=20 width=214) (actual time=0.019..412.884 rows=20 loops=1)
-> Seq Scan on task t (cost=0.00..184231.00 rows=2001 width=214)
(actual time=0.018..412.870 rows=20 loops=1)
Filter: ((status)::text = 'todo'::text)
Rows Removed by Filter: 1998043 -- ← el síntoma: descartó 2 millones de filas
Planning Time: 0.104 ms
Execution Time: 412.912 ms -- ← 412 ms para devolver 20 filas
-- Diagnóstico: Seq Scan + "Rows Removed by Filter" enorme = falta un índice sobre status.
-- Tras crear el índice: Index Scan, "Rows Removed" ≈ 0 y Execution Time en el orden de 1 ms.- 1 · Mide antes de tocar nada. ¿Cuánto tarda, con qué volumen de datos y de forma reproducible? Sin una cifra de partida no sabrás si has mejorado.
- 2 · Cuenta las consultas. Activa el log y mira el número. Si crece con el número de filas devueltas, tienes un N+1: es la causa más frecuente y la más barata de arreglar (16.8).
- 3 · Busca la
consulta lenta. Si son pocas consultas pero una tarda, aísla esa. El campo
tookdel log o el comentario decommentte llevan directamente a ella. - 4 ·
EXPLAIN ANALYZE. ¿Seq Scansobre una tabla grande? Falta un índice. ¿Rows Removed by Filtercon millones? El índice existe pero no se usa (conversión de tipo en elWHERE, función sobre la columna,LIKE '%…'). Capítulo 19. - 5 · Revisa el volumen transferido. ¿Traes columnas que nadie usa? ¿Un
JOINEDsobre dos colecciones duplicando filas? Aplicafieldso cambia de estrategia. - 6 · Revisa la paginación. ¿Hay un
offsetalto? ¿Uncount(*)sobre millones de filas en cada petición? Pasa a cursor o elimina el total exacto (16.9). - 7 · Solo entonces, considera la caché. Cachear una consulta mal escrita es esconder el problema y multiplicarlo por el número de réplicas.
- 8 · Vuelve a medir y deja el test que impida la regresión.
16.15 Errores comunes y cómo solucionarlos
| Síntoma | Causa real | Solución |
|---|---|---|
| El endpoint funciona en desarrollo y tarda segundos en producción | N+1 silencioso: con 10 registros de prueba no se nota; con 500 sí | populate con la estrategia
adecuada y un test de presupuesto de consultas (16.8) |
TypeError: Cannot read properties of undefined al leer una relación | populate olvidado: la relación es una referencia no inicializada | Declararla en
populate; usar el tipo Loaded<T, 'rel'> para que el compilador lo exija |
| Una colección aparece vacía aunque en la base de datos hay filas | Igual que la anterior: sin populate, Collection no está
inicializada | populate, o await coleccion.init(), o collection.loadItems() |
500 Internal Server Error al pedir un recurso inexistente | findOneOrFail lanza NotFoundError y nadie lo captura | failHandler,
findOneOrFailHandler global o un exception filter que lo traduzca a 404 (16.3.2) |
| Un filtro numérico o de fecha no devuelve nada | El parámetro llegó como cadena: "3" no es 3, "2024-01-01" no es
Date | @Type(() => Number) / Date en el DTO y transform: true en el ValidationPipe |
Un filtro se ignora cuando el valor es 0 o cadena vacía | Comprobación truthy (if (q.x)) en lugar de !== undefined | if (q.x !==
undefined); y ?? en lugar de || para los valores por defecto |
| Resultados duplicados, o el total no cuadra con los elementos | JOIN a una colección: el count y el limit operan sobre
filas | $some/$none en lugar del join, SELECT_IN, distinct() o QueryFlag.PAGINATE |
| La lista se degrada solo en las páginas altas | OFFSET alto: el motor lee y descarta todas las filas anteriores | Paginación por cursor; y quitar el
count(*) exacto si no es imprescindible (16.9) |
| Elementos repetidos u omitidos al paginar | Ordenación no determinista: falta un desempate único | Añadir la clave primaria como último criterio del ORDER BY |
| Una entidad en memoria no refleja lo que hay en la base de datos | nativeUpdate modificó la fila a espaldas del Identity Map | em.refresh(entity),
em.clear(), o usar el camino gestionado (16.5.3) |
Un flush revierte cambios recién guardados | El changeset se calcula contra una instantánea obsoleta tras una operación nativa | No mezclar ambos caminos sobre la misma entidad en el mismo contexto |
| «He creado la entidad y la consulta no la encuentra» | Los cambios están en memoria; la consulta va a la base de datos | await em.flush() antes de consultar, o
replantear la lógica con upsert (16.13) |
| Resultados extraños o fuga de datos en una búsqueda | Inyección SQL por concatenación en raw() o en execute() | Parámetros ligados siempre; lista blanca para nombres de columna y sentidos de ordenación (16.7) |
Maximum call stack size exceeded al devolver la respuesta | Referencia circular entre entidades al serializar | Devolver DTOs; exclude o
hidden como parche (16.11) |
| Aparecen campos sensibles en el JSON | Se devuelve la entidad directamente | DTOs con lista blanca; hidden: true como segunda barrera |
Un UPDATE escribe nulos sobre datos correctos | flush sobre una entidad cargada con fields (parcial) | Tratar las proyecciones como solo lectura; recargar la entidad completa para escribir |
| El total del listado sube cuando se añaden etiquetas, no tareas | El count cuenta filas del join, no entidades raíz | count('t.id', true) con distinct, o
evitar el join en el conteo |
16.16 Buenas y malas prácticas
Haz esto
- Declara siempre lo que vas a cargar.
populateexplícito y tiposLoaded<T, …>en las firmas: convierten el N+1 en un error de compilación. - Trabaja con el log de SQL abierto en desarrollo. Si no ves el SQL, no sabes lo que estás escribiendo.
- Un desempate único en todo
ORDER BYpaginado. Sin excepciones. - Filtra con
$some/$noneen lugar de con joins a colecciones cuando la pregunta es de existencia. SELECT_INpara colecciones,JOINEDpara relaciones a-uno. Y mide antes de discutir.- Encapsula las consultas en repositorios con nombres de dominio y tipos de retorno declarados.
- DTOs en la frontera de la API, nunca entidades.
- Parámetros ligados y listas blancas para todo lo que venga del cliente, incluida la ordenación.
- Techo al tamaño de página en el servidor y valores por defecto sensatos.
commentcon el endpoint en las consultas importantes: convierte el log del motor en un mapa.- Tests de presupuesto de consultas en los endpoints más cargados.
Evita esto
- Acceder a relaciones dentro de un bucle sin haberlas cargado. Es la definición operativa del N+1.
populate: ['*']o cargar el grafo completo «por si acaso»: trae megabytes que nadie usa.- Confundir filtrar con cargar. Una condición anidada no carga la relación.
- Concatenar datos del usuario en SQL o en
raw(). Nunca. Ni en un script interno. - Mezclar
nativeUpdatey Unit of Work sobre la misma entidad en el mismo contexto. em.assign(entity, req.body): mass assignment de manual.- Encadenar
andWhereyorWhereesperando la precedencia de las matemáticas. findAndCountpor costumbre cuando el cliente nunca muestra el total.- Usar entidades parciales para escribir.
- Cachear para tapar una consulta mal escrita o un índice que falta.
- Optimizar sin medir, y su gemelo: discutir de rendimiento sin un
EXPLAIN ANALYZEdelante.
16.17 Preguntas frecuentes
¿Cuándo debo usar QueryBuilder en lugar de em.find()?
count, sum, avg),
necesitas GROUP BY, o necesitas un join con una condición adicional en el ON. Si no se da ninguno, em.find() es mejor opción: el mismo SQL con tipado completo.
Un aviso: el QueryBuilder no es «más rápido». Para la misma consulta genera el mismo SQL; lo que cambia es lo que puedes expresar y lo que el compilador puede verificar.¿Por qué mi count devuelve más elementos de los que hay?
JOIN a una relación a-muchos y estás contando filas del producto, no entidades raíz. Tres soluciones, de mejor a peor: sustituir el join por
$some/$none, que genera EXISTS y no multiplica filas; usar count('t.id', true) con distinct; o activar QueryFlag.PAGINATE, que envuelve
la consulta en una subconsulta. El distinct() a secas es el parche menos recomendable porque obliga al motor a ordenar o construir un hash de todo el resultado.¿SELECT_IN o JOINED? Dame una regla que pueda aplicar sin pensar.
OneToMany, ManyToMany) con SELECT_IN; relaciones a-uno (ManyToOne, OneToOne) con JOINED. El
motivo es la duplicación de filas: un join con una colección repite las columnas de la entidad raíz una vez por cada hija, y con dos colecciones el producto se multiplica. Un join con una relación
a-uno no duplica nada, así que ahí la consulta única siempre gana. Y la excepción razonable: en la vista de detalle de una sola entidad, JOINED es cómodo incluso con colecciones
pequeñas, porque una sola ida y vuelta compensa la duplicación de una única fila raíz.¿Puedo confiar en que el ORM genere el SQL óptimo?
count(*) no lo mira nadie. El ORM optimiza la traducción; tú optimizas la pregunta. Y en el 95 % de los casos el problema no es el SQL
generado, es que se están generando cuatrocientas sentencias.¿Por qué findOne devuelve la entidad que ya tenía en memoria y no vuelve a leer?
refresh: true en las opciones o
em.refresh(entity). Es una garantía valiosa: sin ella, dos partes del mismo caso de uso podrían estar modificando dos copias distintas de la misma fila.¿Existe un operador $null?
{ assignee: null } genera is null y { assignee: { $ne: null } } genera is not null. Como
alternativa más legible tienes $exists: true | false, que traduce literalmente a is not null / is null. Y recuerda la lógica de tres valores de SQL: {
status: { $ne: 'done' } } no devuelve las filas cuyo status es NULL. Si las quieres, pídelas explícitamente con un $or.¿Por qué mi filtro por número no encuentra nada si en la base de datos está el valor?
?minPriority=3 produce "3", y según el tipo de la
columna y el motor, la comparación falla o provoca una conversión implícita que además inutiliza el índice. La solución no es convertir en el servicio, es no dejar que entre sin convertir:
@Type(() => Number) en el DTO con transform: true en el ValidationPipe. Este error es tan frecuente que merece una prueba propia.¿Es siempre mejor la paginación por cursor?
¿Puedo devolver entidades directamente desde el controlador?
¿La caché del ORM me quita las consultas repetidas?
¿Cómo sé si me falta un índice?
EXPLAIN ANALYZE. Busca dos señales: Seq Scan sobre una tabla grande y un Rows Removed by Filter muy alto, que significa que el motor leyó
muchísimas filas para quedarse con unas pocas. Ojo con el falso negativo contrario: si el índice existe pero la consulta aplica una función o una conversión sobre la columna
(lower(email), (meta->>'x')::int) o usa LIKE '%algo', el índice no se puede usar. Los detalles, en el capítulo 19.¿Por qué count(*) me llega como cadena en execute()?
bigint como texto para no perder precisión: el rango de bigint excede el de Number.MAX_SAFE_INTEGER.
Convierte explícitamente con Number() en la frontera de tu capa de datos, en un único sitio y no repartido por la aplicación. Si el valor puede ser realmente enorme, trátalo como
string o bigint de principio a fin, incluido el JSON.¿Cómo pruebo que una consulta hace lo que creo?
qb.getQuery() y qb.getParams() devuelven el SQL y los parámetros, y puedes afirmar que aparece el join
esperado o que no aparece un offset; y si el filtro se construye en una función pura, la pruebas comparando objetos. Con base de datos: un test de integración con datos sembrados que
verifique el resultado y, además, el número de consultas ejecutadas. Lo primero es rapidísimo y detecta regresiones estructurales; lo segundo es la única forma de validar el comportamiento real.16.18 Ejercicios
16.1 Escribe la consulta que devuelva las tareas del proyecto 7 con estado todo o doing, prioridad mayor o igual que 3, ordenadas por prioridad descendente y con
desempate por identificador. Antes de ejecutarla, escribe a mano el SQL que esperas; después compáralo con el log.
16.2 Dada la consulta anterior, añade la carga del responsable y de las etiquetas. Comprueba en el log cuántas sentencias se ejecutan con SELECT_IN y cuántas con
JOINED, y cuenta las filas que devuelve cada una.
16.3 Escribe tres consultas equivalentes que devuelvan las tareas sin responsable asignado, usando respectivamente null, $exists y $eq. Comprueba que
las tres generan el mismo SQL.
16.4 Convierte un findAndCount con limit: 20, offset: 0 en un find con limit: 21 que informe de si hay página siguiente sin ejecutar el
count. ¿Qué gana y qué pierde el cliente?
16.5 Implementa el endpoint GET /api/tasks completo: DTO validado con al menos cinco filtros opcionales, construcción tipada del FilterQuery, ordenación con lista
blanca, paginación con techo y respuesta con el tipo compartido. Escribe pruebas unitarias de la función que construye el filtro, sin base de datos.
16.6 Escribe la consulta «tareas que tienen al menos una etiqueta de la lista dada y ningún comentario en los últimos 30 días» de dos formas: con $some/$none y con
joins explícitos en QueryBuilder. Compara el SQL, el número de filas intermedias y el plan de ejecución.
16.7 Construye el informe «horas estimadas por proyecto y por estado» con QueryBuilder, declarando el tipo del resultado y normalizando los tipos numéricos en un único punto.
16.8 Escribe un test de integración que falle si el listado de tareas ejecuta más de cuatro consultas. Después rompe el código a propósito (quitando un populate) y comprueba que
el test lo detecta.
16.9 Implementa paginación por cursor a mano, sin findByCursor: codifica el cursor en base64 con los campos de orden, valida al descodificarlo (un cursor manipulado no debe
provocar un error 500) y soporta navegación hacia delante y hacia atrás. Compara tu resultado con findByCursor.
16.10 Genera 200.000 tareas, mide el tiempo de la página 1 y de la página 5.000 con offset, y después con cursor. Presenta los resultados en una tabla junto a la salida de EXPLAIN
ANALYZE de cada caso y explica la diferencia con lo que ves en el plan.
16.11 Toma un endpoint que devuelva entidades con tres niveles de relaciones y optimízalo hasta reducir a la mitad el tiempo de respuesta, documentando cada paso: número de consultas antes y después, bytes transferidos, columnas proyectadas y estrategia de carga. La entrega es el informe, no solo el código.
16.12 Implementa un decorador o interceptor de NestJS que registre en el log el número de consultas SQL y el tiempo total de base de datos de cada petición, y que emita un aviso cuando
superen un umbral configurable. Piensa cómo aislar el contador por petición (pista: AsyncLocalStorage, capítulo 1).
Solución comentada · 16.5 · endpoint de listado con filtros dinámicos
El diseño se apoya en tres piezas separadas, y la separación es lo importante: el DTO valida y convierte, una función pura traduce a FilterQuery, y el repositorio ejecuta. Así la
lógica de traducción se prueba sin base de datos.
// 1 · El DTO es el de la sección 16.4.7: @Type para convertir tipos, @IsEnum con lista blanca
// para 'sort', @Max(100) en 'limit'. Con transform: true y whitelist: true en el ValidationPipe.
// 2 · La traducción, como función pura (ver 16.4.7 para la versión completa)
export function buildTaskFilter(q: ListTasksQuery, viewerId: number): FilterQuery<Task> {
const and: FilterQuery<Task>[] = [{ project: { team: { members: { id: viewerId } } } }];
if (q.q) and.push({ title: { $ilike: `%${q.q}%` } });
if (q.status?.length) and.push({ status: { $in: q.status } });
if (q.minPriority !== undefined) and.push({ priority: { $gte: q.minPriority } });
if (q.tags?.length) and.push({ tags: { $some: { name: { $in: q.tags } } } });
return and.length === 1 ? and[0] : { $and: and };
}
// 3 · La prueba: rápida, sin base de datos, y cubre los casos que más fallan
describe('buildTaskFilter', () => {
it('siempre restringe al alcance del usuario', () => {
expect(JSON.stringify(buildTaskFilter({} as ListTasksQuery, 9))).toContain('"id":9');
});
it('respeta minPriority = 0 (no lo trata como ausente)', () => {
const f = buildTaskFilter({ minPriority: 0 } as ListTasksQuery, 9) as { $and: unknown[] };
expect(f.$and).toHaveLength(2);
});
it('ignora un array de estados vacío', () => {
const f = buildTaskFilter({ status: [] } as ListTasksQuery, 9);
expect(JSON.stringify(f)).not.toContain('$in'); // [] generaría "in ()"
});
});
Los tres errores que hay que evitar están cubiertos por esas tres pruebas: perder el alcance del usuario (fuga de datos entre clientes), descartar el valor 0 por una
comprobación truthy, y generar un in () con un array vacío. El cuarto, el tipo de los parámetros, lo resuelve el DTO y merece un test del ValidationPipe.
Solución comentada · 16.7 · informe con QueryBuilder
Tres decisiones sostienen este informe. Primera: execute('all') en lugar de getResultList(), porque el resultado no es una entidad. Segunda: el tipo de la fila se declara
explícitamente, ya que el QueryBuilder no lo infiere. Tercera: la conversión de tipos numéricos ocurre en un único punto, justo al salir del repositorio.
export interface HorasPorProyectoRow { projectId: number; projectName: string; status: TaskStatus; horas: number; tareas: number }
async horasPorProyectoYEstado(teamId: number): Promise<HorasPorProyectoRow[]> {
const rows = await this.em.createQueryBuilder(Task, 't')
.select(['p.id as "projectId"', 'p.name as "projectName"', 't.status as status',
raw('coalesce(sum(t.estimate_hours), 0) as horas'), raw('count(*) as tareas')])
.join('t.project', 'p')
.where({ 'p.team': teamId })
.groupBy(['p.id', 'p.name', 't.status'])
.orderBy({ 'p.name': 'asc', 't.status': 'asc' })
.execute<Array<Record<string, unknown>>>('all');
return rows.map((r) => ({
projectId: Number(r.projectId), projectName: String(r.projectName),
status: r.status as TaskStatus, horas: Number(r.horas), tareas: Number(r.tareas),
}));
}
Dos detalles de calidad. El coalesce evita que un sum sobre una columna anulable devuelva null en lugar de 0, que es el origen habitual de
los NaN en el cuadro de mando. Y el join es INNER a propósito: los proyectos sin ninguna tarea no deben aparecer. Si el requisito fuera mostrarlos con un cero,
habría que invertir la consulta, partir de Project y usar leftJoin.
Solución comentada · 16.8 · detectar y corregir un N+1
Paso 1 · Reproducir con datos suficientes. Con cinco registros el N+1 no se nota. Siembra al menos cincuenta tareas con relaciones; ese es el motivo por el que el problema llega a producción.
Paso 2 · Contar. Se instrumenta MikroORM con un logger propio que acumula los mensajes de consulta, y se afirma un número exacto:
const queries: string[] = [];
orm = await MikroORM.init({ ...testConfig, debug: ['query'], logger: (m) => queries.push(m) });
await service.list({ limit: 50 } as ListTasksQuery, viewerId);
const selects = queries.filter((q) => q.includes('select'));
expect(selects).toHaveLength(4); // lista + count + assignee + tags
Paso 3 · Diagnosticar. Antes de la corrección, el test informa de 104 consultas en lugar de 4, y el log muestra decenas de sentencias idénticas salvo el valor del identificador. Esa es la firma inequívoca.
Paso 4 · Corregir en el sitio correcto. No basta con añadir populate donde falla: hay que hacer que el compilador lo exija. Si el método que consume las entidades declara su
parámetro como Loaded<Task, 'assignee' | 'tags'>, cualquier llamada sin el populate correspondiente deja de compilar. La corrección pasa de ser un parche a ser
estructural:
// El DTO exige entidades con las relaciones cargadas: el N+1 se vuelve inexpresable
static from(t: Loaded<Task, 'assignee' | 'tags'>): TaskListDto { /* … */ }
// Y el repositorio declara lo que carga
const [items, total] = await em.findAndCount(Task, where, { populate: ['assignee', 'tags'], limit, offset });
return { items: items.map(TaskListDto.from), total };
Paso 5 · Dejar la barrera puesta. El test del paso 2 se queda en la suite para siempre. Un N+1 no rompe ninguna funcionalidad, así que ningún otro test lo detectará: solo se degrada el tiempo, y en silencio.
16.19 Resumen del capítulo
- Tres formas de consultar, una decisión consciente.
em.find()para casi todo, QueryBuilder para agregados, joins con condición y subconsultas, SQL nativo para lo que solo existe en el motor. La misma consulta produce el mismo SQL por los tres caminos: lo que cambia es qué puedes expresar y qué puede verificar el compilador. FilterQueryconvierte las condiciones en datos. Se construyen, se combinan, se tipan y se prueban como funciones puras. Los operadores de colección ($some,$none,$every) generanEXISTSy evitan la duplicación de filas que provocan los joins.- Filtrar no es cargar. Una condición anidada genera un join
para el
WHERE; para poder leer la relación hace faltapopulate. Confundirlo es la causa más frecuente del N+1. - El N+1 es 1 + N consultas donde bastaban dos.
Se corrige declarando la carga por adelantado:
SELECT_INpara colecciones,JOINEDpara relaciones a-uno. Se detecta con el log, con un test de presupuesto de consultas y con el APM. Y solo el test impide que vuelva. - El
OFFSETalto es lento por diseño y además deriva con las escrituras concurrentes. La paginación por cursor tiene coste constante y es estable, a cambio de renunciar al salto a una página arbitraria. En ambos casos, la ordenación necesita un desempate único. - Las escrituras nativas esquivan el Unit of Work: sin hooks, sin Identity Map, sin control de versión. Compensan con muchas filas y lógica trivial; mezclarlas con el camino gestionado sobre la misma entidad produce datos incoherentes en memoria.
- Traer menos es la optimización más rentable:
fields,exclude, contadores en lugar de colecciones ydisableIdentityMapen las lecturas masivas. - La API devuelve DTOs. Lista blanca en lugar de lista negra: es lo que evita filtrar columnas sensibles, romper clientes al renombrar y las recursiones al serializar.
- Los parámetros ligados no son opcionales, y lo que no se puede parametrizar (columnas, sentido de ordenación) se resuelve con listas blancas en el servidor.
- Mide, no
adivines. Log de SQL en desarrollo,
commentcon el endpoint,EXPLAIN ANALYZEante cualquier duda y una lista de comprobación reproducible ante un endpoint lento.
16.20 Recursos adicionales
- MikroORM · EntityManager — la referencia de
findy familia; consulta aquí la firma exacta deFindOptionspara tu versión. - MikroORM · Query conditions — catálogo
oficial de operadores de
FilterQuerycon su equivalente SQL. - MikroORM · QueryBuilder — joins, subconsultas, expresiones crudas y métodos de ejecución.
- MikroORM · Loading strategies —
select-infrente ajoined, y cómo configurar la estrategia por defecto. - MikroORM · Paginación y
findByCursor— la API de cursor y la forma del objetoCursor. - MikroORM · Serialización —
serialize(),wrap().toObject()y las opciones de las propiedades. - MikroORM · Result cache — adaptadores, TTL e invalidación por clave.
- PostgreSQL · Using EXPLAIN — cómo leer un plan de ejecución, con ejemplos comentados.
- Use The Index, Luke! · No Offset — la explicación canónica de por qué la paginación por cursor gana.
- PostgreSQL · Funciones y operadores JSON — el detalle de
->>,@>y compañía.