Curso Spring Data JPA. 10: Consultas programáticas 1. Query by Example

logo spring

Los tipos de consultas estudiados hasta el momento declaran consultas estáticas. Esto significa que una vez declaradas nunca cambiarán, salvo por la configuración de la ordenación y paginación. En este capítulo veremos la manera más simple de construir consultas dinámicas programáticas; esto es, generadas con código en tiempo de ejecución.

Índice

  1. Primeros pasos con Example y QueryByExampleExecutor
  2. Configurar la selección con ExampleMatcher
  3. Proyecciones. La API FetchableFluentQuery.
  4. Reflexión final
  5. Resumen
  6. Código de ejemplo


Primeros pasos con Example y QueryByExampleExecutor

La expresión query by example, referida con el acrónimo QBE, se traduce como «consulta basada en ejemplos». Es una técnica intuitiva que define búsquedas en bases de datos utilizando un ejemplo de los registros a obtener. El concepto es tan sencillo como eso. No hay que escribir consulta alguna.

Si has padecido Microsoft Access, QBE te será familiar: es la idea en la que se basa su editor visual de consultas. También existen herramientas similares en otros productos.

Vayamos al proyecto del curso. Imagina que quieres obtener los países que contengan en su nombre la cadena «Republic». Necesitas un ejemplo como el que sigue:

Country country = new Country();
country.setName("Republic");

Todas las entidades Country a obtener deben ser compatibles con la anterior. Para imponer esta condición con Spring Data (*), el ejemplo debe ser un «ejemplo»: un objeto de la interfaz Example<T> tipada para la clase de la entidad.

(*) Fíjate que estoy hablando de Spring Data, no de Spring Data JPA. Todos los módulos deberían implementar QBE. MongoDB, al menos, lo hace.

Construyes un Example con el método estático of de la propia interfaz. Su parámetro, llamado sonda (probe), es el objeto con los valores de ejemplo. Por tanto, este es el Example para el país:

Country country = new Country();
country.setName("Republic");
Example<Country> countryExample = Example.of(country);

¿Cómo ejecutas la consulta basada en countryExample? Pues con los métodos de la interfaz QueryByExampleExecutor, o bien con su especialización ListQueryByExampleExecutor, disponible desde Spring Data 3.0, que retorna List en lugar de Iterable. Como cabría esperar, ambas interfaces proveen métodos con parámetros de tipo Sort y Pageable.

Heredemos la interfaz en un nuevo repositorio del proyecto del curso:

package com.danielme.springdatajpa.repository.programmatic;

import com.danielme.springdatajpa.model.entity.Country;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.ListQueryByExampleExecutor;

public interface CountryQBERepository extends Repository<Country, Long>,
        ListQueryByExampleExecutor<Country> {

}

Como alternativa a la herencia, copia en tus repositorios los métodos que precises:

public interface CountryProgrammaticRepository extends Repository<Country, Long> {

    List<Country> findAll(Example<Country> example, Sort sort);
}

Esta prueba une todas las piezas:

@Test
void testFindAllByName() {
    Country country = new Country();
    country.setName("Republic");
    Example<Country> countryExample = Example.of(country);

    List<Country> republics = countryRepository.findAll(countryExample, Sort.by("name"));

    assertThat(republics)
            .extracting(Country::getId)
            .containsExactly(KOREA_ID, DOMINICAN_ID);
}

El test verifica que findAll devuelva los dos países del juego de datos de prueba que incluyen «Republic» en su nombre, pero fallará porque findAll no retorna nada. ¿Qué diantres ocurre? Activemos las trazas y comprobemos en la bitácora el SQL que Hibernate ejecuta:

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=?
binding parameter [1] as [VARCHAR] - [Republic]

Ahí tenemos la respuesta: la selección aplica el criterio de igualdad estricta. Otro detalle interesante es que ignora a los atributos con valor nulo. Dos restricciones demasido exigentes que salvaremos en la próxima sección.

Configurar la selección con ExampleMatcher

Aquí entra en juego la interfaz ExampleMatcher. Su API fluida personaliza la selección de registros efectuada por la cláusula WHERE.

Que no te intimide el diagrama de clases anterior. Reescribamos testFindAllByName manteniendo las aserciones que fallaron:

@Test
void testFindAllByName() {
    Country country = new Country();
    country.setName(REPUBLIC_STRING);
    ExampleMatcher matcher = ExampleMatcher.matching()
            .withStringMatcher(ExampleMatcher.StringMatcher.CONTAINING)
            .withIgnoreCase();
    Example<Country> countryExample = Example.of(country, matcher);

    List<Country> republics = countryRepository.findAll(countryExample, Sort.by("name"));

   assertThat(republics)
          .extracting(Country::getId)
          .containsExactly(KOREA_ID, DOMINICAN_ID);
}

