Curso Spring Data JPA. 12: consultas programáticas 3. QueryDSL

logo spring

En el capítulo anterior aprendiste a seleccionar entidades con predicados de Criteria API. Esto te permite construir consultas dinámicas tipadas. En este capítulo mostraré cómo lograr lo mismo con predicados de la librería QueryDSL. Antes de eso, explicaré por qué deberías considerar QueryDSL y productos similares como alternativas a Criteria API.


Contenido

  1. Alternativas a Criteria API
  2. Cómo añadir QueryDSL a un proyecto
  3. Anatomía de una consulta QueryDSL
  4. La integracion de Spring Data JPA con QueryDSL
    1. QuerydslPredicateExecutor
    2. La gran diferencia con Criteria API
  5. Ordenación
  6. Paginación
  7. Proyecciones. La API FetchableFluentQuery.
  8. Reflexión final
  9. Resumen
  10. Código de ejemplo


Alternativas a Criteria API

Si bien Criteria API es una herramienta útil y eficaz, nunca ha sido popular. Estas son las razones:

  • Su API es verbosa. Te obliga a escribir mucho código, incluso para crear consultas sencillas.
  • Si no tienes soltura usándola, la construcción de consultas complejas es difícil, por no decir dolorosa.
  • Puesto que se basa en JPQL, tiene las mismas limitaciones que este lenguaje presenta con respecto a SQL.

Naturalmente, usa Criteria API si satisface tus necesidades y te sientes cómodo con ella. ¿Pero existen mejores opciones? La respuesta es afirmativa. Destacan tres librerias gratuitas que ofrecen una API amigable y potente:

  • jOOQ. Ha ganado popularidad en los últimos años. Facilita la escritura de consultas SQL complejas y proporciona capacidades avanzadas de este lenguaje. Eso sí, la versión gratuita solo contempla las últimas versiones de bases de datos de código abierto y carece de funcionalidades de las versiones de pago.
  • Blaze Persistence. Diseñada para trabajar con JPA. Se promociona como un reemplazo de Criteria API. Además de ser más fácil de utilizar que esta última, implementa características de SQL avanzadas.
  • QueryDSL. Pionera en la escritura de consultas programáticas con Java. Es compatible con MongoDB, Lucene y SQL. Asimismo, se integra con JPA.

Queda fuera del alcance del curso el análisis de las virtudes y defectos de estas librerías. Me conformo con que sepas que existen para que sopeses su empleo. Lo que sí te voy a explicar es la integración que Spring Data JPA incluye de serie con QueryDSL, la única de las tres librerías que disfruta de este privilegio.

Cómo añadir QueryDSL a un proyecto

Los pasos para añadir QueryDSL al proyecto de ejemplo del curso:

  1. Agrega al pom la dependencia de QueryDSL compatible con JPA.
<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-jpa</artifactId>
    <version>${querydsl.version}</version>
    <classifier>jakarta</classifier>
</dependency>
  1. También necesitas el módulo querydsl-apt. Genera ciertas clases requeridas por QueryDSL, como veremos pronto.
 <dependency>
      <groupId>com.querydsl</groupId>
      <artifactId>querydsl-apt</artifactId>
      <version>${querydsl.version}</version>
     <classifier>jakarta</classifier>
 </dependency>
  1. Indica a Maven que ejecute querydsl-apt justo antes de compilar el proyecto.
<build>
        <plugins>         
            <plugin>
                <groupId>com.mysema.maven</groupId>
                <artifactId>apt-maven-plugin</artifactId>
                <version>1.1.3</version>
                <executions>
                    <execution>
                        <phase>generate-sources</phase>
                        <goals>
                            <goal>process</goal>
                        </goals>
                        <configuration>
                            <processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

