Curso Spring Data JPA. 8: ordenación

logo spring

La ordenación de los resultados de las consultas parece un asunto sencillo y con poco recorrido. Nada más alejado de la realidad; hay bastante de qué hablar, tal y como descubrirás en este capítulo.



Índice

  1. El problema con la ordenación
  2. Ordenación con Sort
  3. La clase Sort.Order
  4. ¿Qué hacemos con los nulos?
  5. Ordenando con funciones
  6. Sort tipado con TypedSort<T>
  7. Prioridad de ordenaciones
  8. Un detalle curioso
  9. Resumen
  10. Código de ejemplo


El problema con la ordenación

En el curso ya han aparecido ejemplos de ordenación de resultados. Recordemos un par de ellos.

  • Consulta derivada con expresiones OrderBy al final:
List<Country> findTop3ByOrderByPopulationAsc();
  • Consulta JPQL con cláusula ORDER BY:
@Query("""
        SELECT new com.danielme.springdatajpa.model.dto.ConfederationSummaryRecord(c.id, c.name, COUNT(ct.id))
        FROM Country ct INNER JOIN ct.confederation c
        GROUP BY ct.confederation.id, c.name
        ORDER BY c.name ASC""")
List<ConfederationSummaryRecord> getSummaryCountryCountAsRecord();

En las consultas anteriores hay un pequeño detalle en el que tal vez no hayas reparado: la ordenación es estática. Se establece en la definición de la consulta y no se puede cambiar.

Si necesitas aplicar distintos criterios de ordenación a una consulta, resulta tentador duplicarla y cambiar la ordenación. No lo hagas. Spring Data te permite declarar criterios dinámicos, esto es, en tiempo de ejecución. Puedes disfrutar de este superpoder tanto en las consultas derivadas como en las escritas con JPQL, además de en otros tipos que veremos en futuros capítulos.

Ordenación con Sort

Aquí tienes un primer ejemplo de ordenación dinámica:

List<Country> findTop3By(Sort sort);

Es lo que parece: se añade a la signatura del método un parámetro de tipo Sort con los criterios de ordenación. No es la primera vez que vemos un método así en el curso, aunque su presentación pasó desapercibida. El siguiente diagrama pertenece al capítulo tres e ilustra los repositorios genéricos. En esta ocasión los métodos con un parámetro Sort aparecen subrayados en rojo.

Recuerda que no estás obligado a heredar de estos repositorios si precisas de algunos de sus métodos; copia los que necesites en tus repositorios.

Esta imagen es el «mapa» de Sort (clic para ampliar) en Spring Data Commons 3.0.0.

El diagrama parece intrincado, pero no te asustes. La clave es que Sort contiene una lista de objetos de su clase interna, estática e inmutable Sort.Order. Cada order de esa lista representa un criterio de ordenación para un atributo de la entidad —se admite la navegación por las relaciones con el operador "."— o un alias declarado en la proyección. El criterio incluye el sentido (enumerado Sort.Direction), el tratamiento de los nulos (enumerado Sort.NullHandling) y el de la capitalización (el flag llamado ignoreCase). No me olvido de la clase TypedSort; la examinaremos más adelante.

IMPORTANTE. El tratamiento de los nulos definido por Spring Data y presentado en este capítulo no está implementado en Spring Data JPA. Lo recoge una incidencia registrada en GitHub.

Esta es la declaración de los elementos anteriores:

public class Sort implements Streamable<org.springframework.data.domain.Sort.Order>, Serializable {

	private static final long serialVersionUID = 5737186511678863905L;

	private static final Sort UNSORTED = Sort.by(new Order[0]);

	public static final Direction DEFAULT_DIRECTION = Direction.ASC;

	private final List<Order> orders;
	
   //...

    public static class Order implements Serializable {

		 private static final boolean DEFAULT_IGNORE_CASE = false;
		 private static final NullHandling DEFAULT_NULL_HANDLING = NullHandling.NATIVE;

		 private final Direction direction;
		 private final String property;
		 private final boolean ignoreCase;
		 private final NullHandling nullHandling;

        //...

Volveremos sobre la clase Order en la próxima sección.

¿Cómo se crean los objetos de la clase Sort? Olvídate de sus constructores, pues son privados. En su lugar, recurre a las sobrecargas del método estático by. Estas son las de uso más habitual:

