Las consultas derivadas son el epítome de la sencillez. Apenas se requiere práctica para alcanzar soltura en su escritura. En este capítulo descubrirás por qué necesitamos la potencia de las consultas JPQL y cómo se usan en Spring Data JPA.
Índice
- Los problemas de las consultas derivadas
- Consultas JPQL
- Consultas nombradas (named queries)
- Sentencias UPDATE y DELETE
- Resumen
- Código de ejemplo
Los problemas de las consultas derivadas
La técnica consistente en declarar una consulta con el nombre del método es en sí misma una limitación: no podemos expresar demasiado, tan solo seleccionar entidades con criterios muy sencillos. Aunque a veces sea suficiente, la experiencia me dice que la mayoría de métodos de los repositorios no entran en esa categoría.
Otra desventaja de las consultas derivadas, menos evidente, es que resulta inaceptable crear métodos con nombres demasiado largos. Es lo que ocurrirá si acumulamos varios criterios de selección.
Soy el primero que defiende la elección de nombres claros, descriptivos y precisos, siguiendo las prácticas recomendadas por la filosofía del código limpio. Este tipo de nombres no solo facilitan la lectura y, por tanto, la comprensión del código, sino que además nos obligan a reflexionar sobre el cometido de una variable, una clase o un método. Dicho esto, cuando la extensión de un nombre supera lo razonable conseguimos el efecto contrario, pues dificultará su lectura. Pecamos de ser más descriptivos de la cuenta.
El criterio para determinar el tamaño máximo de un nombre es subjetivo, mas solo hasta cierto punto. Espero que estés de acuerdo conmigo en que la siguiente consulta derivada, aunque funciona, cruza todos los límites:
List<Country> findByNameContainingIgnoringCaseOrCapitalIgnoringCaseContainingOrderByName(String name, String capital);
Espeluznante 😱
Por último, y no menos decisivo, las consultas derivadas solo retornan entidades (estoy ignorando las operaciones de tipo conteo y existencia) del tipo que maneje el repositorio o bien un conjunto de sus atributos (lo veremos en el próximo capítulo). No todas las consultas que necesitemos se amoldarán a este patrón; pensemos, por ejemplo, en un resumen estadístico que combine varios escalares (cadenas, sumas, etcétera).
Consultas JPQL
JPQL (Jakarta Persistence query language) es un lenguaje de consultas que adapta las capacidades básicas de SQL al mundo JPA. En lugar de tablas y columnas, usa clases de entidad y atributos. El proveedor de JPA deberá traducirlo al SQL de la base de datos subyacente.
Encontrarás una revisión general y práctica de este lenguaje en mi curso Jakarta EE:
Dado que nuestro proveedor de JPA es Hibernate, en realidad lo que emplearemos es su lenguaje de consultas llamado HQL. Se trata de una implementación completa del estándar JPQL que incorpora algunas capacidades de SQL de las que el primero adolece. En el curso, los ejemplos —sencillos en extremo— se ceñirán al estándar.
En Spring Data JPA las consultas JPQL son un ciudadano de primera clase al mismo nivel que las consultas derivadas. Usarlas es igual de sencillo y ambas comparten muchas funcionalidades.
Empecemos con la creación de un nuevo repositorio en el proyecto de ejemplo:
@Transactional(readOnly = true)
public interface CountryJpqlQueryRepository extends Repository<Country, Long> {
@Query("""
SELECT c FROM Country c
WHERE UPPER(c.name) LIKE UPPER(?1) or UPPER(c.capital) LIKE UPPER(?1)
ORDER BY c.name asc""")
List<Country> findByNameOrCapital(String text);
}
findByNameOrCapital
hace (casi) lo mismo que el método «innombrable» que vimos antes; pero en esta ocasión, la consulta es una cadena con el JPQL declarado en la anotación @Query
(*). Cuando esta anotación aparece, el nombre del método será el que queramos.
(*) Como se verá en el capítulo doce, admite consultas SQL.
Al igual que las consultas derivadas, las declaradas con @Query
se chequean durante el inicio de Spring Data JPA.
Aprovechando que el proyecto de ejemplo requiere de Java 17 (requisito de Spring Boot 3), escribiré las consultas con el formato text blocks. Es una nueva sintaxis muy práctica a la hora de escribir cadenas separadas en múltiples líneas. Se delimitan con tres comillas dobles, siendo la primera línea un salto.
Variables
Los variables se declaran en la consulta con los marcadores estándar de JPQL. En el ejemplo anterior, un interrogante seguido de un número «i» el cual indica que la variable se reemplazará con el valor del parámetro del método que ocupa la posición «i».
Otra opción, más recomendable y a prueba de refactorizaciones (el orden de los parámetros podría cambiar), consiste en usar como marcador el nombre del parámetro precedido por dos puntos:
@Query("""
SELECT c FROM Country c
WHERE UPPER(c.name) LIKE UPPER(:text) or UPPER(c.capital) LIKE UPPER(:text)
ORDER BY c.name ASC""")
List<Country> findByNameOrCapital(String text);
La vinculación entre marcador y parámetro es configurable con la anotación @Param
, de tal modo que ambos pueden tener nombres diferentes. Es lo que ocurre en este ejemplo:
@Query("""
SELECT c FROM Country c
WHERE UPPER(c.name) LIKE UPPER(:string) OR UPPER(c.capital) LIKE UPPER(:string)
ORDER BY c.name ASC""")
List<Country> findByNameOrCapital(@Param("string") String text);
Sin Spring Boot, si obvias @Param
cuando marcador y parámetro tengan el mismo nombre, es posible que se produzca este error:
org.springframework.dao.InvalidDataAccessApiUsageException: For queries with named parameters you need to provide names for method parameters; Use @Param for query method parameters, or when on Java 8+ use the javac flag -parameters; nested exception is java.lang.IllegalStateException: For queries with named parameters you need to provide names for method parameters; Use @Param for query method parameters, or when on Java 8+ use the javac flag -parameters
El mensaje explica que debes usar @Param
o bien activar la opción -parameters
de javac
al compilar el código. En Maven lo harías así:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<encoding>${project.build.sourceEncoding}</encoding>
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
</configuration>
</plugin>
En todos los casos, un mismo parámetro puede aparecer en la consulta como variable más de una vez. Con otras palabras, es reutilizable. Por el contrario, en las consultas derivadas cada variable se corresponde un parámetro distinto.
Es posible encapsular las variables de la consulta en una clase. El siguiente record
contiene las necesarias para efectuar una búsqueda por un rango de fechas y un conjunto de confederaciones:
public record CountrySearch(Integer populationMin,
Integer populationMax,
List<Long> confederationsIds) {
}
Y esta es la consulta que lo usa:
@Query("""
SELECT c FROM Country c
WHERE c.population BETWEEN :#{#countrySearch.populationMin} AND :#{#countrySearch.populationMax}
AND c.confederation.id IN :#{#countrySearch.confederationsIds}
ORDER BY c.name ASC""")
List<Country> search(CountrySearch countrySearch);
Con un poquito de SpEL (lenguaje de expresiones de Spring) se indica a Spring Data cómo obtener las variables de la consulta del parámetro llamado countrySearch
.
Hay una diferencia nada obvia entre findByNameOrCapital
y la consulta derivada «del nombre largo». La keyword «Containing \ IsContaining \ Contains
» añade a la cadena usada para la búsqueda el marcador ‘%’ al principio y al final. En JPQL tendremos que hacerlo a mano:
@Test
void testFindByNameOrCapital() {
List<Country> countriesByString = countryRepository.findByNameOrCapital('%' + CITY_STRING + '%');
assertThat(countriesByString)
.extracting(Country::getId)
.containsExactly(GUATEMALA_ID, MEXICO_ID, VATICAN_ID);
}
Puedes reemplazar la llamada a findByNameOrCapital
por la del «nombre largo» y el test continuará siendo exitoso si eliminas ‘%’ de la cadena de búsqueda.
El añadido del carácter ‘%’ podemos hacerlo en JPQL con la función CONCAT. Atención al truco:
@Query("""
SELECT c FROM Country c
WHERE UPPER(c.name) LIKE UPPER(CONCAT('%', :text, '%')) OR UPPER(c.capital) LIKE UPPER(CONCAT('%', :text, '%'))
ORDER BY c.name ASC""")
List<Country> findByNameOrCapitalContaining(String text);
Spring Data JPA aporta un detalle interesante en lo que respecta a la función LIKE
, aunque en el ejemplo resulta inútil porque usamos la función UPPER
para ignorar la capitalización de las cadenas. Se trata de la posibilidad de acompañar la variable de LIKE
con ‘%’:
@Query("""
SELECT c FROM Country c
WHERE c.name LIKE %:name%
ORDER BY c.name ASC""")
List<Country> findByName(String name);
Spring Data JPA retira el comodín ‘%‘ de la consulta que envía a Hibernate y lo añade a la cadena name
. Comprobémoslo con esta prueba:
@Test
void testFindByName() {
List<Country> countriesByString = countryRepository.findByName(CITY_STRING);
assertThat(countriesByString)
.extracting(Country::getId)
.containsExactly(VATICAN_ID);
}
Si activas las trazas para imprimir en la bitácora las consultas SQL y sus parámetros que Hibernate envía a la base de datos, con la ejecución de testFindByName
verás esto:
org.hibernate.SQL : select c1_0.id,c1_0.capital,c1_0.confederation_id,c1_0.name,c1_0.ocde,c1_0.population,c1_0.united_nations_admission from countries c1_0 where c1_0.name like ? order by c1_0.name asc org.hibernate.orm.jdbc.bind : binding parameter [1] as [VARCHAR] - [%City%]
Respuesta
En cuanto a los posibles tipos de respuesta de los métodos anotados con @Query
, nada cambia en lo que se refiere a lo explicado en el capítulo anterior. En esencia, devolvemos una entidad o un valor primitivo en un resultado único (por ejemplo, con Optional
si puede ser null
), en un agregado (List
, Collection
, Iterator
, etcétera) o envuelto en un Future
en el caso de las consultas asíncronas.
El siguiente método retorna la capital de nombre más largo, un simple String
:
@Query("SELECT MAX(c.capital) FROM Country c")
String findLongestCapitalName();
¿Y si la proyección no consistiera en un escalar o una entidad? El próximo capítulo responde a esta pregunta. Aquí tienes la mejor solución que veremos:
@Query("""
SELECT new com.danielme.springdatajpa.model.dto.ConfederationSummaryRecord(
c.id, c.name as name, COUNT(ct.id) as countries)
FROM Country ct INNER JOIN ct.confederation c
GROUP BY ct.confederation.id, c.name""")
List<ConfederationSummaryRecord> getSummaryCountryCount(Sort sort);
Los valores declarados en la SELECT
serán los argumentos de un constructor adecuado perteneciente a la clase de retorno del método.
Consultas nombradas (named queries)
Las consultas nombradas de JPA son consultas JPQL a las que asignamos un nombre único. Hay dos maneras de declararlas:
1-.En cualquier clase de tipo entidad con la anotación @NamedQuery
:
@NamedQuery(name = "Country.findByCapital",
query = """
select c from Country c
where c.capital like concat('%', :capital, '%')
order by c.capital""")
@Entity
2-. En el fichero estándar /META-INF/orm.xml
:
<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings version="3.0" xmlns="https://jakarta.ee/xml/ns/persistence/orm"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence/orm
https://jakarta.ee/xml/ns/persistence/orm/orm_3_0.xsd">
<named-query name="Country.findByCapital">
<query>select c from Country c
where c.capital like concat('%', :capital, '%')
order by c.capital
</query>
</named-query>
</entity-mappings>
Si el mismo nombre se usa en @NamedQuery
y en el fichero orm.xml
, JPA toma la consulta del fichero.
Las consultas nombradas ofrecen dos beneficios. En primer lugar, se procesan cuando Hibernate arranca, de modo que si son incorrectas se producirá una excepción y el inicio de la aplicación se aborta. La segunda ventaja es que aportan una forma estándar de separar las cadenas JPQL del código Java.
Ambas características no son relevantes en Spring Data JPA porque ya las tenemos con @Query
. Además, son consultas JPQL «puras», en el sentido de que no pueden ser enriquecidas con SpEL porque no son tratadas por Spring sino por Hibernate.
A lo sumo, las consultas nombradas serán útiles si queremos centralizar las consultas en el fichero orm.xml
. Otro beneficio interesante del uso de este fichero es que la consulta puede cambiarse en el mismo, lo que evita recompilar el código que, hipotéticamente, contendría la declaración de la consulta.
Sea como fuere, ejecutarlas con un método de un repositorio es bien sencillo. Basta con bautizar la consulta con el nombre del tipo de la entidad que trata el repositorio seguido de un punto y el nombre que queramos para el método. Así, para la consulta de los ejemplos anteriores, llamada Country.findByCapital
, tendremos este método en CountryJpqlQueryRepository
:
List<Country> findByCapital(String capital);
Aunque findByCapital
es una consulta derivada válida, la nombrada tiene preferencia.
Trabajar siguiendo estas convenciones me parece confuso porque viendo el método no se aprecia que estamos ejecutando una consulta nombrada en lugar de una derivada. Por ello, aconsejo usar @Query
para indicar en su atributo name
el nombre de la consulta nombrada:
@Query(name = "Country.findByCapital")
List<Country> findByCapitalUsingNameQuery(String capital);
Ahora el nombre del método es irrelevante y su cometido, obvio. También podremos denominar como queramos a la consulta nombrada.
Sentencias UPDATE y DELETE
Spring Data JPA admite JPQL con sentencias de tipo UPDATE
y DELETE
. El único requisito, además de la necesidad de realizar la operación dentro de una transacción en modo escritura, es acompañar @Query
con @Modifying
:
@Transactional
@Modifying
@Query("""
UPDATE Country c SET c.name = CONCAT(c.name, ' (', :confederationName, ')')
WHERE c.confederation.id IN
(SELECT c.id FROM Confederation c WHERE c.name LIKE :confederationName)""")
int appendConfederationToName(String confederationName);
La operación es un poco absurda, pero sirve como ejemplo. Toma los países asociados a cierta confederación y agrega a sus nombres el de la confederación. El retorno del método (opcional) es el número de entidades actualizadas.
La actualización de múltiples entidades con una sentencia JPQL es más eficiente que hacerlo de una en una obteniéndolas dentro de un contexto de persistencia \ transacción y cambiándolas, a menos que configuremos Hibernate para que actualice entidades en lote (batch update). El inconveniente es que los cambios se efectúan directamente en la base de datos. Esto implica que no se reflejan en las entidades ya presentes en los contextos de persistencia, lo que puede dar lugar a inconsistencias.
Si tuviéramos el problema anterior, lo solucionamos activando el flag clearAutomatically
de @Modifying
. Spring Data JPA invocará al método clear
del gestor de entidades para vaciar el contexto de persistencia (todas sus entidades pasarán a estado desligado) tras la ejecución de la sentencia. Pero esto puede suponer un nuevo problema dado que la ejecución de clear
descarta las operaciones pendientes de sincronizar con la base de datos.
¡Todo son dificultades! Bueno, no es para tanto. De nuevo Spring Data JPA nos ayuda con otro flag de @Modifying
llamado flushAutomatically
. Invoca al método EntityManager#flush
antes de efectuar la actualización para forzar la sincronización de las entidades con las tablas.
En cualquier caso, hay algo que con las sentencias de modificación de JPQL nunca conseguiremos: la ejecución de los métodos que atienden a los eventos de actualización de las entidades (@PreUpdate
y @PostUpdate
). Recuerda que los cambios de UPDATE
no afectan a las entidades del contexto.
Todo lo que acabamos de ver es aplicable a los borrados con DELETE
. Ahora bien, recordemos que estas operaciones se pueden realizar con consultas derivadas. Este es el ejemplo que puse en el capítulo previo:
@Transactional
long deleteByConfederationId(Long id);
El equivalente con JPQL:
@Transactional
@Modifying
@Query("DELETE FROM Country c WHERE c.confederation.id = :id")
int deleteByConfederationId(Long id);
Si bien el objetivo es el mismo —eliminar los países de una confederación—, la manera de alcanzarlo es distinta. La consulta derivada obtiene primero las entidades a borrar y luego las elimina una a una con el método EntityManager#remove
. Por actuarse sobre entidades del contexto, Hibernate ejecutará los métodos asociados a los eventos de borrado (@PreRemove
, @PostRemove
) de las entidades a eliminar, algo imposible con una sentencia DELETE
(va directa a la base de datos al igual que un UPDATE
). Eso sí, el rendimiento del borrado con la consulta derivada es inferior al que ofrecen las sentencias DELETE
, ya que para cada entidad se ejecuta una eliminación independiente en la base de datos.
Otro aspecto a considerar de las sentencias DELETE
(ver la documentación) es que solo se aplica a las entidades de la clase especificada y sus subclases. No se aplica en cascada a las entidades relacionadas.
Resumen
Las claves del capítulo:
- Por su sencillez, las consultas derivadas serán nuestra primera opción. Cuando sean insuficientes, apostaremos por escribirlas en JPQL (u otros mecanismos que ya trataremos).
- Las consultas JPQL se declaran con la anotación
@Query
en un método de un repositorio. Sus variables se vinculan con los parámetros del método mediante su posición en la signatura (?1
), un nombre (:capital
) o expresiones SpEL (:#{#countrySearch.populationMin}
). Para el retorno del método se sigue el criterio de las consultas derivadas. - Las consultas JPQL también se pueden declarar como consultas nombradas de JPA en las clases de tipo entidad (
@NamedQuery
) o en el ficheroorm.xml
. Se ejecutan con un método que respete cierta convención o bien cuente con una anotación@Query
que indique el nombre único de la consulta nombrada. - Si las consultas son de tipo
UPDATE
\DELETE
, el método debe acompañarse con@Modifying
. Son operaciones muy eficientes porque los cambios se realizan directamente en la base de datos con una única sentencia. La contrapartida es que las entidades del contexto no se ven afectadas por esos cambios, algo que puede mitigarse activando las propiedadesflushAutomatically
yclear
de@Modifying
.
Código de ejemplo
El proyecto, explicado en el capítulo dos, se encuentra en GitHub. Para más información sobre cómo utilizar GitHub consulta este artículo.
Otros tutoriales relacionados con Spring
Introducción a Spring Boot: Aplicación Web con servicios REST y Spring Data JPA
Spring Boot: Gestión de errores en aplicaciones web y REST
Testing en Spring Boot con JUnit 45. Mockito, MockMvc, REST Assured, bases de datos embebidas