La configuración mostrada es correcta para Spring Boot 3 y presenta diferencias con los ejemplos que encontrarás en muchos tutoriales que fueron escritos para versiones antiguas de Spring. Es el momento de recordar que esta versión de Spring dio el salto a las especificaciones Jakarta que sustituyen a las especificaciones Java EE (JEE). Una consecuencia es que los paquetes que en JEE comienzan por javax en Jakarta lo hacen por jakarta. Por ello, en las dependencias aparece la línea <classifier>jakarta</classifier>, que indica a QueryDSL que use los paquetes jakarta en vez de javax.

Ahora cuando compilas el proyecto con Maven o IntelliJ, querydsl-apt genera una clase para cada clase de tipo entidad presente en el proyecto. El nombre de la clase generada coincide con el de la entidad que representa, pero con el prefijo «Q«. Dado que en el proyecto tenemos las entidades Country y Confederation, querydsl-apt creará las clases QCountry y QConfederation.

Me referiré a estas clases como clases Q. Están en la carpeta /target/generated-sources/annotations del proyecto. En ella también verás las clases del metamodelo de JPA.

Si IntelliJ no generara las clases Q o bien el código no pudiera encontrarlas, prueba a recargar el proyecto Maven con la orden «Reload project», o a forzar la compilación completa del proyecto con la opción de menú «Build» -> «Rebuild Project».

Si nunca usaste QueryDSL, te estarás preguntando por la utilidad de las clases Q. Lo descubrirás en la siguiente sección.

Anatomía de una consulta QueryDSL

Este capítulo se centra en la integración de QueryDSL con los repositorios. Sin embargo, te mostraré la estructura básica de una consulta QueryDSL por si no estuvieras familiarizado con esta librería.

El caso de estudio principal del capítulo precedente consistía en un método que obtiene los países cuya fecha de admisión en la ONU está comprendida en un rango de dos fechas (fecha de inicio y fecha de fin del rango). Ambas son opcionales. Además, los resultados debían ordenarse por la fecha de admisión, primero las más recientes.

Este es el método equivalente basado en QueryDSL:

public List<Country> findAllWithQueryDSL(LocalDate from, LocalDate to) {
    JPAQuery<Country> query = new JPAQuery<Country>(entityManager)
            .select(QCountry.country)
            .from(QCountry.country)
            .orderBy(QCountry.country.unitedNationsAdmission.desc());

    if (from != null) {
        query.where(QCountry.country.unitedNationsAdmission.goe(from));
    }
    if (to != null) {
        query.where(QCountry.country.unitedNationsAdmission.loe(to));
    }

    return query.fetch();
 }

El código resulta intuitivo. El primer bloque ensambla casi toda la consulta mediante el encadenado de llamadas a un objeto JPAQuery construido con el gestor de entidades de JPA. Tienes el SELECT, el FROM y el ORDER BY. En este ejemplo particular, puedes omitir la llamada al método select porque la consulta proyecta la entidad indicada en el FROM.

A continuación se establece el WHERE dinámico según las fechas recibidas. Los criterios de selección de las entidades se declaran con predicados (objetos de la interfaz Predicate). A diferencia del método where de Criteria API, al where de QueryDSL puedes llamarlo las veces que quieras: el predicado que reciba en cada llamada se une con AND a los recibidos en llamadas anteriores.

Finalmente, el método ejecuta la consulta y devuelve el resultado, una lista de igual tipo que JPAQuery.

Como ves en el código, las clases Q sirven para referenciar de forma cómoda y segura a las entidades y sus atributos. Su propósito coincide con el del metamodelo de JPA, pero fíjate en una capacidad adicional: te dan acceso a los métodos que configuran la ordenación y la selección de entidades.

Al igual que el metamodelo resulta innecesario para trabajar con JPA y Criteria API, QueryDSL no requiere de las clases Q. Pero sería absurdo renunciar a la comodidad y el tipado seguro que nos regalan.

La integracion de Spring Data JPA con QueryDSL

QuerydslPredicateExecutor

