
El gestor de entidades permite recuperar las entidades que conforman el modelo de datos, incluyendo relaciones entre ellas, gracias a las funciones de carga perezosa e inmediata. Pero no es suficiente. En cualquier aplicación de gestión necesitaremos explotar la información almacenada en la base de datos realizando filtros más o menos complejos, comprobando relaciones, efectuando cálculos… En definitiva, son imprescindibles las posibilidades que nos brinda el lenguaje SQL.
JPA posibilita el uso de SQL aprovechando la abstracción que provee el modelo de entidades y la gestión de transacciones del servidor. No obstante, proporciona dos alternativas para la realización de consultas y que, a diferencia de SQL, son independientes de la base de datos subyacente: el lenguaje JPQL, parecido a SQL, y la API Criteria. La primera se fundamenta en cadenas de texto y la segunda en objetos y métodos.
Nota. Los capítulos dedicados a JPQL asumen que el lector conoce los fundamentos básicos de SQL. No voy a describir con excesivo detalle cómo funciona una consulta, las uniones entre tablas, etcétera.
JPQL y HQL
JPQL es el lenguaje de consultas de Jakarta Persistence. Su sintaxis está basada en SQL, lenguaje ideal para estos menesteres y que todos los programadores conocen (o deberían) aunque sea con un nivel básico. La gran novedad de JPQL es que no utiliza tablas y columnas, sino entidades y sus atributos. Es decir, trabajamos con el modelo de datos de la aplicación y nos abstraemos por completo de la estructura de tablas. Así pues, podemos definir JPQL como un lenguaje de consultas de entidades. La implementación de JPA se encargará de traducir las sentencias JPQL a SQL para que sean ejecutadas en la base de datos a través del controlador JDBC.
JPQL es una estandarización de HQL (Hibernate Query Language, Lenguaje de consultas de Hibernate). Las consultas escritas con ambos se ejecutan sin distinción alguna en Hibernate con el gestor de entidades de JPA y sus clases asociadas. Si bien todas las sentencias JPQL son válidas en HQL, lo contrario no siempre es cierto porque HQL posee características no recogidas en el estándar. Veremos algunas de ellas que nos harán la vida más fácil.
Antes que nada, echemos un vistazo al modelo de entidades de prueba, proveniente de los ejemplos de capítulos previos.

A continuación se muestra una consulta SQL que obtiene todos los gastos ordenados por fecha cuya cuantía sea al menos 100 unidades monetarias.
SELECT e.*
FROM expenses e
WHERE e.amount >= 100
ORDER BY e.date DESC
La equivalencia en JPQL es esta.
SELECT e
FROM Expense e
WHERE e.amount >= 100
ORDER BY e.date DESC
La estructura es la misma, pero en este ejemplo tan básico ya apreciamos diferencias. La cláusula FROM se aplica a una entidad y no a su tabla. Recordemos que el nombre de una entidad coincide con el de la clase, a menos que se indique otro en la anotación @Entity. En la proyección (los datos que se devolverán), si queremos toda la entidad indicamos su alias. Además, en JPQL siempre hay que respetar la capitalización de los nombres de entidades y atributos. En el curso seguiré la práctica habitual de escribir en mayúsculas las palabras reservadas y funciones de JPQL y SQL.
En IntelliJ, el plugin JPA Buddy nos ayuda en la construcción de consultas, además de otras funcionalidades muy útiles.

La ejecución de consultas JPQL\HQL la trataremos en los dos próximos capítulos. Adelanto que las que vamos a ver pueden ejecutarse así
entityManager.createQuery("SELECT c FROM Coupon c ")
.getResultList();
Los IDE con soporte de JPA suelen disponer de consolas de ejecución de consultas creadas a partir de un fichero persistence.xml y una conexión a base de datos que tendremos que configurar. En IntelliJ, lo tenemos en la vista persistence.

