Java EE / Jakarta EE
JPQL, la API Criteria y las consultas nativas en Jakarta Persistence
Intermedio8 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
JPQL es el lenguaje de consultas orientado a objetos de Jakarta Persistence: se parece a SQL, pero nombra entidades y sus atributos en lugar de tablas y columnas. Esta guía recorre el lenguaje desde los selects sencillos hasta los fetch joins, los agregados, las subconsultas y las proyecciones a DTO; después muestra la API Criteria para consultas dinámicas, los entity graphs para controlar la carga, las actualizaciones masivas y el SQL nativo, junto con las trampas de rendimiento y de corrección que trae cada uno: N+1, paginación con fetch joins y contextos de persistencia desactualizados.
Lo que aprenderás
- Escribir selects JPQL con joins, fetch joins, condiciones, ordenación, agrupación y subconsultas
- Devolver DTO con expresiones constructor y paginar resultados de forma segura
- Construir consultas dinámicas y tipadas con la API Criteria y el metamodelo estático
- Controlar la carga con entity graphs y evitar los selects N+1
- Usar actualizaciones masivas, consultas nativas y mapeos de result set sin corromper el contexto de persistencia
Mapear entidades sirve para meter y sacar datos fila a fila; las consultas son la forma de obtener conjuntos. Jakarta Persistence ofrece tres maneras de hacerle una pregunta a la base de datos: JPQL, un lenguaje de cadenas sobre el modelo de objetos; la API Criteria, el mismo lenguaje construido con objetos Java; y el SQL nativo para los casos que la abstracción no cubre. Las tres devuelven entidades managed o valores simples a través de las mismas interfaces Query, y las tres interactúan con el contexto de persistencia de maneras en las que es fácil equivocarse. Los ejemplos usan las entidades Order, OrderLine, Customer y Product de la guía de mapeo.
JPQL en una página
JPQL se lee como SQL, pero cada identificador es un nombre Java: nombres de entidad (Order, no orders), rutas de atributos (o.customer.name) y literales Java. En la práctica los alias son obligatorios; las palabras clave no distinguen mayúsculas de minúsculas, pero los identificadores sí; y el resultado de un select es lo que nombres en la cláusula SELECT: entidades, atributos, agregados u objetos construidos.
TypedQuery<Order> q = em.createQuery("""
select o
from Order o
join o.customer c
where c.country = :country
and o.status in :statuses
and o.placedAt >= :since
order by o.placedAt desc""", Order.class);
List<Order> recent = q.setParameter("country", "FR")
.setParameter("statuses", EnumSet.of(OrderStatus.NEW, OrderStatus.PAID))
.setParameter("since", Instant.now().minus(30, ChronoUnit.DAYS))
.setMaxResults(50)
.getResultList();
TypedQuery<T> es la variante que debes usar: hace explícito el tipo del resultado y evita los casts. Los parámetros son con nombre (:country) o posicionales (?1); los que llevan nombre son más claros y se pueden reutilizar dentro de la consulta. Los parámetros de tipo colección se expanden en una lista IN. Nunca construyas la cadena de la consulta a partir de la entrada del usuario: además de la inyección, cada cadena distinta se analiza y se cachea por separado.
La cláusula WHERE admite los operadores SQL que esperas (=, <>, <, BETWEEN, LIKE con ESCAPE, IS NULL, IN, EXISTS), funciones de cadenas, aritméticas y de fechas (CONCAT, SUBSTRING, LOWER, LENGTH, SIZE, CURRENT_TIMESTAMP) y operadores propios del modelo de objetos: IS EMPTY para colecciones, MEMBER OF para la pertenencia, TYPE(o) para jerarquías de herencia y TREAT(o AS RushOrder) para hacer downcast. Persistence 3.1 añadió LOCAL DATE, LOCAL TIME y LOCAL DATETIME para valores java.time, EXTRACT(YEAR FROM o.placedAt) y las funciones numéricas CEILING, FLOOR, EXP, LN, POWER, ROUND y SIGN.
Joins y navegación por rutas
Una ruta a través de una asociación monovaluada se puede usar directamente (o.customer.name) e implica un inner join. Las asociaciones de tipo colección hay que unirlas explícitamente:
select distinct o
from Order o
join o.lines l
join l.product p
where p.category = 'BOOKS'
El distinct es necesario porque el join produce una fila por cada línea que cumple la condición y, sin él, JPQL devolvería el mismo pedido varias veces, igual que haría SQL. left join conserva los pedidos sin líneas. join … on (JPA 2.1) añade condiciones al propio join. Unir una entidad que no tiene asociación mapeada (join Product p on p.sku = l.sku) no está en la especificación 3.1, pero tanto Hibernate como EclipseLink lo soportan como extensión, lo cual resulta útil con esquemas heredados.
Fetch joins: la herramienta contra el N+1
El siguiente bucle lanza una consulta para los pedidos y después una por pedido para sus líneas; es el problema N+1:
for (Order o : em.createQuery("select o from Order o", Order.class).getResultList()) {
total += o.getLines().size(); // lazy collection: SELECT ... WHERE order_id = ? each time
}
Un fetch join carga la asociación en la misma sentencia y la inicializa en las entidades devueltas:
select distinct o
from Order o
left join fetch o.lines
left join fetch o.customer
where o.status = :status
En JPQL portable, los fetch joins no llevan alias y no se pueden filtrar (un fetch filtrado dejaría la colección cargada a medias y, por tanto, incorrecta). Traer dos colecciones a la vez produce un producto cartesiano (diez líneas por tres pagos son treinta filas por pedido) y Hibernate se niega a traer varios bags juntos lanzando MultipleBagFetchException. Trae una sola colección por consulta, o usa colecciones Set y asume el producto cuando los tamaños son pequeños.
Agregados, agrupación y proyecciones
Los agregados (COUNT, SUM, AVG, MIN, MAX) funcionan con GROUP BY y HAVING exactamente igual que en SQL. Los resultados que no son entidades llegan como Object[] a menos que indiques un tipo. Las expresiones constructor crean un DTO directamente:
record CustomerRevenue(String name, long orders, BigDecimal revenue) { }
List<CustomerRevenue> top = em.createQuery("""
select new shop.report.CustomerRevenue(c.name, count(o), sum(o.total.amount))
from Order o join o.customer c
where o.status = shop.model.OrderStatus.PAID
group by c.name
having sum(o.total.amount) > :minimum
order by sum(o.total.amount) desc""", CustomerRevenue.class)
.setParameter("minimum", new BigDecimal("10000"))
.setMaxResults(20)
.getResultList();
Una consulta de proyección carga exactamente las columnas que pides, no crea entidades managed y no puede provocar sorpresas de lazy loading más adelante, por lo que es la forma adecuada para pantallas de solo lectura e informes. Fíjate en el literal del enum: el JPQL portable exige el nombre totalmente cualificado de la constante, aunque Hibernate 6 también acepta la forma corta.
Subconsultas y predicados sobre colecciones
Las subconsultas se permiten en WHERE y HAVING (where o.total.amount > (select avg(x.total.amount) from Order x)), con EXISTS, ALL, ANY y SOME. Los predicados sobre colecciones expresan muchas condiciones del tipo «tiene al menos uno» sin un join explícito: where exists (select l from o.lines l where l.product.discontinued = true) o, más sencillo, where :product member of o.products.
Named queries, resultados únicos y paginación
@NamedQuery asocia una consulta a una entidad y la valida al arrancar la unidad de persistencia, de modo que una errata hace fallar el despliegue en lugar de la primera petición de un usuario:
@Entity
@NamedQuery(name = "Order.byReference",
query = "select o from Order o where o.reference = :ref")
public class Order { ... }
Order order = em.createNamedQuery("Order.byReference", Order.class)
.setParameter("ref", ref)
.getSingleResult();
getSingleResult lanza NoResultException si no hay filas y NonUniqueResultException si hay más de una; ambas son excepciones de runtime y, dentro de una transacción gestionada por el contenedor, la primera no es por sí sola una condición de rollback, aunque a menudo se propaga como tal. getResultStream devuelve un Stream que algunos proveedores respaldan con un cursor desplazable: ciérralo. Para una fila que puede no existir, getResultList().stream().findFirst() evita la excepción; la próxima versión de la especificación añade getSingleResultOrNull precisamente para esto.
La paginación es setFirstResult(offset) más setMaxResults(size), que se traducen al LIMIT/OFFSET de la base de datos o a su equivalente. Dos reglas: añade siempre un ORDER BY determinista (una página sin un orden total devuelve filas distintas en cada ejecución) y no combines nunca la paginación por offset con un fetch join sobre una colección, por el motivo que explica la FAQ. Para páginas profundas, la paginación por clave (keyset, where o.placedAt < :lastSeen order by o.placedAt desc) es más rápida y además estable frente a inserciones.
Entity graphs
Los fetch joins arreglan la carga de una consulta concreta. Los entity graphs describen qué cargar con independencia de la consulta y se pasan como hint, lo que encaja con repositorios que exponen una misma consulta con varios perfiles de carga:
@NamedEntityGraph(name = "Order.withLines",
attributeNodes = { @NamedAttributeNode("customer"),
@NamedAttributeNode(value = "lines", subgraph = "lines") },
subgraphs = @NamedSubgraph(name = "lines", attributeNodes = @NamedAttributeNode("product")))
@Entity
public class Order { ... }
EntityGraph<?> graph = em.getEntityGraph("Order.withLines");
Order order = em.find(Order.class, id, Map.of("jakarta.persistence.fetchgraph", graph));
fetchgraph carga en eager exactamente los atributos indicados y trata todo lo demás como lazy; loadgraph carga los atributos indicados además de lo que ya es eager por mapeo. Los grafos funcionan con find y con consultas mediante setHint. Cómo los ejecuta el proveedor (un join, varios selects) queda a su criterio; Hibernate usa joins por defecto.
La API Criteria para consultas dinámicas
Las pantallas de búsqueda con filtros opcionales son el caso clásico en el que concatenar fragmentos de JPQL se vuelve feo. La API Criteria construye la misma consulta a partir de objetos y, con el metamodelo estático (las clases generadas Order_, Customer_ que produce el annotation processor del proveedor), el compilador comprueba las referencias a atributos:
public List<Order> search(String customer, OrderStatus status, Instant since) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Order> cq = cb.createQuery(Order.class);
Root<Order> o = cq.from(Order.class);
Join<Order, Customer> c = o.join(Order_.customer);
List<Predicate> where = new ArrayList<>();
if (customer != null) where.add(cb.like(cb.lower(c.get(Customer_.name)), customer.toLowerCase() + "%"));
if (status != null) where.add(cb.equal(o.get(Order_.status), status));
if (since != null) where.add(cb.greaterThanOrEqualTo(o.get(Order_.placedAt), since));
cq.select(o).where(where.toArray(Predicate[]::new))
.orderBy(cb.desc(o.get(Order_.placedAt)));
return em.createQuery(cq).setMaxResults(100).getResultList();
}
Todo lo que hace JPQL (fetch joins con o.fetch(Order_.lines, JoinType.LEFT), subconsultas con cq.subquery(...), proyecciones con constructor mediante cb.construct(...), agrupación, CriteriaUpdate y CriteriaDelete) tiene su forma en Criteria. El precio es la verbosidad: reserva Criteria para las consultas realmente dinámicas y usa JPQL para el resto. Sin el metamodelo vuelves a usar nombres de atributo en cadenas de texto (o.get("status")), con lo que pierdes la comprobación de tipos que justificaba la verbosidad.
Actualizaciones y borrados masivos
Las sentencias UPDATE y DELETE de JPQL se ejecutan como una única sentencia SQL, que es la forma eficiente de modificar muchas filas:
int archived = em.createQuery("""
update Order o set o.status = :archived
where o.status = :closed and o.placedAt < :cutoff""")
.setParameter("archived", OrderStatus.ARCHIVED)
.setParameter("closed", OrderStatus.CLOSED)
.setParameter("cutoff", cutoff)
.executeUpdate();
Se saltan por completo el contexto de persistencia: las entidades ya cargadas no se actualizan, @Version no se incrementa salvo que lo hagas en la propia sentencia, las cascadas y orphanRemoval no se ejecutan y los callbacks de ciclo de vida no se invocan. Lanza las sentencias masivas antes de cargar entidades en la misma transacción, o llama a em.clear() después. Además, requieren una transacción activa.
SQL nativo
Cuando JPQL no puede expresar la consulta (funciones de ventana, CTE recursivas, operadores JSON propios de una base de datos, hints para el optimizador), usa createNativeQuery. El resultado se puede mapear a una entidad, a escalares o a un DTO mediante @SqlResultSetMapping:
@SqlResultSetMapping(name = "DailyRevenue",
classes = @ConstructorResult(targetClass = DailyRevenue.class,
columns = { @ColumnResult(name = "day", type = LocalDate.class),
@ColumnResult(name = "revenue", type = BigDecimal.class) }))
List<DailyRevenue> series = em.createNativeQuery("""
select date_trunc('day', placed_at)::date as day,
sum(total_amount) over (order by date_trunc('day', placed_at)) as revenue
from orders where status = 'PAID'
group by 1 order by 1""", "DailyRevenue")
.getResultList();
Las consultas nativas que devuelven entidades producen instancias managed como cualquier otra, así que se aplican las mismas reglas de lazy loading. Lo que pierdes es la portabilidad entre bases de datos y el conocimiento que el proveedor tiene del mapeo: los nombres de columna del SQL deben coincidir con el esquema, no con la entidad. Los procedimientos almacenados tienen su propia API (@NamedStoredProcedureQuery, createStoredProcedureQuery) con parámetros IN, OUT y de cursor.
Hints, timeouts y bloqueos
setHint pasa hints estándar o del proveedor: jakarta.persistence.query.timeout (en milisegundos), jakarta.persistence.lock.timeout, los hints de entity graph vistos antes y los org.hibernate.readOnly u org.hibernate.fetchSize de Hibernate. setLockMode(LockModeType.PESSIMISTIC_WRITE) convierte el select en SELECT … FOR UPDATE, de modo que las filas quedan bloqueadas hasta el commit; LockModeType.OPTIMISTIC_FORCE_INCREMENT incrementa la versión de un padre cuando cambias sus hijos, que es como consigues que la versión de un agregado cubra sus partes. Hibernate también ofrece una caché de segundo nivel y una caché de consultas, ambas desactivadas por defecto, que solo merece la pena activar para datos de referencia que cambian poco.
Errores habituales
- Selects N+1. Recorrer los resultados tocando asociaciones lazy. Fetch join, entity graph o proyección.
- Paginación por offset con un fetch join sobre una colección. Todo el result set en memoria. Pagina la raíz y después carga.
- Traer dos bags a la vez.
MultipleBagFetchExceptiono una explosión cartesiana. Una colección por consulta. - Olvidar
distincten joins sobre colecciones. Entidades raíz duplicadas en la lista. - Actualización masiva sin
clear(). Entidades desactualizadas y comprobaciones de versión omitidas. Limpia el contexto o lanza antes la sentencia masiva. getSingleResultpara filas opcionales.NoResultExceptionen el caso normal. UsagetResultListyfindFirst.- JPQL construido con cadenas a partir de la entrada del usuario. Inyección y una caché de planes de consulta saturada. Parámetros y Criteria.
- Omitir
ORDER BYen consultas paginadas. Filas que se repiten o desaparecen entre páginas. Ordena siempre por una clave única.
Puntos clave
- JPQL nombra entidades y atributos, no tablas y columnas; usa
TypedQueryy parámetros con nombre, y nunca concatenes entrada del usuario. - Las colecciones necesitan joins explícitos;
distinctelimina las raíces duplicadas;JOIN FETCHcarga las asociaciones en la misma sentencia y es el remedio principal contra el N+1. - Las expresiones constructor devuelven DTO sin crear entidades managed, que es lo que deberían usar las pantallas de solo lectura.
- Los entity graphs describen la carga con independencia de la consulta y se pasan como hints
fetchgrapholoadgraph. - Usa la API Criteria con el metamodelo generado para los filtros dinámicos y JPQL para todo lo estático.
- Los
UPDATE/DELETEmasivos se saltan el contexto de persistencia (límpialo después), y el SQL nativo es la vía de escape para funcionalidades propias de la base de datos, con@SqlResultSetMappingpara dar forma a los resultados. - Pagina con un orden determinista y nunca junto con un fetch join sobre una colección.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿Qué diferencia hay entre JOIN y JOIN FETCH?
- Un JOIN normal te permite filtrar o proyectar sobre la entidad asociada, pero no la carga en el resultado: la asociación sigue siendo lazy. JOIN FETCH carga la entidad o la colección asociada en la misma sentencia SQL y la inicializa en las entidades devueltas, y así es como se evitan los selects N+1 para un patrón de acceso conocido.
- ¿Por qué mi consulta paginada con un fetch join lo carga todo?
- Un fetch join sobre una colección multiplica las filas: un pedido con cinco líneas se convierte en cinco filas. El proveedor no puede aplicar LIMIT/OFFSET a esas filas sin partir una entidad por la mitad, así que Hibernate aplica la paginación en memoria después de traer el result set completo y registra un aviso. Pagina primero sobre la entidad raíz y luego carga las colecciones de esa página con una segunda consulta o un entity graph.
- ¿Debo usar JPQL, Criteria o SQL nativo?
- JPQL para las consultas que conoces en tiempo de desarrollo: es legible y, si usas named queries, se valida al arrancar la unidad de persistencia. Criteria para las consultas que se construyen en tiempo de ejecución a partir de filtros opcionales, donde concatenar cadenas sería frágil. SQL nativo cuando necesitas una funcionalidad de la base de datos que JPQL no puede expresar (funciones de ventana, CTE recursivas, hints del fabricante) o para consultas de reporting que no deberían crear ninguna entidad.
- ¿Un UPDATE masivo en JPQL actualiza las entidades que ya tengo cargadas?
- No. Los UPDATE y DELETE masivos van directamente a la base de datos sin pasar por el contexto de persistencia, así que las entidades managed conservan su estado anterior y su versión no se incrementa. Ejecuta las sentencias masivas al principio de la transacción, o llama a clear() después y vuelve a cargar lo que necesites.