Queremos los países cuyo nombre contenga (línea cuatro) el nombre del país sonda, y que se ignore la capitalización (línea cinco). Los objetos country y matcher son los argumentos de un método Example#of (línea seis).

Ahora el test es exitoso. Mira la consulta SQL que Hibernate genera:

where lower(c1_0.name) like ? 
binding parameter [1] as [VARCHAR] - [%republic%]

Volvamos al código, en concreto a la línea tres. El encadenado de llamadas a ExampleMatcher comienza con uno de estos métodos estático:

  • matching y matchingAll —hacen lo mismo— imponen que los resultados sean coherentes con todos los atributos de la sonda.
  • Con matchingAny nos conformamos con que los resultados sean compatibles con un único atributo de la sonda.

En el ejemplo da igual llamar a cualquiera de estos métodos porque la sonda solo contiene un atributo no nulo.

Escribamos una consulta QBE que encuentre los países cuyo nombre o capital contengan cierta cadena. Esa o indica que necesitas matchingAny.

@Test
void testFindAllByNameOrCapital() {
    Country country = new Country();
    country.setName(REPUBLIC_STRING);
    country.setCapital(CITY_STRING);
    ExampleMatcher matcher = ExampleMatcher.matchingAny()
            .withStringMatcher(ExampleMatcher.StringMatcher.CONTAINING)
            .withIgnoreCase();
    Example<Country> countryExample = Example.of(country, matcher);

    List<Country> republicsCountriesOrCapital = countryRepository.findAll(countryExample, Sort.by("name"));

    assertThat(republicsCountriesOrCapital)
                .extracting(Country::getId)
                .containsExactly(GUATEMALA_ID, MEXICO_ID, KOREA_ID, DOMINICAN_ID, VATICAN_ID);
}

El WHERE resultante:

where lower(c1_0.capital) like ? or lower(c1_0.name) like ?
binding parameter [1] as [VARCHAR] - [%republic%]
binding parameter [2] as [VARCHAR] - [%city%]

matchingAny es el responsable de ese OR. Si usaras matching, verías un AND.

Esta tabla recopila los métodos que configuran el comportamiento general de la búsqueda:

withIgnorePaths(String... ignoredPaths)Atributos de la sonda a ignorar.
withStringMatcher(StringMatcher defaultStringMatcher);Comportamiento del filtrado de los atributos de tipo String, según el enumerado StringMatcher.
withIgnoreCase()Ignora la capitalización. En JPA se usa la función lower de JPQL.
withIncludeNullValues()La consulta considerará todos los atributos de la sonda, tengan valor o sean nulos, y que no hayan sido excluidos con withIgnorePath. En el caso de JPA, los atributos nulos se comprueban con el operador IS NULL.

Hasta ahora, he afirmado que las consultas definidas con QBE solo consideran los atributos con valor de la sonda. Pero como explica la última fila de la tabla, se trata de un comportamiento predeterminado configurable.

Con withMatcher y GenericPropertyMatcher estableces configuraciones específicas para los atributos:

Country country = new Country();
country.setName("republic");
country.setCapital("city");
ExampleMatcher matcher = ExampleMatcher.matchingAny()
            .withMatcher("name", ExampleMatcher.GenericPropertyMatcher::startsWith)
            .withMatcher("capital", ExampleMatcher.GenericPropertyMatcher::contains)
            .withIgnoreCase();
Example<Country> countryExample = Example.of(country, matcher);

En esta ocasión, el nombre de los países debe empezar (startsWith, línea cinco) por «republic» o su capital contener (contains, línea seis) «city». De nuevo, esa o es asunto de matchingAny:

where lower(c1_0.capital) like ? or lower(c1_0.name) like ?
binding parameter [1] as [VARCHAR] - [%city%]
binding parameter [2] as [VARCHAR] - [republic%]

También puedes modificar el valor de un campo concreto de la sonda. Llama a withTransformer con una implementación de la interfaz funcional PropertyValueTransformer. Es muy ingeniosa, pues consiste en una Function de Java tipada para Optional:

interface PropertyValueTransformer extends Function<Optional<Object>, Optional<Object>> {}

La función recibe el campo de la sonda en un Optional y devuelve un Optional con el valor para ese campo que usará la consulta. Este ejemplo, admito que sin mucho sentido, transforma el nombre del país, si existe, en una cadena en mayúsculas:

ExampleMatcher matcher = ExampleMatcher.matchingAll()
                .withTransformer("name",
                        o -> o.map(value -> ((String) value).toUpperCase()));