JPQL proporciona un buen número de las características básicas de SQL. Veamos qué podemos hacer y qué no.
SELECT
Es posible devolver entidades completas, valores escalares (cadenas, números, booleanos, fechas…) o una combinación de ambos. Los escalares pueden ser valores constantes, atributos de entidades, o bien el resultado de expresiones aritméticas o ciertas funciones que ya veremos, incluyendo las de agregación (COUNT, SUM, MAX…)
SELECT e.concept, e.date
FROM Expense e
La cláusula SELECT es opcional en HQL cuando la proyección consiste en la variable del FROM.
FROM Expense
No obstante, la usaré en todos los ejemplos procurando la máxima compatibilidad con el estándar.
Evitaremos resultados duplicados con el operador DISTINCT.
SELECT DISTINCT e.date
FROM Expense e
Se puede navegar por las relaciones combinando el nombre del atributo con el operador «punto» para acceder a atributos específicos.
SELECT e, e.category.name
FROM Expense e
Veremos esta «magia» con más detenimiento en la próxima entrega del curso porque conlleva la reunión o join de entidades.
Estructura CASE WHEN
También se admite el retorno de escalares con la estructura de selección case. Se comporta como si de una sentencia switch o estructura if-else se tratara.
CASE (atributo) WHEN (condición) THEN (devolver escalar)..ELSE (devolver escalar) END
Las condiciones se evalúan en orden. En cuanto se verifique una, se devuelve un escalar y no se comprueban las restantes. Si no se cumple ninguna, se retorna el resultado de la cláusula else. Al igual que sucede con la cláusula SELECT, CASE-WHEN no admiten subconsultas.
Esta consulta obtiene un listado con el identificador de todos los gastos y un texto cuyo valor depende del importe.
SELECT e.id,
CASE
WHEN e.amount > 50 THEN 'EXPENSIVE'
WHEN e.amount > 20 THEN 'STANDARD'
ELSE 'SMALL'
END
FROM Expense e
En el when se ha indicado la condición. También se puede usar para efectuar un chequeo «es igual que» sobre un valor definido en el case del siguiente modo.
SELECT c.id,
CASE c.name
WHEN 'FOOD' THEN 'Alimentación'
WHEN 'FUEL' THEN 'Combustible'
ELSE 'Otro'
END
FROM Category c
Un uso típico es el retorno de un valor lógico calculado. Así podemos averiguar la existencia de un gasto sin llegar a obtenerlo.
SELECT
CASE
WHEN (COUNT(e) > 0) THEN true
ELSE false
END FROM Expense e
WHERE e.id = 14
COALESCE es una forma simplificada de escribir un bloque CASE WHEN cuando necesitamos evaluar valores nulos. Recibe una lista de argumentos y devuelve el primero que no sea nulo siguiendo el orden establecido. La siguiente consulta retorna el comentario del gasto salvo que sea nulo, en cuyo caso devuelve el concepto.
SELECT COALESCE(e.comments, e.concept)
FROM Expense e
Equivale a esta otra.
SELECT
CASE
WHEN e.comments IS NOT NULL THEN e.comments
ELSE e.concept
END
FROM Expense e
NULLIF es otro caso particular de evaluación de tipo CASE. Devuelve NULL si los dos valores escalares que recibe como argumento son iguales. De no ser así, retorna el primero de ellos.
Este ejemplo obtiene para cada gasto su concepto si no coincide con su descripción. Cuando ambos sean iguales, retorna NULL.
SELECT NULLIF(e.concept, e.comments)
FROM Expense e
Es lo mismo que lo siguiente.
SELECT
CASE
WHEN e.concept = e.comments THEN NULLL
ELSE e.concept
END
FROM Expense e
Proyección directa en DTO
No siempre necesitaremos recuperar entidades, ya sean incompletas o con todos sus atributos, o un escalar. De hecho, a veces precisaremos de una clase a medida de la consulta.
Los valores proyectados se pueden encapsular en un objeto mediante un constructor con la signatura adecuada. Hablamos de DTOs (objetos para la transferencia de datos), clases que se limitan a contener datos y que debemos procurar que sean inmutables. No poseen ninguna funcionalidad, aparte de proveer getters. Desde Java 14, se pueden reemplazar con records.
SELECT new com.danielme.jakartaee.jpa.ExpenseDTO(e.concept, e.date, e.amount, e.category.name)
FROM Expense e
La clase ExpenseDTO con lombok queda muy simple. Se le podría aplicar solo la anotación @Data que, además de @Getter y @AllArgsConstructor, incluye @ToString y @EqualsAndHasCode.
package com.danielme.jakartaee.jpa;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.math.BigDecimal;
import java.time.LocalDate;
@Getter
@AllArgsConstructor
public class ExpenseDTO {
private final String concept;
private final LocalDate date;
private final BigDecimal amount;
private final String category;
}
¿Entidad o DTO?
En general, solo debemos retornar entidades si necesitamos trabajar con ellas en un contexto de persistencia, por lo común para realizar cambios. En los demás casos, es más eficiente y seguro recoger la información estrictamente necesaria en cada momento en DTOs o records mediante la técnica del constructor. Es fácil olvidar que si recuperamos entidades asumimos un coste.
- Se almacenan en el contexto de persistencia.
- Se hace un seguimiento de sus posibles cambios y se trasladan a la base de datos. Veremos cómo obtener entidades de solo lectura en el próximo capítulo.
- Se obtienen todas sus columnas asociadas en las tablas, las necesitemos o no.
- La tentación de obtener relaciones perezosas, de forma cómoda pero generalmente ineficiente, está ahí.
Un beneficio de los DTO desde el punto de vista de la arquitectura es que pueden enviarse a cualquier capa o módulo de la aplicación, por ejemplo a las clases con los servicios de una API REST, pues son almacenes de datos de propósito general. Si hacemos lo mismo con entidades, estaremos esparciendo por todo el sistema código perteneciente a las clases de persistencia porque las entidades poseen anotaciones de JPA \ Hibernate y reflejan la estructura de la base de datos. Esto debe evitarse si queremos construir sistemas flexibles y mantenibles gracias a un elevado grado de abstracción y el bajo acoplamiento entre sus componentes.
Con respecto a lo anterior, la solución habitual para evitar exponer las entidades fuera de su entorno natural consiste en volcar sus datos con métodos «mapeadores» en DTOs. Una tarea tediosa, evitable en muchos casos si hacemos que las consultas devuelvan esos DTOs cuando las entidades no sean imprescindibles.
FROM
En esta cláusula declaramos las entidades con las que vamos a trabajar, asignándoles una variable o alias para poder referenciarlas en las proyecciones, el WHERE o incluso en el propio FROM. Encontraremos declaraciones individuales o reuniones de entidades con JOIN. Estas reuniones merecen especial atención, así que les dedicaré un capítulo propio.
WHERE
A nivel conceptual, la cláusula WHERE de JPQL tampoco difiere de su homónima en SQL. Se utiliza para definir condiciones de selección basadas en operadores aritméticos y lógicos, funciones, etcétera, de los resultados producidos por las reuniones de entidades definidas en el FROM.
Parámetros
En esta consulta, siempre seleccionamos los gastos de al menos cien unidades monetarias.
SELECT e
FROM Expense e
WHERE e.amount >= 100
¿Y si queremos aplicar cualquier otra cantidad? Necesitamos que la cantidad no sea fija, sino un parámetro o variable al que daremos valor cada vez que se ejecute la consulta.
En JDBC, todos los parámetros de las consultas SQL se declaran con el caracter ? , lo cual nos obliga a proporcionar sus valores desde el código siguiendo escrupulosamente el orden de declaración porque es la única manera de distinguirlos. Es un sistema propenso a errores y que dificulta la lectura del código. En JPQL, la parametrización es más práctica porque cada parámetro distinto se identifica con un índice.
SELECT e
FROM Expense e
WHERE e.amount >= ?1
O, mejor todavía, con un nombre descriptivo.
SELECT e
FROM Expense e
WHERE e.amount >= :amount
En los dos casos, la asignación de los valores a los parámetros en código se hace mediante su identificador y no tenemos que preocuparnos por seguir ningún orden específico. Lo veremos en el próximo capítulo.
Expresiones
Las expresiones de selección se construyen con los atributos de las entidades y unos operadores provenientes de SQL que no necesitan presentación.
- Aritméticos: suma (+), resta (-), multiplicación(*), división(/) y los unarios «+» y «-«.
- Lógicos: AND, OR y NOT.
- De comparación: =, >, >=,<,<=,<>, BETWEEN (es inclusivo), NOT BETWEEN, LIKE, NOT LIKE, IN, NOT IN, IS NULL, IS NOT NULL, IS TRUE, IS FALSE, EXISTS, NOT EXISTS, SOME, ANY, ALL. HQL admite «!=» para expresar desigualdad.
Una ausencia llamativa es UNION, aunque -probablemente- estará disponible en HQL en Hibernate 6.
Al igual que en las SELECT, podemos navegar por las relaciones.
SELECT e
FROM Expense e
WHERE e.category.name LIKE :catName
Colecciones
JPQL añade cuatro operadores exclusivos aplicables a colecciones.
IS NOT EMPTY y IS EMPTY informan, respectivamente, si una colección tiene elementos o no. Busquemos las categorías cuyo listado de presupuestos no está vacío.
SELECT c
FROM Category c
WHERE c.budgets IS NOT EMPTY
Estos operadores se traducen en SQL con una verificación de la existencia de resultados para una subconsulta -las veremos en breve- realizada con el operador EXISTS. En nuestro ejemplo, si la lista no está vacía es porque «existe» al menos un presupuesto que deba estar en ella.
SELECT
category0_.id as id1_5_,
category0_.color_hex as color_he2_5_,
category0_.name as name3_5_
FROM
categories category0_
WHERE EXISTS (
SELECT
budget2_.id
FROM budgets_categories budgets1_,
budgets budget2_
WHERE
category0_.id = budgets1_.category_id
and budgets1_.budget_id = budget2_.id)
Los operadores MEMBER OF y NOT MEMBER OF verifican la posible pertenencia de una entidad a una colección. Obtengamos las categorías que no formen parte del listado de categorías de cierto presupuesto, el cual indicamos con un parámetro.
SELECT c
FROM Category c
WHERE :budget NOT MEMBER OF c.budgets
El SQL generado revela que la condición de no pertenencia a la colección se ha implementado con NOT IN.
SELECT
category0_.id as id1_5_,
category0_.color_hex as color_he2_5_,
category0_.name as name3_5_
FROM
categories category0_
WHERE
? NOT IN (
SELECT
budgets1_.budget_id
FROM
budgets_categories budgets1_
WHERE
category0_.id = budgets1_.category_id)
:budget se corresponde con una entidad que debemos proporcionar, pero es suficiente con que su objeto contenga el identificador.
Subconsultas
La selección del WHERE en las dos SQL anteriores se apoya en subconsultas. Una subconsulta es una consulta «normal» escrita entre paréntesis y ubicada dentro de otra llamada padre o principal (se permiten múltiples niveles de anidamiento). En caso de que sea necesario vincular ambas, la subconsulta puede acceder a las tuplas de la consulta padre. El resultado de la subconsulta se usa en condiciones (lo más común), proyecciones, el retorno del THEN de un CASE…WHEN, o incluso como si fuera una tabla del FROM (tablas derivadas). Las posibilidades dependerán de cada base de datos.

