Curso Jakarta EE 9 (33). JPA con Hibernate (16): trabajando con JPQL\HQL (2). Proyecciones y paginación.

logo Jakarta EE

Con los conocimientos del capítulo anterior, estamos limitados a consultas que devuelvan un único tipo de entidad o de valor escalar, pese a que ya vimos que JPQL es flexible en este aspecto. Tampoco dimos solución a la recuperación de listados grandes. Dos asuntos de vital importancia en nuestro día a día con JPA que examinamos en detalle en este capítulo.

>>>> ÍNDICE <<<<

Tratamiento de la proyección

En las consultas que devuelven una entidad o un valor asignable a un tipo primitivo, vimos que la respuesta se recibe directamente sin ninguna complicación, tanto con Query como TypedQuery.

@Override
 public List<Expense> findAll() {
        return em.createQuery(
                    "SELECT e  FROM Expense e " +
                    "ORDER BY e.date DESC,  e.concept",
                    Expense.class)
                .getResultList();
}

Repasemos las alternativas disponibles, tanto en JPA como en Hibernate, cuando aparezcan más de un valor en la proyección.

Empecemos suponiendo un caso realista. Queremos un listado con todas las categorías y, para «cada una de ellas» -este requerimiento nos indica que debemos usar GROUP BY-, un resumen de sus gastos con funciones agregadas. Las categorías sin gastos también deben aparecer, lo que implica la reunión externa de Category y Expense. Ambos conceptos (GROUP BY y reunión externa), tomados prestados de SQL, los veremos en los dos próximos capítulos. Por ahora, centrémonos en la proyección.

@NamedQuery(name = "Category.summary",
    query = "SELECT c.id AS id, c.name AS name, SUM (e.amount) AS total, COUNT(e) AS expenses " +
            "FROM Category c LEFT JOIN c.expenses e " +
            "GROUP BY c.id ORDER BY c.name")
public class Category {

Se trata de una consulta estática nombrada que usaremos varias veces en el DAO para Category. Las pruebas estarán en la clase CategoryJpqlDAOTest. La mayoría de ellas no las muestro por no ser de especial relevancia.

En bruto (RAW)

JPA puede devolver la proyección anterior en un array de Object, siguiendo el orden en el que aparecen los elementos en la cláusula SELECT.

 List<Object[]> summary = em.createNamedQuery("Category.summary",  Object[].class)
                                                   .getResultList();

No es nada práctico trabajar ni con arrays ni con objects. Generalmente querremos transformar los resultados en clases que resulten más convenientes.

@Override
public List<CategorySummaryDTO> getSummaryRaw() {
    return em.createNamedQuery("Category.summary", Object[].class)
            .getResultStream()
            .map(this::map)
            .collect(Collectors.toList());
}

private CategorySummaryDTO map(Object[] columns) {
    return new CategorySummaryDTO(
            (Long) columns[0],
            (String) columns[1],
            (BigDecimal) columns[2],
            (Long) columns[3]);
}

Un poco tosco y cercano a la forma de emplear los ResultSet de JDBC. Tiene que haber algo mejor.

La interfaz Tuple

La información contenida en el array de Object se puede encapsular automáticamente en un objeto de tipo Tuple. Es una especie de «envoltorio» del array con métodos que facilitan la extracción de sus datos. De hecho, así lo implementa Hibernate.

public class HqlTupleImpl implements Tuple {
		private Object[] tuple;

		public HqlTupleImpl(Object[] tuple) {
			this.tuple = tuple;
		}

Usamos Tuple creando el objeto TypedQuery correspondiente.

@Override
public List<CategorySummaryDTO> getSummaryTuple() {
    return em.createNamedQuery("Category.summary", Tuple.class)
            .getResultStream()
            .map(this::map)
            .collect(Collectors.toList());
}

En la línea 5 se llama a una nueva versión del método map muy parecida a la que ya tenemos. Accederá a los datos por su posición en la proyección, empezando en cero, pero ahora la conversión de tipos es realizada por Tuple.

 private CategorySummaryDTO mapIndex(Tuple tuple) {
        return new CategorySummaryDTO(
                tuple.get(0, Long.class),
                tuple.get(1, String.class),
                tuple.get(2, BigDecimal.class),
                tuple.get(3, Long.class));
    }

El código queda más legible y seguro si declaramos un alias para cada elemento del resultado porque podemos usarlo en lugar del índice.

SELECT c.id AS id, 
       c.name AS name,
       SUM (e.amount) AS total,
       COUNT(e) AS expenses
private CategorySummaryDTO mapAlias(Tuple tuple) {
        return new CategorySummaryDTO(
                tuple.get("id", Long.class),
                tuple.get("name", String.class),
                tuple.get("total", BigDecimal.class),
                tuple.get("expenses", Long.class));
 }
Constructor

Si el lector ha seguido el curso, esta opción no necesita presentación. Es la más práctica de todas porque no tenemos que hacer nada para encapsular cada resultado en cualquier clase que queramos. Basta con proporcionar en la consulta su constructor con el operador NEW.

@Override
public List<CategorySummaryDTO> getSummaryConstructor() {
    return em.createQuery("SELECT NEW " + CategorySummaryDTO.class.getName() +
                        "(c.id, c.name , SUM (e.amount) , COUNT(e))  " +
                        "FROM Category c LEFT JOIN c.expenses e  " +
                        "GROUP BY c.id ORDER BY c.name", CategorySummaryDTO.class)
                    .getResultList();
}

Este es el DTO. La creación del constructor se ha legado en la anotación @AllArgsConstructor de Lombok. El orden de los argumentos coincide con el orden en el que se han declarado los atributos.

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class CategorySummaryDTO {

    private Long id;
    private String name;
    private BigDecimal total;
    private Long expenses;

}

¡Esto ya es otra cosa! Y si estamos en Java 16, es posible usar records. Todo esto suponiendo que podamos recoger cada resultado en un objeto de tal forma que no tengamos que efectuar algún tratamiento adicional que no sea apropiado realizar en el constructor. Cuando suceda esto último, elegiremos una de las demás alternativas.

ResultTransformer

Abandonamos la especificación para examinar una opción exclusiva de Hibernate: el transformador de resultado, interfaz con dos métodos.