Proyecciones. La API FetchableFluentQuery.

En los ejemplos anteriores buscamos países y obtenemos sus entidades. Parece obvio, pero recuerda el capítulo siete dedicado a las proyecciones personalizadas. Lo habitual es que las consultas obtengan información para leerla y no editarla. Incluso en muchas ocasiones los datos ni siquiera tienen por qué amoldarse a una clase de tipo entidad.

Así pues, ¿tenemos proyecciones en QBE? Probemos con una proyección en interfaz tal y como lo haríamos con una consulta derivada:

List<IdName> findAll(Example<Country> spec, Sort sort);

Los métodos de IdName recogen los atributos id y name de Country:

public interface IdName {

    Long getId();

    String getName();

}

No funciona. El resultado es la entidad Country, y por ello Spring lanza la excepción ConversionFailedException:

org.springframework.core.convert.ConversionFailedException: Failed to convert

Vaya fastidio. Por fortuna, QBE provee una forma de proyectar los resultados de las consultas. En la interfaz QueryByExampleExecutor encontrarás este método que parece un jeroglífico:

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

Descifrémoslo. La clave radica en el segundo parámetro. Se trata de una Function que recibe un objeto de la interfaz FluentQuery.FetchableFluentQuery y devuelve el tipo de retorno de la consulta (la R de findBy). Sírvete de esa interfaz para configurar de manera fluida ciertos aspectos de la consulta, entre ellos el tipo del resultado.

Veamos cómo usar findBy repitiendo el ejemplo de los países que contienen «republic» en su nombre:

@Test
void testFindByAsIdName() {
    Example<Country> countryExample = buildRepublicExample();

    Function<FluentQuery.FetchableFluentQuery<Country>, List<IdName>> queryFunction = new Function<>() {
        @Override
        public List<IdName> apply(FluentQuery.FetchableFluentQuery<Country> countryFetchableFluentQuery) {
            return  countryFetchableFluentQuery
                    .sortBy(Sort.by("name"))
                    .as(IdName.class)
                    .all();
         }
    };

    List<IdName> republics = countryRepository.findBy(countryExample, queryFunction);

    assertThat(republics)
            .extracting(IdName::getId)
            .containsExactly(KOREA_ID, DOMINICAN_ID);
    }

Dado que esta versión de findBy carece de un parámetro Sort, tengo que definir la ordenación con sortBy. A continuación, con as indico la clase de cada resultado. Debe ser una interfaz, el único tipo de proyección que QBE admite, al menos en Spring Data JPA 3.0. Finalizo el encadenado de métodos indicando que quiero el resultado en una lista.

Declaré queryFunction como una clase anónima para que veas mejor su código. No obstante, una expresión lambda resulta más sucinta:

List<IdName> republics = countryRepository.findBy(countryExample, queryFunction -> queryFunction
            .sortBy(Sort.by("name"))
            .as(IdName.class)
            .all());

Por desgracia, a diferencia de las proyecciones en interfaz de las consultas derivadas, la proyección en QBE es ineficiente: Hibernate obtiene las entidades al completo en vez de recuperar solo los atributos imprescindibles. La bitácora no engaña:

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 lower(c1_0.name) like ? escape '\' order by c1_0.name

Si depuras el código, descubrirás que Spring crea para IdName un proxy que contiene a la entidad:

Por tanto, te ahorras el código que transforma Country en IdName, pero te pierdes la otra ventaja de las proyecciones: su rendimiento.

Prosigamos con la revisión de la API. Aquí tienes los métodos de FetchableFluentQuery:

List all()Establece que la consulta devuelve una lista.
<R>FluentQuery.FetchableFluentQuery as(Class resultType)Establece el tipo de cada resultado.
long count()Establece que la consulta devuelve el conteo de los resultados totales. Spring Data JPA lo calcula con SELECT COUNT. Asimismo, nótese que ya existe un método count en QueryByExampleExecutor.
boolean exists()Establece que la consulta informa si existe al menos un resultado. De nuevo, ya existe un método exists en QueryByExampleExecutor.
Optional<T> first()Establece que la consulta devuelve el primer resultado, o un Optional vacío si no hay ninguno.
T firstValue()Establece que la consulta devuelve el primer resultado, o null si no hay ninguno.
Optional<T> one()Establece que la consulta devuelve un único resultado, o ninguno (Optional vacío). Si hay varios, se lanza IncorrectResultSizeDataAccessException.
T oneValue()Establece que la consulta devuelve un único resultado, o ninguno (null). Si hay varios, se lanza IncorrectResultSizeDataAccessException.
Page<T> page(Pageable pageable)Establece que la consulta devuelve la página correspondiente a un Pageable.
FluentQuery.FetchableFluentQuery project(String…properties)