  • public static Sort by(String... properties). Recibe un varargs con los nombres de los campos. El sentido de la ordenación es ascendente. Esto implica que la siguiente llamada equivale a invocar al método findTop3ByOrderByPopulationAsc:
Sort sortByPopulation = Sort.by("population");
List<Country> lessPopulatedCountries = countryRepository.findTop3By(sortByPopulation);
  • public static Sort by(Direction direction, String... properties). De nuevo, un método que recibe los campos en un varargs, pero acompañado del sentido. Esta llamada equivale a la anterior en sentido inverso:
Sort sortByPopulation = Sort.by(Sort.Direction.DESC, "population");
List<Country> mostPopulatedCountries = countryRepository.findTop3By(sortByPopulation);

Los métodos by crean los objetos Order idóneos. Y, por supuesto, la lista que los contiene respeta el orden de declaración de los campos, de tal modo que aparecerán en ese orden en la cláusula ORDER BY.

Veamos un ejemplo con varios criterios de ordenación. Partamos de esta consulta:

@Query("""
            SELECT new com.danielme.springdatajpa.model.dto.ConfederationSummaryRecord(
                c.id, c.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);

Es una consulta JPQL con una proyección personalizada adaptada del capítulo precedente. Observa que he añadido alias a la proyección para nombrar sus componentes.

El caso que planteo: «ordenar primero por el número de países en sentido descendente y después por el nombre de la confederación en sentido ascendente». Con los métodos by es imposible escribir tal ordenación porque cada propiedad tiene un sentido distinto. Aquí entran en juego estos métodos de Sort:

Sort descending()Devuelve un nuevo Sort con los mismos criterios de ordenación que el objeto sort sobre el que se invoca el método, pero todos en sentido descendente.
Sort ascending()Devuelve un nuevo Sort con los mismos criterios de ordenación que el objeto sort sobre el que se invoca el método, pero todos en sentido ascendente.
Sort and(Sort sort)Devuelve un nuevo Sort con los mismos criterios de ordenación que el objeto sort sobre el que se invoca el método más los del argumento.

El hecho de que descending, ascending y and devuelvan nuevos objetos Sort es consecuencia de que los objetos Sort.Order son inmutables, tal como señalé párrafos atrás. Asimismo, el retorno del objeto creado permite encadenar llamadas de manera fluida.

Ahora cuentas con las herramientas para declarar la ordenación que propuse. Una posible solución:

Sort sort = Sort.by("countries")
                .descending()
                .and(Sort.by("name"));

El primer campo a ordenar es el conteo de los países de cada confederación futbolística. Si bien puede indicarse el sentido como un argumento de by, quiero mostrar cómo hacerlo con el método descending.

El segundo campo se agrega llamando a and. Recibe un Sort, construido con by, con el criterio de ordenación ascendente para name. Su retorno es un nuevo objeto Sort con toda la configuración realizada hasta el momento al que puedes añadir más criterios invocando and.

El mismo ejemplo sin descending:

 Sort sort = Sort.by(Sort.Direction.DESC, "countries")
                .and(Sort.by("name"));

¿Y si no quisieras establecer la ordenación? Los argumentos de tipo Sort son opcionales, así que puedes proporcionar null. Sin embargo, es más elegante crear una ordenación «vacía» con Sort#unsorted():

confederationRepository.getSummaryCountryCount(Sort.unsorted());

La clase Sort.Order

Los métodos by crean un Order para cada campo de ordenación, asignando a ignoreCase y nullHandling las constantes DEFAULT_IGNORE_CASE (es false) y DEFAULT_NULL_HANDLING. Esto significa que, en general, solo crearás objetos Order cuando necesites especificar los valores ignoreCase y \ o nullHandling. Reitero que este último valor carece de efecto alguno en Spring Data JPA.

Puedes crear objetos Order de dos formas:

  • Con los constructores públicos:
public Order(@Nullable Direction direction, String property) {
			this(direction, property, DEFAULT_IGNORE_CASE, DEFAULT_NULL_HANDLING);
}

public Order(@Nullable Direction direction, String property, NullHandling nullHandlingHint) {
			this(direction, property, DEFAULT_IGNORE_CASE, nullHandlingHint);
}
  • Con métodos estáticos:
public static Order asc(String property) {
    return new Order(Direction.ASC, property, DEFAULT_NULL_HANDLING);
}

public static Order desc(String property) {
    return new Order(Direction.DESC, property, DEFAULT_NULL_HANDLING);
}

public static Order by(String property) {
	return new Order(DEFAULT_DIRECTION, property);
}

Una vez tengas tus objetos Order necesitarás un Sort que los contenga. Cómo cabría esperar, Sort provee métodos by que reciben objetos Order en una lista o un varargs:

public static Sort by(List<Order> orders)
public static Sort by(Order... orders)

Este es el último Sort de ejemplo de la sección anterior construido con objetos Order:

List<Sort.Order> orders = List.of(Sort.Order.desc("countries"), 
                                                       Sort.Order.asc("name"));

List<ConfederationSummaryRecord> confederationsSummary = confederationRepository.getSummaryCountryCount(Sort.by(orders));

Con todo, si revisas los métodos presentados, verás un detalle desconcertante: ¡ninguno permite configurar la capitalización! El constructor que recibe como argumento el valor de ignoreCage es privado. La solución está en la API fluida de Order, en concreto en el método ignoreCase. Para usar la API, crea primero un Order y luego haz que invoque a los métodos de esta tabla:

Sort.Order ignoreCase()Devuelve un nuevo objeto Sort.Order activando el flag ignoreCase.
Sort.Order nullsFirst()Devuelve un nuevo objeto Sort.Order con la opción Sort.NullHandling.NULLS_FIRST.
Sort.Order nullsLast()Devuelve un nuevo objeto Sort.Order con la opción Sort.NullHandling.NULLS_LAST.
Sort.Order nullsNative()Devuelve un nuevo objeto Sort.Order con la opción Sort.NullHandling.NATIVE. Delega en el almacén de datos el criterio de ordenación de los nulos.
Sort.Order with(Sort.Direction)Devuelve un nuevo objeto Sort.Order con el sentido indicado.
Sort.Order with(Sort.NullHandling)Devuelve un nuevo objeto Sort.Order con el criterio para los nulos indicado.

He aquí un ejemplo:

Sort.Order orderName = Sort.Order.by("name")
                .with(Sort.Direction.DESC)
                .ignoreCase();

La consecuencia de llamar a ignoreCase es la aplicación la función LOWER de JPQL para que las cadenas se ordenen como si todos sus caracteres estuvieran en minúsculas:

order by lower(c1_0.name) asc

¿Qué hacemos con los nulos?

Como dije, la respuesta a esta pregunta no la encontrarás en Sort; pero tampoco en la especificación JPA.

Por fortuna, con Hibernate algo podemos hacer. HQL, su implementación de JPQL, admite las opciones NULLS FIRST y NULLS LAST de SQL:

ORDER BY c.name ASC NULLS LAST

Algunas bases de datos son compatibles con estas opciones (Oracle, PostgreSQL, HyperSQL), otras no (MySQL, SQL Server). En las últimas, Hibernate las emulará. Por ejemplo, en MySQL para NULLS FIRST generará lo siguiente:

order by
        case 
            when country0_.name is null then 0 
            else 1 
        end,

Cabe mencionar, además, la posibilidad de configurar la prioridad predeterminada de los nulos para todas las consultas que Hibernate genere:

spring.jpa.properties.hibernate.order_by.default_null_ordering=last

Los valores aceptados son first (primero los nulos), last (nulos al final) y none (predeterminado, lo que haga la base de datos). Ten en cuenta que el comportamiento predeterminado de la base de datos depende de cada producto.

Ordenando con funciones

Un caso especial es la ordenación según el resultado de una función. Se trata de una capacidad propia de Spring Data JPA solo aplicable a las consultas declaradas con @Query. Obliga a usar JpaSort, un subtipo de Sort tal y como muestra el siguiente diagrama.

Este Sort ordena los países de mayor a menor según el tamaño de su nombre:

Sort sort = JpaSort.unsafe(Sort.Direction.DESC, "LENGTH(name)");

Cualquier cosa que contenga la cadena pasada como argumento a unsafe se inserta en la consulta JPQL final generada por Spring Data JPA. En el proyecto del curso, Hibernate genera para la sentencia anterior este SQL:

order by character_length(c1_0.name) desc

Sort tipado con TypedSort<T>

Hay un elemento de inseguridad en las ordenaciones que hemos escrito: los atributos de las clases se indican con una cadena. Si nos equivocamos en su escritura, no detectaremos el error hasta que ejecutemos el código.

Nota. Si quieres constantes con los nombres de los atributos de las entidades, no las crees a mano. El plugin del metamodelo de JPA las crea automáticamente. Las tendrás siempre actualizadas y a prueba de refactorizaciones.

Spring Data solventa esta problemática con TypedSort<T>, un «Sort tipado para T«. Consiste en una especialización de Sort declarada como una clase interna, pública y estática de Sort. Su razón de ser es requerir que los campos de ordenación se indiquen con referencias a métodos (method reference) de la clase T, con lo que evitamos escribir cadenas.

Como puedes ver en el siguiente diagrama de clases, TypedSort tiene métodos parecidos a los de Sort que permiten configurar la ordenación de manera fluida.

Construyes un objeto de TypedSort su constructor es privado con el método estático Sort#sort.

public static TypedSort sort(Class type)

Un ejemplo de ordenación por dos campos:

Sort.TypedSort<ConfederationSummaryRecord> typedSort = Sort.sort(ConfederationSummaryRecord.class);
Sort sort = typedSort.by(ConfederationSummaryRecord::countries)
    .descending()
    .and(typedSort.by(ConfederationSummaryRecord::name));

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

El objeto typedSort exige establecer como campos de ordenación atributos de la clase ConfederationSummaryRecord. Se indican proporcionando su método de acceso como argumento de by.

Aunque el código compila, no podrás ejecutarlo debido a este error:

java.lang.IllegalArgumentException: Type to record invocations on must not be final

El mensaje es claro: el tipo (clase) no puede ser final. ¿Qué sentido tiene esta extraña restricción? Resulta que los objetos de TypedSort necesitan internamente un objeto proxy de la clase de su tipado. Spring es incapaz de crear el proxy si su clase es final, y, como quizás ya sepas, los records son clases finales. Por ello, voy a declarar este DTO para usarlo con TypedSort:

package com.danielme.springdatajpa.model.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class ConfederationSummaryDTO {
    private final Long id;
    private final String name;
    private final Long countries;

}

Ahora todo funciona:

Sort.TypedSort<ConfederationSummaryDTO> typedSort = Sort.sort(ConfederationSummaryDTO.class);
Sort sort = typedSort.by(ConfederationSummaryDTO::getCountries)
            .descending()
            .and(typedSort.by(ConfederationSummaryDTO::getName));

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

Fíjate que el resultado de la consulta es ConfederationSummaryRecord en vez de ConfederationSummaryDTO (no existe relación alguna entre el tipado de typedSort y el del resultado de getSummaryCountryCount). Dado que esta dualidad resulta confusa, en este ejemplo aconsejo descartar el record en favor del DTO. Ambas clases, al tener los mismos atributos, son intercambiables a la hora de recoger los resultados con solo ajustar la definicion de la consulta. En getSummaryCountryCount tendrías que cambiar el constructor en la SELECT:

@Query("""
            SELECT new com.danielme.springdatajpa.model.dto.ConfederationSummaryRecord(

Prioridad de ordenaciones

¿Qué sucede si aplicamos Sort a una consulta que ya tiene una ordenación declarada? Buena pregunta. Cambiemos la consulta de las últimas pruebas por esta:

 @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
            ORDER BY countries DESC""")

La sentencia JPQL contiene la ordenación por el número de países. Solicitemos la del nombre en un Sort:

confederationRepository.getSummaryCountryCount(Sort.by("name"));

¡Funciona! Spring Data JPA combina las dos ordenaciones, añadiendo la del objeto Sort a la establecida dentro de la propia consulta. Eso sí, cuando aparezcan criterios repetidos, las duplicidades no se eliminan.

El comportamiento anterior también se da en las consultas derivadas.

Un detalle curioso

Para cerrar el capítulo, te invito a revisar en la bitácora las consultas SQL que Hibernate genera para los ejemplos basados en consultas JPQL:

select c1_0.confederation_id,c2_0.name,count(c1_0.id) 
from countries c1_0 join confederations c2_0 on c2_0.id=c1_0.confederation_id 
group by c1_0.confederation_id,c2_0.name order by 3 desc,2 asc

¿Ves algo que te llame la atención? La cláusula ORDER BY se refiere a los elementos de la proyección por su posición en vez de por su nombre. Se trata de una característica de SQL poco conocida, y no me sorprende: la consulta es menos legible y, sobre todo, frágil, porque un cambio en la SELECT puede romper la ordenación si no se revisan las posiciones. En cualquier caso, es una consulta generada de forma automática, así que no te preocupes.

Resumen

Las claves del capítulo:

  • Consigues ordenación dinámica agregando un parámetro Sort al método que presenta una consulta. Contiene un conjunto de criterios de ordenación basados en atributos de entidades y alias de elementos proyectados.
  • Creas objetos de tipo Sort con métodos estáticos by y\o de manera fluida con la ayuda de otros métodos estáticos.
  • Sort almacena cada criterio de ordenación en un Sort.Order. Los métodos anteriores ya los crean, pero puedes crearlos con constructores y\o varios métodos estáticos.
  • Es posible emplear como campo de ordenación el resultado de una función gracias a JpaSort#unsafe.
  • La precedencia de los campos nulos contemplada por Sort.Order no funciona para JPA. Con Hibernate puedes hacer un par de cosas al respecto.
  • TypedSort<T> es una clase hija de Sort tipada para una clase T. Te obliga a usar referencias a métodos de T para establecer los campos de ordenación, lo cual resulta más seguro que usar cadenas.

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

Spring JDBC Template: simplificando el uso de SQL

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 )

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.