La interfaz QuerydslPredicateExecutor<T>, tipada para la entidad de clase T, integra Spring Data JPA con QueryDSL. Sus métodos ejecutan consultas que seleccionan las entidades T que cumplan con las condiciones impuestas por un predicado recibido como argumento. En concreto, obtendrás las entidades (findAll o findOne), las contarás (count) y comprobarás su existencia (exists); y por supuesto, los métodos son compatibles con los mecanismos de ordenación y paginación de Spring Data.

El diagrama anterior muestra el contrato QuerydslPredicateExecutor. El subtipo ListQuerydslPredicateExecutor, introducido en Spring Data JPA 3, refina varios métodos para que retornen listas en vez de iteradores.

Hereda cualquiera de las interfaces anteriores para incorporar sus métodos a un repositorio. Asegúrate de declarar la interfaz con la entidad del repositorio.

public interface CountryQueryDSLRepository extends Repository<Country, Long>,
        ListQuerydslPredicateExecutor<Country> {
    
}

Esta prueba llama al repositorio anterior para obtener los países admitidos en la ONU en 1945:

@Test
void testFindAllCountries1945() {
    LocalDate startRange = LocalDate.of(1945, 1, 1);
    LocalDate endRange = LocalDate.of(1945, 12, 31);
    Predicate predicateFullRange = QCountry.country.unitedNationsAdmission.goe(startRange)
                .and(QCountry.country.unitedNationsAdmission.loe(endRange));

        List<Country> countries1945 = countryRepository.findAll(predicateFullRange);

       assertThat(countries1945)
                .extracting(Country::getId)
                .hasSize(9)
                .doesNotContain(DatasetConstants.SPAIN_ID,
                        DatasetConstants.KOREA_ID,
                        DatasetConstants.VATICAN_ID);
    }

Como ves, solo debes ocuparte del predicado; el resto queda en manos de Spring Data JPA. El predicado que findAll recibe es la unión con AND de otros dos, uno para cada extremo del rango. No obstante, en el ejemplo resulta más sucinto recurrir al método between:

QCountry.country.unitedNationsAdmission.between(startRange, endRange);

¿Y cuando no tengas los dos valores? En el método findAllWithQueryDSL las fechas eran opcionales, y así puedes considerarlas al construir el predicado. Por ejemplo, una sola fecha:

List<Country> countriesGt1945 = countryRepository.findAll(
                QCountry.country.unitedNationsAdmission.goe(startRange), 
                order);

La gran diferencia con Criteria API

Si leíste el capítulo previo, QuerydslPredicateExecutor te será familiar. Esta interfaz es análoga a JpaSpecificationExecutor, la cual ofrece la integración de Spring Data JPA con Criteria API. Pero hay una diferencia notable entre ellas. Los métodos de QuerydslPredicateExecutor reciben predicados de QueryDSL, mientras que los métodos de JpaSpecificationExecutor no reciben predicados de Criteria API tal y como parece lógico. En su lugar, reciben especificaciones (interfaz Specification) que encapsulan la creación de los predicados.

Este diseño es consecuencia de las características de Criteria API. Ten en cuenta que para crear predicados con ella necesitas los objetos CriteriaBuilder y Root con los que se construye la consulta. La solución de Spring Data JPA consiste en dártelos como argumentos del método Specification#toPredicate:

Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder);

En cambio, la creación de predicados de QueryDSL es independiente del código de la consulta, pues solo precisas de los atributos y métodos de las clases Q.

Ordenación

Con Sort ordenas las consultas basadas en predicados de QueryDSL, tal y como harías con cualquier otro tipo de consulta:

Sort sort = Sort.by("unitedNationsAdmission").descending();
List<Country> countries1945 = countryRepository.findAll(predicateFullRange, sort);

Algunos métodos de ListQuerydslPredicateExecutor admiten la API de ordenación de QueryDSL. Por ejemplo:

@Override
List<T> findAll(Predicate predicate, OrderSpecifier<?>... orders);

OrderSpecifier resulta más práctico que Sort porque aprovecha las clases Q:

OrderSpecifier<?>[] orders = {QCountry.country.unitedNationsAdmission.desc(),
                QCountry.country.name.asc()};
