Curso Jakarta EE 9 (31). JPA con Hibernate (14): el lenguaje JPQL\HQL (1). Consultas básicas y modificaciones.

logo Jakarta EE

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.

>>>> ÍNDICE <<<<

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, when, then y else 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

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 completas de un mismo tipo o un escalar. 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. No poseen ninguna funcionalidad, aparte de proveer getters. En Java 16, se pueden implementar con records.

SELECT new com.danielme.jakartaee.jpa.ExpenseDTO(e.concept, e.date, e.amount, e.category.name) 
FROM Expense e
package com.danielme.jakartaee.jpa;

import lombok.Getter;

import java.math.BigDecimal;
import java.time.LocalDate;

@Getter
public class ExpenseDTO {

    private final String concept;
    private final LocalDate date;
    private final BigDecimal amount;
    private final String category;

    public ExpenseDTO(String concept, LocalDate date, BigDecimal amount, String category) {
        this.concept = concept;
        this.date = date;
        this.amount = amount;
        this.category = category;
    }

}
¿Entidad o DTO?

Se tiene asumir, de manera equivocada en mi opinión, que las clases DAO devuelven entidades. Lo cierto es que, en general, solo las retornaremos si necesitamos trabajar con ellas en un contexto de persistencia para realizar cambios. En los demás casos -explotación de datos en modo lectura-, es más eficiente y seguro recoger la información en DTOs inmutables o bien en records de Java 16 mediante la técnica del constructor, aunque la consulta pueda devolver entidades que contengan los datos que buscamos. Evitaremos modificaciones por error, llamadas ineficientes a relaciones LAZY y, sobre todo, guardar en la caché de primer nivel de Hibernate entidades que en realidad no necesitamos.

Otra ventaja desde el punto de vista de la arquitectura es que los DTOs pueden enviarse a cualquier capa 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 distribuyendo 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 con un elevado grado de abstracción y bajo acoplamiento entre sus componentes.

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

Hasta este momento, no hemos usado variables o parámetros de entrada en las condiciones.

SELECT e
FROM Expense e
WHERE e.amount >= 100

La parametrización de consultas en JPQL es similar a la de JDBC aunque más práctica: en vez de emplear ?, y luego tener que proporcionar sus valores desde el código «a ciegas» , los parámetros se especifican según 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, el mismo parámetro puede usarse las veces que haga falta. Más adelante veremos cómo darles valor desde el código cada vez que se ejecute la consulta.

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 condición de existencia. 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. Esto implica que se ejecuta 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 un único valor para indicarlo (por ejemplo, el 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

>>>> ÍNDICE <<<<

Deja una respuesta

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Salir /  Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Salir /  Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Salir /  Cambiar )

Conectando a %s

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.