Las subconsultas también existen en JPQL, pero su aplicación es más limitada: solo pueden aparecer dentro de los WHERE, HAVING y en el SET que acompaña a UPDATE.
Reescribamos las dos consultas JPQL de la sección anterior replicando el SQL que generaron.
IS NOT EMPTY
SELECT c
FROM Category c
WHERE EXISTS (
SELECT 1
FROM Budget b JOIN b.categories bc
WHERE bc.id = c.id )
La subconsulta obtiene los presupuestos relacionados con la categoría correspondiente a la variable c de la consulta padre. Debería ejecutarse para cada categoría que procese la consulta padre, aunque las bases de datos pueden realizar optimizaciones que minimicen el impacto en el rendimiento. Dado que queremos saber si «existe» algún presupuesto que cumpla con el requerimiento (no importa cuál), basta con devolver cualquier valor para indicarlo, y suele seguirse la convención de retornar 1.
NOT MEMBER OF
SELECT c
FROM Category c
WHERE :budgetId NOT IN (
SELECT b.id
FROM Budget b JOIN b.categories bc
WHERE bc.id = c.id )
La subconsulta es idéntica, aunque ahora devuelve los identificadores de los presupuestos para que sean los valores de una expresión NOT IN.
Veamos otro ejemplo en el que se recuperan los gastos de cuantía superior al valor medio de todos ellos.
SELECT e
FROM Expense e
WHERE e.amount > (
SELECT AVG(e.amount)
FROM Expense e )
En esta ocasión, la subconsulta no accede a entidades de la consulta padre (la variable e declarada en la subconsulta sobrescribe a la del padre). Al ser independiente, la base de datos la ejecutará solo una vez. También podríamos ejecutarla nosotros y proporcionar el resultado como un parámetro a la condición, pero es más eficiente y cómodo hacerlo todo de una vez.
SELECT e
FROM Expense e
WHERE e.amount > :average
En HQL se permiten subconsultas en las SELECT. En la siguiente, se ha optado por una subconsulta en lugar de la expresión e.category.name para devolver el nombre de la categoría.
SELECT e.date, (SELECT c.name FROM Category c
WHERE e.category.id = c.id)
FROM Expense e
ORDER BY
La cláusula ORDER BY con la que, de forma opcional, se cierra una consulta, es análoga a la que encontramos en SQL. Emplea la misma sintaxis que la admitida en la anotación @OrderBy. Indicaremos una o varias propiedades de entidades incluídas en la proyección, separadas por comas, por las que se irá ordenando sucesivamente todo el conjunto de resultados. El criterio del orden se establece para cada propiedad con ASC (ascendente, opción predeterminada, no hace falta ponerla) o DESC (descendiente).
Con esta consulta se obtienen los gastos ordenados por fecha, primero los más recientes. Cuando varios tengan la misma, se ordenan por su cuantía, primero los de valor superior.
SELECT e
FROM Expense e
ORDER BY e.date DESC, e.amount DESC
Si no se especifica el orden, no aparecerá ORDER BY alguno en el SQL generado y la ordenación la decidirá la base de datos. No debemos confiar en su criterio, pues las bases de datos no garantizan un orden consistente.
¿Qué sucede cuando las propiedades que aparecen en el ORDER BY son nulas? Interesante cuestión. En HQL, contamos con las cláusulas NULLS FIRST \ NULLS LAST de SQL para solicitar que los valores nulos de una propiedad se situen al principio \ final de los resultados ordenados. Si no las empleamos, queda en manos de la base de datos decidir su posición.
Tomemos como ejemplo la entidad Expense en la que los comentarios pueden ser nulos. En la siguiente consulta, los gastos se ordenan de manera decreciente según sus comentarios, y aquellos que no los tengan aparecerán al final. El valor NULL se considera, por tanto, siempre menor que cualquier otro posible valor.
SELECT e
FROM Expense e
ORDER BY e.comments DESC NULLS LAST
Modificación de datos
¿Qué pasaría si tuviéramos que actualizar de una tacada el mismo valor para un conjunto de cupones? Tendríamos que obtenerlos en un listado y modificarlos de uno en uno. Es lo que hace el método mostrado a continuación: añade un prefijo al nombre de aquellos cupones que hayan sido descontados en un gasto y todavía no tengan ese prefijo.
@Override
public int updateUsedPrefixOneByOne() {
List<Coupon> coupons = em.createQuery("SELECT c FROM Coupon c WHERE c.expense IS NOT NULL " +
"AND SUBSTRING(c.name, 1, :length) NOT LIKE :prefix", Coupon.class)
.setParameter("length", Coupon.USED_PREFIX.length())
.setParameter("prefix", Coupon.USED_PREFIX)
.getResultList();
coupons.forEach(c -> c.setName(Coupon.USED_PREFIX + c.getName()));
return coupons.size();
}
En updateUsedPrefixOneByOne hay un par de conceptos que se veremos más adelante. La función SUBSTRING de JPQL extrae una cadena. En la consulta, a los parámetros de entrada (:prefix) se les da valor con el método setParameter.
En cualquier caso, pongamos el foco en las sentencias que Hibernate envía a la base de datos. Para cada cupón modificado, se realiza un UPDATE que vuelca todos los atributos de la entidad. Esto supone un problema de rendimiento cuando se actualicen muchos cupones.
SELECT ...
FROM coupons coupon0_
WHERE (coupon0_.expense_id IS NOT NULL) and (SUBSTRING(coupon0_.name, 1, 7) NOT LIKE '[USED] ')
UPDATE coupons set amount=?, expense_id=?, expiration=?, name=? WHERE id=?
UPDATE coupons set amount=?, expense_id=?, expiration=?, name=? WHERE id=?
...
Los UPDATE de JPQL funcionan de igual modo que los de SQL. Realicemos la acción anterior con una única actualización masiva, lo que se denomina «bulk update». Es bastante más rápido que hacerlo entidad a entidad. De hecho, es la única opción razonable cuando haya que modificar miles de registros en la tabla.
UPDATE Coupon c
SET c.name = CONCAT(:prefix, c.name)
WHERE c.expense IS NOT NULL AND SUBSTRING(c.name, 1, :length) NOT LIKE :prefix
Diseccionemos la operación. En la cláusula UPDATE se declara la variable para el tipo de entidad a actualizar. Solo es una, simplificación importante con respecto al SQL de muchas bases de datos. En SET indicamos la propiedad a actualizar y su nuevo valor (puede ser el resultado de una subconsulta). Si quisiéramos modificar más de una, separaremos cada expresión con una coma. Por último, tenemos una cláusula WHERE, igual a la que podemos escribir en las SELECT, para seleccionar las entidades que serán modificadas. Si no se proporciona, la operación se aplicará a todas.
También tenemos la opción de borrado de SQL. Aquí eliminamos los gastos inferiores a uno.
DELETE FROM Expense e
WHERE e.amount < 1
Primero se declara el tipo de entidad, y luego se indica con un WHERE las que deben borrarse. De nuevo, esta cláusula es opcional, así que cuidado porque si no la ponemos se eliminarán todas y generalmente no querremos eso.
Es importante tener en cuenta que la eliminación nunca se propaga hacia las relaciones, incluso aunque se haya configurado su borrado en cascada. Por ello, se pueden violar restricciones de integridad si no vamos eliminando las entidades relacionadas en el orden adecuado.
Con respecto a la actualización y borrado masivo, la documentación oficial hace esta advertencia.
Se debe tener cuidado al ejecutar operaciones de actualización o eliminación masiva porque pueden dar lugar a incoherencias entre la base de datos y las entidades en el contexto de persistencia activo. En general, las operaciones de actualización y eliminación masivas solo deben realizarse dentro de una transacción en un nuevo contexto de persistencia o antes de obtener o acceder a entidades cuyo estado podría verse alterado por dichas operaciones.
El problema es que las entidades que ya estuvieran en el contexto de persistencia en el que se ejecuta la actualización o borrado no se ven afectadas. Volveremos sobre este asunto en el próximo capítulo.
HQL contempla la inserción, con lo que disponemos del juego de operaciones CRUD de SQL: INSERT, SELECT, UPDATE y DELETE. Eso sí, solo inserta los valores procedentes de una SELECT. En el siguiente ejemplo «clonamos» el gasto con identificador expenseId en una nueva entidad, pero con una fecha pasada como parámetro. El identificador, por ser autogenerado, no lo establecemos.
INSERT INTO Expense(amount, concept, date, category)
SELECT e.amount, e.concept, :date, e.category
FROM Expense e
WHERE e.id = 1