Aplicar filtros dinámicos

Filter y FilterDef son dos anotaciones de Hibernate ORM (lo que hay por debajo de Panache) usadas para crear filtros dinámicos con nombre. Si queremos que nuestro PanacheQuery pueda recibir una cadena de filtros, podemos usarlos para aplicar sucesivas cadenas de criterios para reducir los resultados devueltos por el repositorio.

La clase PanacheQuery también dispone de un método denominado filter(). Podríamos pensar que sirve para declarar filtros del mismo modo que podemos establecer criterios de búsqueda con las operaciones list() y find() de un repositorio o de una entidad. Sin embargo, su comportamiento es diferente. Tiene que ver con las anotaciones @Filter y @FilterDef, que son anotaciones de Hibernate ORM con las que podemos declarar filtros dinámicos.

Declaración de filtros con @Filter y @FilterDef

@Filter y @FilterDef se usan en conjunto para fabricar un filtro con nombre. Típicamente nosotros filtramos estableciendo un criterio de búsqueda. Por ejemplo, booksRepository.find("active", true) o membersRepository.find("last_access >= ?1", oneMonthAgo). Sin embargo, si vamos a estar usando esos filtros en varias partes, tal vez interese guardar el filtro bajo un alias, para poder referirse a él sin tener que escribir el filtro todo el tiempo. Así, si lo queremos modificar, ya solo tenemos que cambiar la definición del filtro, no los mil usos que hagamos de él.

Para declarar un filtro tenemos que usar estas dos anotaciones. Ambas forman parte del paquete org.hibernate.annotations así que sus nombres completos son org.hibernate.annotations.Filter y org.hibernate.annotations.FilterDef. También existen otras versiones como Filters o FiltersDef, para los plurales. En general, es buena idea mirar la documentación de Hibernate ORM para ver todas las anotaciones que podemos usar. En la guía de Hibernate ORM 6.3 esto está cubierto en la sección 5.9.3.

Para definir un filtro necesitaremos lo siguiente:

  • El nombre que vayamos a darle al filtro. Luego nos referiremos a él, así que conviene usar un filtro con un nombre bien descriptivo o por lo menos documentar todos los filtros que posee una entidad.
  • Los parámetros que queremos que tenga el filtro. Puede que un filtro no tenga parámetros si no hace falta especificarle nada por fuera. En el caso de que queramos filtrar por los libros que estén disponibles para prestar, por ejemplo, no hay ningún parámetro: un libro está o no está para alquilar, y eso es algo que depende de la lógica de negocio de la aplicación. Si el filtro recuperase los libros que pertenecen a un género concreto, entonces tal vez sí sea necesario que el nombre del género se proporcione como parámetro para poder usar un género u otro a cada vez.
  • El criterio de búsqueda que le queramos aplicar al filtro. Se define usando una query en formato HQL. HQL es muy parecido a SQL y como cuento en el ejemplo que viene a continuación, no tiene mucha pérdida.

Pues bien, la razón por la que he enumerado antes los tres elementos es porque ahora tenemos que declarar un par de anotaciones Filter + FilterDef, y repartir estos tres elementos del siguiente modo.

Primero pondremos una anotación llamada @Filter, que recibe como parámetro el nombre del filtro usando el atributo name y el criterio del filtro usando el atributo condition. Por ejemplo:

  • @Filter(name = "book.for_lend", condition = "available = true"). Esto define un filtro llamado book.for_lend con la condición HQL available = true. Es fácil ver el código SQL al que se convertiría esto, que sería un WHERE.
  • @Filter(name = "book.by_genre", condition = "genre = :g"). Esto define otro filtro llamado book.by_genre, que lleva como condición la expresión genre = :g. En este caso, el género lleva un parámetro llamado g, y lo reconocemos porque lleva el símbolo de dos puntos delante. Si algo lleva dos puntos delante, entonces es un identificador, una variable que usaremos luego para especificar el valor real del filtro. Así podemos hacer by_genre({"g": "Política"}) o by_genre({"g": "Poesía"}).

