Parte IV · MikroORM

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.

CORE MikroORM Tiempo de lectura: ~115 min Prerrequisitos: capítulos 14 y 15 (Unit of Work, entidades y relaciones) y SQL básico

16.1 Qué vas a poder hacer al terminar

El modelo de dominio del libro

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.

CriterioAPI del EntityManagerQueryBuilderSQL 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
La regla del libro: 90 / 9 / 1 Empieza siempre por 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.
Analogía Piensa en una cocina profesional. La API del 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-conceptual.ts · MikroORM v6
// 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.

tasks.service.ts · la familia completa
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' }] });
SQL generado (PostgreSQL)
-- 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:

tasks.service.ts · failHandler por consulta
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)}`),
    },
  );
}
Seguridad: no filtres la condición en el mensaje Ese 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.
mikro-orm.config.ts · handler global
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.

tasks.controller.tsINCORRECTO
@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 });
}
tasks.controller.tsCORRECTO
@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ónTipoQué 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_WRITEFOR 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.
tasks.repository.ts · varias opciones combinadas
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
  },
);
SQL generado · fíjate en el comentario y en las tres sentencias
/* 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.

Usa 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

condiciones-basicas.ts
// 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 });
SQL generado
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;
Detalle clave: { 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

OperadorSQL equivalenteEjemploNotas
$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.
$inin (…){ status: { $in: ['todo', 'doing'] } } Con array vacío genera una condición siempre falsa; compruébalo antes de construirla.
$ninnot 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.
$likelike{ title: { $like: '%api%' } } Sensible a mayúsculas en PostgreSQL. Un comodín inicial impide usar el índice B-tree.
$ilikeilike{ title: { $ilike: '%api%' } } Insensible a mayúsculas. Específico de PostgreSQL.
$reregexp / ~{ title: { $re: '^\\[urgente\\]' } } Potente y caro. Riesgo de retroceso catastrófico si la expresión viene del cliente.
$fulltextto_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».
$existsis not null{ assignee: { $exists: true } } No es el EXISTS de SQL: es una comprobación de nulidad. $exists: false genera is null.
$andand{ $and: [{ a: 1 }, { b: 2 }] } Implícito entre las claves de un mismo objeto; explícito cuando repites campo.
$oror{ $or: [{ status: 'todo' }, { priority: 5 }] } Se agrupa entre paréntesis automáticamente.
$notnot (…){ $not: { status: 'done' } } Niega el bloque completo, no un campo.
$someexists (…){ tags: { $some: { name: 'urgente' } } } Sobre colecciones: «al menos un elemento cumple».
$nonenot exists (…){ comments: { $none: {} } } «Ningún elemento cumple». Con {}: la colección está vacía.
$everynot exists (… not …){ tags: { $every: { name: { $ne: 'spam' } } } } «Todos cumplen». Cuidado: por lógica de conjuntos, una colección vacía cumple siempre.
No existe un operador $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

condiciones-logicas.ts
// 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'] } } });
SQL generado (tercer ejemplo)
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);
Ese 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.

relaciones-anidadas.ts
// 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' },
});
SQL generado · cuatro joins que no escribiste
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;
Filtrar no es cargar El error conceptual número uno de este capítulo: la condición anidada genera un join para filtrar, pero no carga la relación. Después de la consulta anterior, 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.

colecciones.ts
// $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'] } } } });
SQL generado · subconsultas correlacionadas, sin duplicar filas
-- $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))
 );
buscar.tsINCORRECTO
// 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
buscar.tsCORRECTO
// $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 tareas

16.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.

json.ts
// 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'] } } });
SQL generado (PostgreSQL)
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;
El JSON es cómodo y caro

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.

dto/list-tasks.query.ts
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';
}
tasks.repository.ts · el filtro tipado
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 };
  }
}
tasks.service.tsINCORRECTO
// 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
  });
}
tasks.service.tsCORRECTO
// 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 };
}
El filtro que se olvida es una brecha de seguridad Fíjate en que 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.
Un filtro es una función pura: pruébalo como tal 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:

create.tsINCORRECTO
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();
create.tsCORRECTO
// 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 persist
SQL generado
insert 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ónEfectoCuá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».
tasks.service.ts · PATCH correcto
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;
}
Nunca hagas 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.
Nombre de opción dependiente de la versión La opción de fusión de objetos se llamaba 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 pierdeConsecuencia 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.
operaciones-directas.ts
// 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 } });
SQL generado
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;
archive.service.tsINCORRECTO
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.
archive.service.tsCORRECTO
// 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);
Cuándo 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».

sync.service.ts
// 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' });
SQL generado (PostgreSQL)
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";
Dos avisos sobre 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) + flushem.nativeDelete(Entity, where)
Requiere la entidad cargadaSí (o una referencia con getReference)No: basta la condición
Número de sentenciasUna por entidad (agrupadas por lotes en el flush)Una para todas las filas
Hooks @BeforeDelete / @AfterDeleteNo
Cascadas del ORMSí (cascade, orphanRemoval)No: solo las de la base de datos
Filtros globales (borrado lógico)Se respetanSe respetan en el where, pero no convierten el borrado en lógico
Uso recomendadoBorrado de una entidad con reglas de negocioPurgas, limpieza de datos temporales, mantenimiento
borrado.ts
// 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" < $1

16.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 BY o HAVING y 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 WHERE o columnas calculadas en el SELECT.
  • 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.
Regla de convivencia Encapsula cada consulta de QueryBuilder en un método con nombre de dominio dentro de un repositorio (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

qb-basico.ts
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();
SQL generado
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;
La precedencia de 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étodoQué añadeNotas
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ámetrosLlamarlo dos veces reemplaza; para acumular usa andWhere.
andWhere / orWhereAcumulan con AND / ORCuidado con la precedencia (aviso anterior).
orderBy(obj)ORDER BYAcepta objeto o array de objetos para fijar el orden de los criterios.
groupBy(campos)GROUP BYTodo lo no agregado del SELECT debe estar aquí.
having(cond)HAVINGFiltra después de agrupar; el where filtra antes (y es más barato).
limit(n, offset?)LIMIT y opcionalmente OFFSETTambién existe offset(n) por separado.
distinct()SELECT DISTINCTParche habitual para la duplicación por join; casi siempre hay una solución mejor.
setFlag(flag)Activa un QueryFlagQueryFlag.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.

qb-joins.ts
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();
SQL generado
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;
El prefijo 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.
La condición del join no es la del 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

qb-agregados.ts
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();
SQL generado
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.
Verifica la superficie exacta de 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

qb-subconsultas.ts
// 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();
SQL generado
-- 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étodoDevuelve¿Entidades gestionadas?Uso típico
getResult() / getResultList()Array de entidades La consulta selecciona entidades completas (o campos suficientes de ellas).
getSingleResult()Una entidad o null 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] El equivalente de findAndCount en QueryBuilder: dos sentencias.
execute('all')Array de objetos planosNo Informes y agregados. La forma que usas con GROUP BY.
execute('get')Un objeto planoNo Una sola fila de un agregado.
execute('run')Metadatos de la operaciónNo INSERT, UPDATE, DELETE construidos con QueryBuilder.
getQuery()SQL con marcadoresDepuración y pruebas de la forma del SQL.
getParams()Array de parámetrosDepuració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.
qb-depuracion.ts
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 inspeccionarlo
Prueba la forma del SQL, no solo el resultado getQuery() 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 │
  └──────────────────────────┘          └──────────────────────────────┘
Los tipos numéricos en 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.

reports.repository.ts
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),
  }));
}
SQL generado
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.

reports.repository.ts
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.

search.repository.ts
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.

stale.repository.ts
/** 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();
}
SQL generado
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;
Incrustar una subconsulta con 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.

native.repository.ts
// 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) }));
search.repository.tsINCORRECTO
// 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.
search.repository.tsCORRECTO
// 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.
La única regla no negociable del SQL nativo Nunca, bajo ninguna circunstancia, construyas SQL concatenando datos que no hayas generado tú. No basta con «escapar las comillas»: los parámetros ligados son la única defensa completa, porque el valor viaja por un canal distinto al del código. Y ojo: los parámetros solo pueden ocupar el lugar de valores. Un nombre de columna o un sentido de ordenación no se pueden parametrizar, así que esos siempre se resuelven contra una lista blanca en el servidor, jamás con el texto que llegó del cliente.

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.

em-map.ts
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();
Requisito de 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 recursivasEl QueryBuilder no las expresa con naturalidad y el SQL resultante sería el mismo.
Operaciones masivas: insert … select, update … from, copyMueven 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_querySon la razón por la que elegiste ese motor; renunciar a ellas por purismo es absurdo.
Mantenimiento: vacuum, reindex, explain, consultas al catálogoNo tienen nada que ver con el modelo de entidades.
Una consulta crítica que el ORM genera de forma medible peorCon 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.

tasks.service.tsINCORRECTO · N+1
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 consultas
tasks.service.tsCORRECTO
const 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
log de SQL con debug: true · la prueba del delito
[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
La firma visual del N+1 Muchas consultas casi idénticas que solo se diferencian en el valor de la clave primaria, ejecutadas en ráfaga inmediatamente después de una consulta de lista. Si además ves ids repetidos, es que el Identity Map ni siquiera está ayudando porque las entidades no estaban en el contexto. En cuanto aprendes a reconocer ese patrón en un log, lo ves en todas partes.
  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.

solucion-1.ts + SQL
import { LoadStrategy } from '@mikro-orm/core';
const tasks = await em.find(Task, { project: 7 },
  { populate: ['assignee', 'tags'], strategy: LoadStrategy.SELECT_IN });
SQL · 3 sentencias, ninguna duplica filas de task
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.

solucion-2.ts + SQL
const tasks = await em.find(Task, { project: 7 },
  { populate: ['assignee', 'project'], strategy: LoadStrategy.JOINED });
SQL · 1 sentencia con alias prefijados
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.

solucion-3.ts
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.

solucion-4.ts
// 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 = ?
La desnormalización se paga en coherencia Un contador es un dato duplicado, y todo dato duplicado puede quedar desincronizado: un borrado por 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

CriterioSELECT_INJOINED
Número de sentencias1 + una por relación1
Idas y vueltas de redVarias (pero constantes)Una
Volumen transferido con colecciones (1:N, M:N)Mínimo: cada fila viaja una vezAlto: las columnas de la raíz se repiten por cada fila hija
Relaciones M:1 y 1:1Correcto, pero una consulta extra evitableMejor opción: el join no multiplica filas
Compatible con limit/offset en la raízSí, de forma naturalProblemático: requiere QueryFlag.PAGINATE
Varias colecciones a la vezEscala bien: N + M filasExplota: N × M filas
Trabajo del motorVarios accesos por índice, muy baratosUn plan con joins; puede requerir ordenación o hash
Recomendación del libroColecciones (1:N, M:N)Relaciones a-uno (M:1, 1:1) y detalles de un solo registro
Por qué 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.
Comprueba el valor por defecto de tu versión La estrategia por defecto se configura globalmente con 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

test/tasks.n1.spec.ts · patrón anti-N+1
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);
  });
});
El test de presupuesto de consultas es el mejor regalo para tu equipo Un N+1 no rompe nada: la respuesta es correcta y los tests funcionales pasan. Solo se degrada el tiempo, y de forma proporcional al éxito del producto. Un test que afirma «este endpoint hace 4 consultas» es la única barrera que convierte ese problema silencioso en un fallo de integración continua. Escríbelo para los tres o cuatro endpoints más cargados de tu aplicación y habrás cerrado la puerta.

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.

paginacion-offset.ts
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) };
SQL generado · página 500 de 20 elementos
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.

El segundo problema, más sutil: la deriva de resultados El usuario lee la página 1 (elementos 1 al 20). Mientras la mira, se insertan tres tareas nuevas que, por el orden descendente de fecha, se colocan al principio. El usuario pide la página 2: el 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.

paginacion-cursor.ts · implementación manual
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 };
}
SQL generado · sin offset, con salto por índice
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.

findbycursor.ts
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,
};
Detalles de 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/limitCursor/keyset
Coste por página profundaCrece linealmenteConstante
Salto a una página arbitrariaNo: solo anterior y siguiente
Total de elementosNatural con findAndCountPosible pero caro; a menudo se omite
Estabilidad con escrituras concurrentesNo: duplica y omite elementosSí: 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ónTrivialMedia (la resuelve findByCursor)
Caso de uso idealTablas de administración con navegador de páginas y pocos datosFeeds, listas infinitas, exportaciones, sincronización, APIs públicas

16.9.3 Ordenación estable y contrato de respuesta

list.tsINCORRECTO
// 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.
list.tsCORRECTO
// 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.

libs/shared/src/pagination.ts · compartido por NestJS y Angular
/** 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 };
}
apps/web/src/app/tasks/tasks.service.ts · el mismo tipo en Angular
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);
  });
}
Criterio del libro Ofrece paginación por desplazamiento en las pantallas de administración, donde el usuario quiere el navegador de páginas y los conjuntos son pequeños. Usa cursor en todo lo que crezca sin límite: feeds, historiales, listas con desplazamiento infinito, exportaciones y cualquier consumo programático de tu API. Y pon siempre un techo al tamaño de página en el servidor: ?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.

proyecciones.ts
// 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'] });
Qué pierdes con 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écnicaQué resuelveCoste o riesgo
fields / excludeReduce columnas leídas y transferidas; permite index-only scansEntidad parcial, no apta para escritura
DTO de lectura dedicadoContrato explícito con el cliente, independiente del modelo de persistenciaCódigo de mapeo que hay que mantener
Vista o vista materializadaEncapsula un informe complejo; la materializada precalcula el resultadoRefresco periódico y datos potencialmente desactualizados; se mapea como entidad de solo lectura
collection.loadCount()Un count(*) en lugar de cargar toda la colecciónUna consulta por colección: no lo pongas en un bucle
Contador desnormalizadoCero consultas adicionales para el tamaño de una colecciónRiesgo de incoherencia (16.8.2)
disableIdentityMap: trueLecturas masivas sin retener las entidades en el contextoLas entidades no son gestionadas: no se pueden modificar ni comparar por identidad
export.service.ts · lectura masiva de solo lectura
// 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.

serializacion.ts
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,
});
entities/user.entity.ts · control desde la entidad
@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;
}
El criterio del libro: la API devuelve DTOs, no entidades 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.
dto/task-list.dto.ts · DTO de lectura con constructor estático
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.

cache.ts
// 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');
Por qué la caché del ORM no sustituye a una caché de aplicación Cachea una consulta concreta, no un concepto de negocio: si el mismo dato se lee desde cinco consultas distintas, tendrás cinco entradas que caducan por separado y se invalidan por separado. Sin clave explícita, la clave se deriva del SQL y los parámetros, así que un filtro distinto es una entrada nueva: en un endpoint con seis filtros opcionales, la tasa de acierto se acerca a cero. El adaptador por defecto es en memoria del proceso: con tres réplicas tendrás tres cachés incoherentes. Y no invalida nada por sí sola cuando escribes. Úsala para lo que es buena: datos casi estáticos y consultas caras, idénticas y repetidas (catálogos, configuración, cuadros de mando). Para lo demás, una caché explícita de dominio con Redis, claves con significado y una política de invalidación pensada por ti (capítulo 11).

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.

ModoComportamientoCuándo elegirlo
FlushMode.AUTOAntes de una consulta, vuelca los cambios pendientes si son de entidades que la consulta podría afectarEl valor por defecto y el más seguro: la consulta ve tus cambios
FlushMode.COMMITSolo 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.ALWAYSVuelca antes de cada consulta, sin comprobar si hace faltaDepuración y casos con SQL nativo que debe ver todo lo pendiente
import.service.tsINCORRECTO
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!
  }
});
import.service.tsCORRECTO
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',
  });
});
Reglas prácticas Dentro de una transacción, si necesitas que una consulta vea lo que acabas de crear, haz 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

mikro-orm.config.ts · instrumentación
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)
});
explain.ts · EXPLAIN ANALYZE desde el ORM
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'));
salida de EXPLAIN ANALYZE · cómo leerla
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.
Lista de comprobación ante un endpoint lento
  • 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 took del log o el comentario de comment te llevan directamente a ella.
  • 4 · EXPLAIN ANALYZE. ¿Seq Scan sobre una tabla grande? Falta un índice. ¿Rows Removed by Filter con millones? El índice existe pero no se usa (conversión de tipo en el WHERE, función sobre la columna, LIKE '%…'). Capítulo 19.
  • 5 · Revisa el volumen transferido. ¿Traes columnas que nadie usa? ¿Un JOINED sobre dos colecciones duplicando filas? Aplica fields o cambia de estrategia.
  • 6 · Revisa la paginación. ¿Hay un offset alto? ¿Un count(*) 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íntomaCausa realSolución
El endpoint funciona en desarrollo y tarda segundos en producciónN+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ónpopulate olvidado: la relación es una referencia no inicializadaDeclararla 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 filasIgual que la anterior: sin populate, Collection no está inicializadapopulate, o await coleccion.init(), o collection.loadItems()
500 Internal Server Error al pedir un recurso inexistentefindOneOrFail lanza NotFoundError y nadie lo capturafailHandler, findOneOrFailHandler global o un exception filter que lo traduzca a 404 (16.3.2)
Un filtro numérico o de fecha no devuelve nadaEl 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íaComprobación truthy (if (q.x)) en lugar de !== undefinedif (q.x !== undefined); y ?? en lugar de || para los valores por defecto
Resultados duplicados, o el total no cuadra con los elementosJOIN 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 altasOFFSET alto: el motor lee y descarta todas las filas anterioresPaginación por cursor; y quitar el count(*) exacto si no es imprescindible (16.9)
Elementos repetidos u omitidos al paginarOrdenación no determinista: falta un desempate únicoAñadir la clave primaria como último criterio del ORDER BY
Una entidad en memoria no refleja lo que hay en la base de datosnativeUpdate modificó la fila a espaldas del Identity Mapem.refresh(entity), em.clear(), o usar el camino gestionado (16.5.3)
Un flush revierte cambios recién guardadosEl changeset se calcula contra una instantánea obsoleta tras una operación nativaNo 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 datosawait em.flush() antes de consultar, o replantear la lógica con upsert (16.13)
Resultados extraños o fuga de datos en una búsquedaInyecció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 respuestaReferencia circular entre entidades al serializarDevolver DTOs; exclude o hidden como parche (16.11)
Aparecen campos sensibles en el JSONSe devuelve la entidad directamenteDTOs con lista blanca; hidden: true como segunda barrera
Un UPDATE escribe nulos sobre datos correctosflush 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 tareasEl count cuenta filas del join, no entidades raízcount('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. populate explícito y tipos Loaded<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 BY paginado. Sin excepciones.
  • Filtra con $some/$none en lugar de con joins a colecciones cuando la pregunta es de existencia.
  • SELECT_IN para colecciones, JOINED para 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.
  • comment con 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 nativeUpdate y Unit of Work sobre la misma entidad en el mismo contexto.
  • em.assign(entity, req.body): mass assignment de manual.
  • Encadenar andWhere y orWhere esperando la precedencia de las matemáticas.
  • findAndCount por 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 ANALYZE delante.

16.17 Preguntas frecuentes

¿Cuándo debo usar QueryBuilder en lugar de em.find()?
Cuando el resultado deja de ser «entidades que cumplen una condición». Los tres indicadores claros son: necesitas un agregado (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?
Porque hay un 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.
Colecciones (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?
Puedes confiar en que genere SQL correcto y razonable. Lo que el ORM no puede saber es tu intención: no sabe que solo vas a usar tres de las veinte columnas, ni que la relación que has cargado no se va a leer, ni que ese 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?
Por el Identity Map (capítulo 14): dentro de un contexto, una fila es un objeto. MikroORM sí ejecuta la consulta, pero al hidratar reutiliza la instancia existente en lugar de crear otra, y por defecto no sobrescribe los cambios que tengas sin guardar. Si necesitas el estado real de la base de datos, usa 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?
No. El nulo se expresa con el valor: { 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?
Casi con total seguridad porque el valor llegó como cadena. Los parámetros de una URL son siempre texto: ?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?
Es mejor en rendimiento y en estabilidad, pero no da lo que a veces se necesita: saltar a la página 47 y mostrar «página 3 de 210». Si el conjunto es pequeño (unos miles de filas), la interfaz tiene navegador de páginas y los datos no cambian mientras se navegan, offset/limit es más simple y suficiente. En feeds, historiales, listas infinitas, exportaciones y APIs públicas, cursor. Nada impide ofrecer ambas en distintos endpoints, siempre que el contrato de respuesta sea coherente.
¿Puedo devolver entidades directamente desde el controlador?
Técnicamente sí, y es cómodo al principio. Es mala idea a medio plazo por cuatro razones: publicas columnas sensibles por omisión (una lista negra falla en cuanto alguien añade una columna), acoplas el contrato público al esquema de la base de datos (renombrar una columna rompe a todos los clientes), arriesgas recursiones infinitas al serializar relaciones bidireccionales, y pierdes el lugar natural donde documentar la API. Los DTOs cuestan código de mapeo; ese código es la frontera de tu sistema.
¿La caché del ORM me quita las consultas repetidas?
Solo si la consulta es idéntica y está dentro del TTL. Sin clave explícita, la clave se deriva del SQL y de los parámetros, así que en un listado con filtros la tasa de acierto es mínima. Además, el adaptador por defecto vive en la memoria del proceso: con varias réplicas, varias cachés incoherentes. Sirve para catálogos y consultas caras que se repiten idénticas; para el resto, una caché de dominio con Redis y una política de invalidación explícita (capítulo 11).
¿Cómo sé si me falta un índice?
Con 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()?
Porque el driver de PostgreSQL devuelve 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?
En dos niveles complementarios. Sin base de datos: 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

Nivel 1 · básico

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?

Nivel 2 · intermedio

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.

Nivel 3 · avanzado

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.
  • FilterQuery convierte las condiciones en datos. Se construyen, se combinan, se tipan y se prueban como funciones puras. Los operadores de colección ($some, $none, $every) generan EXISTS y 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 falta populate. 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_IN para colecciones, JOINED para 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 OFFSET alto 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 y disableIdentityMap en 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, comment con el endpoint, EXPLAIN ANALYZE ante cualquier duda y una lista de comprobación reproducible ante un endpoint lento.

16.20 Recursos adicionales

Siguiente paso Ya sabes leer y escribir datos con criterio, y predecir el SQL de cada línea. El capítulo 17 cierra la Parte IV con lo que rodea a las consultas: transacciones y niveles de aislamiento, bloqueo optimista y pesimista, migraciones, filtros globales, subscribers y las técnicas de rendimiento que quedan por ver.