  • transformTuple. Se invoca para cada resultado con un Object[] que contiene los elementos de la proyección. Lo usaremos para crear y devolver el objeto que represente al resultado.
  • transformList. Se invoca tras el procesamiento completo del ResultSet y, por tanto, después de todas las llamadas a transformTuple. Aquí recibimos la lista con todos los resultados que hemos construido con el método transformTuple para hacer cualquier tipo de procesamiento final de todo el conjunto.

El siguiente transformador se limita a reutilizar el método map creado líneas atrás para procesar el array de Object de la proyección.

private class CategorySummaryTransformer implements ResultTransformer {

    private static final long serialVersionUID = 1L;

    @Override
    public Object transformTuple(Object[] tuple, String[] aliases) {
        return CategoryJpqlDAOImpl.this.map(tuple);
   }

   @Override
   public List transformList(List collection) {
       return collection;
  }
 
}

Lo aplicamos al objeto Session de Hibernate.

@Override
public List<CategorySummaryDTO> getSummaryResultTransformer() {
    return em.unwrap(Session.class)
             .createNamedQuery("Category.summary")
            .setResultTransformer(new CategorySummaryTransformer())
            .getResultList();
}

A pesar de que el método setResultTransformer fue marcado como obsoleto en Hibernate 5.2, no se ofrece una alternativa hasta Hibernate 6.0, versión en la que seguirá disponible esta funcionalidad tal y como la presento en este capítulo.

En la práctica, con la creación de transformadores estamos estandarizando el tratamiento de los resultados en bruto (poca cosa). Lo interesante es que Hibernate incluye varios listos para ser usados. El más útil es AliasToBeanResultTransformer, capaz de convertir el resultado en un DTO siempre y cuando posea atributos, accesibles con setters, con nombres coincidentes con los alias declarados en la SELECT.

@Override
public List<CategorySummaryDTO> getSummaryAliasResultTransformer() {
    return em.unwrap(Session.class)
            .createNamedQuery("Category.summary")
            .setResultTransformer(new AliasToBeanResultTransformer(CategorySummaryDTO.class))
            .getResultList();
}

De no existir el método set adecuado para algún elemento, se lanza una excepción.

org.hibernate.PropertyNotFoundException: Could not resolve PropertyAccess for

Asimismo, CategorySummaryDTO debe tener el constructor vacío, de modo que sus atributos no pueden ser finales, criterio que recomendé seguir cuando fuera posible.

Otra implementación a la que merece echar un vistazo es AliasToBeanConstructorResultTransformer. Hace, ni más ni menos, lo mismo que el operador NEW: volcar la proyección en un constructor compatible con el orden y tipado. El inconveniente es que necesitamos una instancia de Constructor.

@Override
public List<CategorySummaryDTO> getSummaryConstructorResultTransformer() {
    Constructor<?> constructor;
    try {
        constructor = CategorySummaryDTO.class.getConstructor(Long.class, String.class, BigDecimal.class, Long.class);
    } catch (NoSuchMethodException e) {
        throw new RuntimeException("constructor for " + CategorySummaryDTO.class + "not found !!!");
    }
    return em.unwrap(Session.class)
            .createNamedQuery("Category.summary")
            .setResultTransformer(new AliasToBeanConstructorResultTransformer(constructor))
            .getResultList();
}

AliasToBeanResultTransformer y AliasToBeanConstructorResultTransformer no parecen demasiado útiles porque disponemos de la proyección en constructor…en JPQL y Criteria API. Cobrarán sentido cuando tratemos con consultas nativas (SQL).

Paginación

He alertado en repetidas ocasiones a lo largo del curso del peligro que supone traer de la base de datos grandes listados. Cuantos más registros, mayor tiempo de obtención y consumo de memoria. El rendimiento del sistema se degrada y, en casos extremos, puede venirse abajo por un OutMemoryException o similar.

Dado que es esperable que existan innumerables gastos, no deberíamos escribir métodos susceptibles de obtener una gran cantidad de ellos, como el findAll que vimos en el capítulo anterior. No obstante, por simplicidad no he usado paginación en los ejemplos porque, veremos, también se requiere una consulta adicional para calcular un conteo.

La solución consiste en recuperar, siempre a nivel de SQL, los datos agrupados en lotes pequeños y manejables denominados páginas. Es lo que observamos en cualquier aplicación web que muestre una tabla o listado.

La imagen muestra la sección de paginación de una tabla que propone Material Design. Permite especificar el tamaño de cada página (el número de filas) y navegar entre ellas con botones. Este tamaño tendrá un límite máximo razonable y debemos asegurar en el código que sea respetado. Lo consideraremos cuando desarrollemos la API REST y validemos los datos que nos envíen sus clientes.

En JPA, la paginación se configura con los métodos setFirstResult y setMaxResults de Query \ TypedQuery, por lo que puede aplicarse de igual manera a las consultas JPQL, Criteria API y SQL. Sirven para indicar el primer resultado a devolver -el índice parte de cero- y cuántos queremos (el tamaño de la página). Para que el reparto de los datos en páginas sea coherente, SIEMPRE debe aplicarse un criterio de ordenación, aunque sea el identificador, porque las bases de datos nunca garantizan un orden predeterminado. También hay que tener en cuenta que el solicitante de los datos debería ser informado del número total de posibles resultados para facilitarle la navegación entre ellos.

Teniendo en cuenta todas estas consideraciones, escribamos un método findAll para Expense con paginación. Devolverá una página de datos en un objeto inmutable de la clase Page que incluye tanto la lista de gastos como la información necesaria para iterar por todos los resultados. Con tipado genérico será reutilizable.

package com.danielme.jakartaee.jpa;

import lombok.AllArgsConstructor;
import lombok.Getter;

import java.util.List;

@Getter
@AllArgsConstructor
public class Page<T> {