FluentQuery.FetchableFluentQuery project(Collection<String> properties)
Indica los atributos a recuperar desde la base de datos. En el caso de Spring Data JPA este método solo considera a los atributos que representan relaciones perezosas.
FluentQuery.FetchableFluentQuery sortBy(Sort sort)Establece un criterio de ordenación.
Stream<T> stream()Establece que la consulta devuelve los resultados como un Stream.

Te presento un par de casos de uso para que te familiarices con FetchableFluentQuery. El primero incluye en el país la relación confederation y toma el primer resultado:

@Test
void testFindByFirstWithConfederation() {
    Example<Country> countryExample = buildRepublicExample();

    Country firstRepublic = countryRepository.findBy(countryExample,
            q -> q.sortBy(Sort.by("name"))
                    .project("confederation")
                    .firstValue());

    assertThat(firstRepublic.getConfederation().getName()).isEqualTo(AFC_NAME);
}

private Example<Country> buildRepublicExample() {
      Country country = new Country();
      country.setName(REPUBLIC_STRING);
      ExampleMatcher matcher = ExampleMatcher.matching()
              .withStringMatcher(ExampleMatcher.StringMatcher.CONTAINING)
              .withIgnoreCase();
      return Example.of(country, matcher);
}

Este otro muestra cómo procesar un stream:

@Test
void testFindByStream() {
    Example<Country> countryExample = buildRepublicExample();

    List<Long> republics = countryRepository.findBy(countryExample,
            q -> q.sortBy(Sort.by("name"))
                    .stream()
                    .map(Country::getId)
                    .toList());

    assertThat(republics)
            .containsExactly(KOREA_ID, DOMINICAN_ID);
}

¿Puedes descubrir el problemilla del ejemplo anterior? Es lo mismo que comenté de las proyecciones en QBE: solo se quieren los identificadores de los países, pero el código recupera todos los atributos. En aras de una mayor eficiencia, en estas situaciones prefiere un tipo de consulta capaz de obtener únicamente el identificador del país. Por ejemplo, JPQL:

    @Query("""
            SELECT c.id FROM Country c
            WHERE UPPER (c.name) LIKE UPPER(CONCAT('%', :name, '%')) 
            ORDER BY c.name""")
    List<Long> findIds(String name);

Más simple todavía con una consulta derivada:

List<Id> findByNameContainingIgnoreCaseOrderByName(String name);

Id es una interfaz, clase o registro (record) que recoge el identificador.

Reflexión final

Aunque QBE es fácil de usar, resulta limitado. Dejando a un lado las cadenas, solo aplica condiciones de igualdad exacta o de nulidad. Olvídate, por ejemplo, de seleccionar rangos de fechas o números menores que cierto valor, requisitos que son el pan de cada día cuando trabajamos con bases de datos. Tampoco puedes agrupar selecciones con AND y OR. Si a estas restricciones le añadimos la existencia de alternativas más potentes, como las que abordaremos en el siguiente capítulo, se comprende que el empleo de QBE en Spring Data JPA sea marginal.

Resumen

Los puntos de interés del capítulo:

  • La técnica QBE consiste en definir consultas con ejemplos de cómo deben ser los resultados.
  • El ejemplo es una entidad envuelta en una interfaz Example.
  • Ejecutas las consultas QBE heredando en un repositorio los métodos de las interfaces QueryByExampleExecutor o ListQueryByExampleExecutor. También puedes copiar esos métodos en un repositorio.
  • La selección de los resultados se configura con ExampleMatcher.
  • Cuentas con la proyección en interfaz, configurable con la API de FetchableFluentQuery.
  • Puesto que los criterios de selección están muy limitados, en la práctica son pocos los escenarios en los que te beneficias de QBE.

Código de ejemplo

El proyecto, explicado en el capítulo dos, se encuentra en GitHub. Para más información acerca de 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

Spring JDBC Template: simplificando el uso de SQL

3 comentarios sobre “Curso Spring Data JPA. 10: Consultas programáticas 1. Query by Example

  1. Hola,

    Excelente curso, muy bueno.

    Me permites una sugerencia? El codigo es dificil de leer, porque la limitacion de ancho de pagina fuerza muchos saltos de linea. Seria ideal que la pagina aprovechara todo el ancho del navegador posible. Asi el codigo se leeria sin saltos de linea (de hecho, lo estoy forzando yo manualmente para poder leerlo comodamente)

    Gracias por todo tu esfuerzo!!!!

Deja un comentario

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