List<Country> countries1945 = countryRepository.findAll(predicateFullRange, orders);

De todas formas, recuerda que el metamodelo de JPA también te ayuda a declarar ordenaciones seguras frente a errores tipográficos:

 Sort order = Sort.by(Sort.Direction.DESC, Country_.UNITED_NATIONS_ADMISSION)
                     .and(Sort.by(Country_.NAME));

Si tu proyecto cuenta con QueryDSL y prefieres OrderSpecifier a Sort, tengo una buena noticia. La clase QSort es una implementación de Sort que provee métodos estáticos para configurar la ordenación con objetos OrderSpecifier. Así, cuando un método requiera un parámetro Sort, puedes pasarle como argumento un QSort construido con OrderSpecifier. Y da igual el tipo de consulta que ese método ejecute.

Aquí tienes un ejemplo:

Sort order = QSort.by(QCountry.country.unitedNationsAdmission.desc(), 
                          QCountry.country.name.asc());

Puedes agregar a un QSort los criterios de ordenación uno a uno:

Sort order = QSort.by(QCountry.country.unitedNationsAdmission.desc())
                .and(QCountry.country.name.asc());

Incluso es posible combinar objetos Sort y OrderSpecifier con los métodos de Sort heredados por QSort. Recuperemos esta prueba de la clase ConfederationJpqlQueryRepositoryTest:

    @Test
    void testGetSummaryCountryCountWithSort() {
        Sort sort = Sort.by("countries")
                .descending()
                .and(Sort.by("name"));

        List<ConfederationSummaryRecord> confederationsSummary = confederationRepository.getSummaryCountryCount(sort);

        assertConfederationsSummarySorting(confederationsSummary);
    }

¿Cómo declararías la misma ordenación con OrderSpecifier? Tiene truco. La propiedad de ordenación countries no es un atributo de la confederación, así que no la encontrarás en la clase QConfederation. Se trata del alias de un elemento de la proyección de la consulta que ejecuta getSummaryCountryCount:

SELECT new com.danielme.springdatajpa.model.dto.ConfederationSummaryRecord(
                c.id, c.name as name, COUNT(ct.id) as countries)

Una solución es combinar un Sort para countries con un OrderSpecification para name:

Sort sort = QSort.by(Sort.Direction.DESC,"countries")                
                .and(QSort.by(QConfederation.confederation.name.desc()));

Otra consiste en crear un OrderSpecifier «a mano»; esto es, sin las clases Q:

OrderSpecifier orderAlias = new OrderSpecifier<>(Order.DESC, Expressions.stringPath("countries"));
Sort sort = QSort.by(orderAlias)
                .and(QSort.by(QConfederation.confederation.name.desc()));

Por último, un beneficio adicional de OrderSpecifier: puedes indicar el criterio de ordenación de los valores nulos. Si bien Sort ofrece esta posibilidad, Spring Data JPA no la implementa.

QCountry.country.unitedNationsAdmission.desc().nullsLast();

Esta capacidad solo funciona en las consultas QueryDSL.

Paginación

Si QSort implementa un Sort adaptado a las peculiaridades de QueryDSL, QPageRequest hace lo propio con Pageable. Recuerda que la interfaz Pageable modela la configuración de paginación. Construyes objetos de tipo Pageable con los métodos estáticos de la clase PageRequest.

¿Qué tiene de especial QPageRequest? Echa un vistazo al siguiente diagrama. En concreto, mira las líneas que salen de QPageRequest y PageRequest.

Si en PageRequest estableces la ordenación con Sort, en QPageRequest lo haces con QSort u OrderSpecifier. En la práctica, la utilidad de QPageRequest es discutible, ya que es posible crear con QSort los Sort para PageRequest. Con todo, te dejo un ejemplo:

 @Test
 void testFindAllUefaPagination() {
    BooleanExpression confFilter = QCountry.country.confederation.id.eq(UEFA_ID);
    Pageable page1 = QPageRequest.of(0, 2 , QCountry.country.name.asc());

    Page<Country> countriesUefa = countryRepository.findAll(confFilter, page1);

    assertThat(countriesUefa.getTotalPages()).isEqualTo(2);
}