Como ves, en el caso de la anotación @Filter se usa el símbolo de dos puntos, no el símbolo de interrogación.

Después, encima de la anotación @Filter pondremos la anotación @FilterDef. Esta anotación se usa para especificar el nombre definitivo de cada parámetro así como su tipo de datos. Es necesario especificarla siempre, incluso cuando un filtro no vaya a tener parámetros. En ese caso, no se especificaría ningún parámetro, únicamente su nombre, que debe coincidir con el dado al Filter.

Por ejemplo, en el siguiente caso estamos declarando de forma definitiva un filtro sin parámetros llamado available_for_lend que no tiene parámetros:

@Entity
@FilterDef(name = "available_for_lend")
@Filter(name = "available_for_lend", condition = "available = true")
public class Book {
  // ...
}

El nombre, de nuevo, tiene que coincidir en ambas anotaciones, que es obligatorio que estén declaradas.

En este otro ejemplo, en cambio, sí que vamos a declarar el parámetro g para referirse al nombre del género del libro. Para declarar el parámetro usamos la anotación @ParamDef, donde especificamos su nombre y el tipo de datos.

@Entity
@FilterDef(
  name = "by_genre",
  parameters = @ParamDef(name = "g", type = String.class)
)
@Filter(name = "by_genre", condition = "genre = :g")
public class Book {
  // ...
}

Si tuviésemos más de un parámetro, podríamos declararlo como un array. En este otro caso estamos declarando un filtro que tiene más de un parámetro, :high y :low:

@Entity
@FilterDef(
    name = "by_range",
    parameters = {
      @ParamDef(name = "low", type = Integer.class),
      @ParamDef(name = "high", type = Integer.class)
    }
)
@Filter(
  name = "by_range",
  condition = "price >= :low and price <= :high"
)
public class Product {
  // ...
}

Una entidad puede tener tantas anotaciones Filter + FilterDef como queramos:

@Entity
@FilterDef(
  name = "by_code",
  parameters = @ParamDef(name = "code", type = String.class)
)
@Filter(name = "by_code", condition = "code = :code")
@FilterDef(
  name = "by_range",
  parameters = {
    @ParamDef(name = "low", type = Integer.class),
    @ParamDef(name = "high", type = Integer.class)
  }
)
@Filter(name = "by_range", condition = "price >= :low and price <= :high")
public class Book {
  // ...
}

Uso de los parámetros dinámicos

Por último, para usar esos filtros tendremos que usar el método filter() de una PanacheQuery. Es importante considerar que aunque pueda parecer igual que los métodos find() o list(), repito una vez más que solo este método aceptará filtros con nombre.

Este método tiene varias sobrecargas. Como mínimo le tenemos que dar el nombre del filtro. Si el filtro no tiene parámetros, esto será suficiente:

PanacheQuery<Book> query = repository.findAll();
query.filter("available_for_lend"); // Filtro: available = true
query.page(Page.of(page, 20)); // podemos paginar si queremos
return query.list(); // devuelve la lista

Sin embargo, en algunas ocasiones tendremos que especificar los parámetros del filtro. Esto lo podemos hacer, o bien con un mapa que asocie String a Object para cada uno de los parámetros...

PanacheQuery<Product> query = repository.findAll();

Map<String, Object> params = new HashMap<>();
params.put("min", 1000);
params.put("max", 2000);

query.filter("by_range", params); // Filtro: price >= 1000 and price <= 2000
query.page(Page.of(page, 20)); // podemos paginar si queremos
return query.list(); // devuelve la lista

O bien simplemente usando la clase de utilidad Parameters (io.quarkus.panache.common), que tiene como ventaja que nos ofrece métodos de apoyo como el método estático with para instanciar este mapa fácilmente, o el método de instancia and, para ir concatenando parámetros.

PanacheQuery<Book> query = repository.findAll();
query.filter("by_genre", Parameters.with("g", "Política"));
query.page(Page.of(page, 20)); // podemos paginar si queremos
return query.list(); // devuelve la lista