    private final List<T> results;
    private final long total;
    private final int firstPosition;
    private final int pageSize;

    public int getNumPages() {
        return total == 0 ? 0 : (int) Math.ceil((double) this.total / (double) pageSize);
    }

    public int getNumber() {
        return results.size() == 0 ? 0 : (int) Math.ceil((firstPosition + 1.0) / pageSize);
    }

    public boolean hasNext() {
        return getNumber() > 0 && getNumber() < getNumPages();
    }

}

Si nos apoyamos en el método count() del DAO genérico para obtener el valor de la propiedad total de Page, la implementación de findAll con paginación queda como sigue.

    @Override
    public Page<Expense> findAll(int first, int max) {
        List<Expense> expenses = em.createQuery("SELECT e FROM Expense e " +
                        "ORDER BY e.date DESC, e.concept, e.amount", Expense.class)
                .setFirstResult(first)
                .setMaxResults(max)
                .getResultList();
        return new Page<>(expenses, count(), first, max);
    }

El dataset de pruebas contiene seis gastos. Si queremos que el tamaño de las páginas sea cuatro, así debemos usar findAll.

Comprobamos que se recuperan las dos páginas esperadas con esta prueba.

    @Test
    void testFindAllPaginated() {
        int pageSize = 4;

        Page<Expense> page1 = expenseJpqlDAO.findAll(0, pageSize);
        Page<Expense> page2 = expenseJpqlDAO.findAll(4, pageSize);

        assertThat(page1.getResults())
                .extracting("id")
                .containsExactly(Datasets.EXPENSE_ID_6, Datasets.EXPENSE_ID_5,
                        Datasets.EXPENSE_ID_3, Datasets.EXPENSE_ID_4);
        assertThat(page1.getNumPages())
                .isEqualTo(2);
        assertThat(page1.getTotal())
                .isEqualTo(Datasets.TOTAL_EXPENSES);
        assertThat(page1.getNumber())
                .isEqualTo(1);
        assertThat(page1.hasNext())
                .isTrue();

        assertThat(page2.getResults())
                .extracting("id")
                .containsExactly(Datasets.EXPENSE_ID_2, Datasets.EXPENSE_ID_1);
        assertThat(page2.getNumber())
                .isEqualTo(2);
        assertThat(page2.hasNext())
                .isFalse();
    }

La técnica que Hibernate ha usado para paginar los datos es bien sencilla: añade las cláusulas LIMIT y OFFSET – o las equivalentes según el dialecto de nuestra base de datos- a la consulta SQL, de tal modo que a la aplicación solo llegan los datos necesarios. Ninguna de estas cláusulas existe en JPQL, así que la paginación siempre se definirá en código.

SELECT expense0_.id AS id1_4_, ...
FROM	expenses expense0_
ORDER BY expense0_.date DESC, expense0_.concept, expense0_.amount
LIMIT ?, ?
Limitaciones

La paginación en base de datos no está disponible para todas las consultas. Es el caso de aquellas que efectúan reuniones en modo FETCH con relaciones múltiples (one-to-many, many-to-many). Lo examinaremos en la próxima entrega del curso.

Código de ejemplo

El código de ejemplo se encuentra en GitHub (todos los proyectos son independientes pero están en un único repositorio). Para más información sobre cómo utilizar GitHub, consultar este artículo.

>>>> Í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.