Lo mismo con PageRequest:

Pageable page1 = PageRequest.of(0, 2 , QSort.by(QCountry.country.name.asc()));

Proyecciones. La API FetchableFluentQuery.

Tercer capítulo consecutivo en el que hablamos de esta API, y ya no queda nada por decir. Esta es la signatura del método de QuerydslPredicateExecutor que la utiliza:

<S extends T, R> R findBy(Predicate predicate, Function<FluentQuery.FetchableFluentQuery<S>, R> queryFunction);

La misma de siempre, ahora con un predicado de QueryDSL como primer argumento. Si quieres más detalles, consulta esta sección del capítulo 10.

Obtengamos los países asociados a la UEFA, ordenados por nombre, en una lista de objetos de la interfaz IdName. Esto último implica que necesitamos una proyección en interfaz. La única forma de conseguirlo con las consultas programáticas (QBE, Criteria API, QueryDSL) es configurando la consulta con FluentQuery.FetchableFluentQuery e indicando con su método as la interfaz IdName:

@Test
void testFindAllUefaAsIdName() {
    BooleanExpression confFilter = QCountry.country.confederation.id.eq(UEFA_ID);

    List<IdName> countriesUefa = countryRepository.findBy(confFilter,
          q -> q.sortBy(Sort.by("name"))
                 .as(IdName.class)
                 .all());

   SharedCountryAssertions.assertUefaIdName(countriesUefa);
}

La ordenación (línea seis) se estableció con Sort. Si prefieres OrderSpecifier, usa QSort.

Reflexión final

Las consultas que ejecutas con el mecanismo de integración de Spring Data JPA con QueryDSL son del mismo tipo que las que vimos en el capítulo anterior para la integración con Criteria API. Es decir, consultas que seleccionan entidades de la clase asociada al repositorio.

En consecuencia, si solo vas a crear consultas programáticas de este tipo, elegir entre Criteria API y QueryDSL es una cuestión de preferencia personal más que una decisión técnica. Las ventajas de QueryDSL (te ahorras construir los predicados con especificaciones, la posibilidad de emplear QSort) están ahí, pero no son determinantes. Ambas herramientas resultan efectivas.

Cuando quieras construir consultas complejas con QueryDSL y Criteria API, deberás crear métodos con cuerpo en repositorios personalizados. Y ante esa casuística, QueryDSL sí brilla frente a Criteria API. La misma afirmación es válida para Blaze Persistence y jOOQ.

Resumen

Las principales lecciones de esta entrega del curso:

  • Criteria API es útil, pero tiene deficiencias. Considera alternativas como QueryDSL, jOOQ y Blaze Persistence.
  • Incluyes QueryDSL en un proyecto configurando el pom, de modo que durante la compilación QueryDSL genere una clase Q que represente a cada clase de tipo entidad.
  • La integración de Spring Data JPA con QueryDSL es equivalente a la integración con Criteria API. Con métodos de la interfaz QuerydslPredicateExecutor o ListQuerydslPredicateExecutor ejecutas consultas sobre una entidad aplicando la selección definida por un predicado de QueryDSL.
  • Con QSort, un subtipo de Sort, te beneficias de las clases Q para crear criterios de ordenación compatibles con cualquier tipo de consulta.
  • Tienes disponible la clase FetchableFluentQuery que ya vimos para QBE y Criteria API.
  • En lo que respecta a la integración con Spring Data JPA, no hay grandes ventajas objetivas para preferir QueryDSL a Criteria API.

Código de ejemplo

El proyecto, explicado en el capítulo dos, se encuentra en GitHub. Si necesitas ayuda para obtener y configurar proyectos alojados en GitHub, consulta este tutorial.



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

Spring JDBC Template: simplificando el uso de SQL

Deja un comentario

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