Un ejemplo completo

Termino con un ejemplo completo mostrando cómo podemos aplicar filtros a un recurso.

Dada la siguiente clase que representa un género literario. Le pongo un filtro llamado name.like donde uso una condición un poco más específica: LOWER(name) LIKE LOWER(:name). Aquí estoy usando el operador LIKE para poder recuperar nombres que coincidan parcialmente en el nombre, de la manera en la que funciona el operador LIKE en SQL. Debido a que Hibernate no me permite usar ILIKE, estoy usando un LIKE pero pasando tanto el parámetro como el valor de la columna name a minúscula mediante el uso de la función LOWER.

@Entity
@FilterDef(
        name = "name.like",
        parameters = @ParamDef(name = "name", type = String.class)
)
@Filter(name = "name.like", condition = "LOWER(name) LIKE LOWER(:name)")
public class Genre {
    @Id
    @GeneratedValue
    private Long id;

    private String name;

    // Getters, setters y esas cosas...
}

Voy a definir también un repositorio. Lo interesante aquí es que al repositorio le voy a meter un método de apoyo que me prepare ya un objeto de tipo PanacheQuery para poder paginar. Esta es una operación interesante de cara a limitar los elementos devueltos por la API, así que es ideal para integrarla en el propio repositorio, ya que se comporta como un servicio después de todo:

@ApplicationScoped
public class GenreRepository implements PanacheRepository<Genre> {

    public PanacheQuery<Genre> findPage(int page) {
        Page p = new Page(page - 1, 5);
        var query = findAll(Sort.descending("id"));
        query.page(p);
        return query;
    }

}

Finalmente, en el recurso, cuyo código completo puedes ver aquí, estoy usando el siguiente método:

@GET
public PaginatedResponse<Genre> list(
  @QueryParam("page") @DefaultValue("1") int page,
  @QueryParam("q") String q
) {
  var query = genres.findPage(page);
  if (q != null) {
    var nameLike = "%" + q + "%";
    query.filter("name.like", Parameters.with("name", nameLike));
  }
  return new PaginatedResponse<>(query);
}

PaginatedResponse es un record para formatear una query que definí en el capítulo sobre paginación. Aquí lo interesante es que me estoy aprovechando del método findPage() que le metí previamente a mi repositorio de géneros. Cuando el parámetro q es válido, preparo un filtro llamando a query.filter(), indicando que quiero filtrar por name.like, y especificando el nombre usando un conjunto de parámetros. Para que el filtro LIKE funcione correctamente, tengo que ponerle como prefijo y sufijo el operador porcentaje (%) a la query que le entra.

Podemos apreciar ahora que si no le especificamos ningún parámetro al endpoint, nos devuelve la lista completa de géneros:

# GET /genres
{
    "currentPage": 1,
    "data": [
        {
            "id": 2,
            "name": "Informática"
        },
        {
            "id": 1,
            "name": "Poesía"
        }
    ],
    "totalPages": 1
}

Pero que en cambio a medida que aplicamos un valor para el filtro q, nos permite recuperar solo un conjunto de los resultados.

# GET /genres?q=oes
{
    "currentPage": 1,
    "data": [
        {
            "id": 1,
            "name": "Poesía"
        }
    ],
    "totalPages": 1
}


# GET /genres?q=ática
{
    "currentPage": 1,
    "data": [
        {
            "id": 2,
            "name": "Informática"
        }
    ],
    "totalPages": 1
}
Lista de reproducción
  1. 1
    Configurar una base de datos
    7 minutos
  2. 2
    Crear una entidad y un repositorio
    12 minutos
  3. 3
    Active Record con PanacheEntity
    4 minutos
  4. 4
    Modificar y borrar registros
    12 minutos
  5. 5
    Filtros y ordenación
    12 minutos
  6. 6
    Paginación de resultados
    13 minutos
  7. 7
    Aplicar filtros dinámicos
    12 minutos
  8. 8
    JsonIgnore, JsonProperty y JsonAlias
    6 minutos