Curso Spring Data JPA. 3: repositorios

logo spring

Spring Data se fundamenta en el concepto de repositorio. Ya vimos una pequeña y sorprendente muestra al final del primer capítulo. En el presente exploraremos este concepto poniendo el foco en el mundo JPA.


Índice

  1. El primer repositorio
  2. Repositorios genéricos predefinidos
    1. CrudRepository
    2. Probando CrudRepository
    3. JpaRepository
  3. Personalizar los repositorios genéricos
  4. Crea tus propios repositorios genéricos
  5. Resumen
  6. Código de ejemplo


El primer repositorio

En Spring Data, un repositorio es una abstracción que proporciona las operaciones relativas a una clase de dominio para interactuar con un almacén de datos. Pero no de cualquier manera; a fin de cuentas, esta definición es trasladable a las clases DAO de toda la vida. El gran objetivo es reducir al máximo el código a escribir.

He aquí el repositorio más simple posible para la clase de tipo entidad Country del proyecto de ejemplo:

package com.danielme.springdatajpa.repository.basic;

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

public interface CountryRepository extends Repository<Country, Long> {

}

Lo primero a destacar es la manera en la que declaramos el repositorio: con una interfaz que herede de Repository. La tipamos con la clase del objeto del dominio que gestiona el repositorio, en nuestro caso Country, y la de su identificador. Se ve más claro con el código de Repository:

package org.springframework.data.repository;

import org.springframework.stereotype.Indexed;

@Indexed
public interface Repository<T, ID> {

}

El propósito de la interfaz es informar a Spring que las interfaces que la especialicen representan repositorios. No se requiere nada más, como aplicar alguna anotación o seguir una convención a la hora de nombrar los repositorios. Aun así, voy a respetar la praxis habitual consistente en usar el nombre de la clase de la entidad con el sufijo Repository.

Como alternativa, podemos definir un repositorio anotando una interfaz con @RepositoryDefinition:

@RepositoryDefinition(domainClass = Country.class, idClass = Long.class)
public interface CountryRepository {
}

Ten cuidado con la anotación @Repository. No tiene nada que ver con Spring Data. Es un subtipo de @Component que sirve para marcar como beans de Spring clases que contienen operaciones de accesos a fuente de datos.

Spring Data es capaz de encontrar y configurar todos los repositorios, siempre y cuando esté activada esa opción. Es el caso de Spring Boot y, por ende, del proyecto de ejemplo. Cuando no sea así, activaremos la detección y configuración de los repositorios marcando con @EnableJpaRepositories una clase de configuración (clase marcada con @Configuration o @SpringBootApplication). Por omisión, Spring buscará los repositorios a partir del paquete de la clase con esa anotación. La propiedad basePackages permite indicar los paquetes raíz.

@Configuration
@EnableJpaRepositories(basePackages="com.danielme.springdatajpa.repository")
class JpaConfiguration {}

Si el repositorio es una interfaz, ¿dónde la implementamos? En ningún sitio. Spring genera en tiempo de ejecución una implementación como un objeto proxy. Comprobémoslo inyectando CountryRepository en una prueba:

package com.danielme.springdatajpa.repository.basic;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
@Slf4j
class CountryRepositoryTest {

    @Autowired
    private CountryRepository countryRepository;

    @Test
    void testRepository() {
        log.info(countryRepository.toString());
    }

}

Ejecutando el test desde nuestro IDE en modo depuración con un punto de ruptura o breakpoint, veremos la realidad del objeto CountryRepository.

Sea como fuere, CountryRepository no sirve de nada, pues carece de métodos. Aprenderemos a escribir consultas en futuros capítulos. Antes, dedicaremos el resto del actual a revisar los repositorios genéricos que Spring Data y Spring Data JPA ofrecen de serie.

Repositorios genéricos predefinidos

CrudRepository

Será habitual que muchas entidades precisen de un conjunto de operaciones denominadas CRUD: creación, lectura, actualización y borrado. El acrónimo viene del inglés (Create, Read, Update, Delete)

Spring Data proporciona un subtipo de Repository con el elocuente nombre de CrudRepository. Define doce métodos que, para mayor seguridad, respetan el tipado de la entidad y de su identificador.

Esta es la declaración de la interfaz:

@NoRepositoryBean
public interface CrudRepository<T, ID> extends Repository<T, ID> {

La anotación @NoRepositoryBean evita que Spring cree un objeto proxy para este repositorio. Asimismo, para que CrudRepository sea un repositorio genérico, no está tipado; en su lugar respeta el tipado genérico (T, ID) de Repository. Todo lo anterior conlleva que CrudRepository siempre forme parte de una jerarquía de interfaces-repositorio que tenga como elemento final un repositorio concreto para un objeto de dominio.

CrudRepository ofrece los siguientes métodos de lectura:

Optional<T> findById(ID id) Obtiene una entidad dado su identificador. Cuando no exista devuelve un opcional vacío.
boolean existsById(ID id)Indica si una entidad existe. Si solo queremos comprobarlo y no necesitamos la entidad, existsById es más legible y eficiente que findById.
Iterable<T> findAll()Recupera todas las entidades. Usar este método es peligroso porque si hay muchas nos traeremos una colección inmensa de objetos. Debemos obtener las entidades en pequeños lotes con paginación, tal y como analizaremos en el capítulo nueve.
Iterable<t> findAllById(Iterable<T> ids)Obtiene un lote de entidades a partir de sus identificadores únicos (no se admiten nulos, se lanzará NullPointerException). Las que no existan no aparecerán en los resultados, ni siquiera como nulas.
long count()Cuenta el número total de entidades. Espero que nadie llame a findAll y cuente 🤦

Estos son los métodos de escritura:

<S extends T> S save (S entity)Guarda la entidad, tanto si es de nueva creación como si ya existe.
<S extends T> Iterable<S> saveAll(Iterable<S> entities)Guarda las entidades solicitadas. En el caso de JPA, se llama a save para cada una de ellas.
void deleteById(ID id)Elimina la entidad según su identificador. Si no existe, se lanza EmptyResultDataAccessException. Deberíamos ejecutar antes existsById para chequear la existencia. O mejor todavía, recurrir a otras alternativas que se propondrán en el curso.
void delete(T entity)Elimina la entidad. Si no existe, no hace nada.
void deleteAllById(Iterable<? extends ID> ids)Elimina las entidades indicadas con sus identificadores únicos (no se admiten nulos, se lanzará NullPointerException). La implementación de JPA invoca deleteById para cada identificador.
void deleteAll(Iterable<? extends T> entities)Elimina las entidades recibidas como argumentos (no se admiten nulos). En JPA se ejecuta delete para cada identificador.
void deleteAll()¡Lo borra todo! Además de peligrosa, es una operación ineficiente si hay muchas entidades porque las obtiene con findAll y luego llama a delete para cada una.

¿Ves algo llamativo en los parámetros y retornos de estos métodos? En lugar de listas o colecciones, los agregados de datos son objetos de tipo Iterable, una abstracción de nivel superior (la interfaz Collection hereda de ella). El motivo es ser lo menos restrictivos posible con el fin de facilitar la implementación de CrudRepository por parte de los módulos de Spring Data.

Este tipo de decisiones son habituales cuando se diseñan librerías y marcos de trabajo genéricos; sin embargo, en proyectos finales lo común será que utilicemos listas. Por ello, Spring Data 3.0 introdujo la siguiente interfaz.

public interface ListCrudRepository<T, ID> extends CrudRepository<T, ID> {
    <S extends T> List<S> saveAll(Iterable<S> entities);

    List<T> findAll();

    List<T> findAllById(Iterable<ID> ids);
}

ListCrudRepository especializa a CrudRepository para sobrescribir los métodos que devuelven Iterable de tal modo que retornen listas.

En cualquier caso, ¿cómo disfrutamos de los métodos de CrudRepository? Tan fácil como heredar de ella:

package com.danielme.springdatajpa.repository;

import com.danielme.springdatajpa.model.Country;
import org.springframework.data.repository.CrudRepository;

public interface CountryCrudRepository extends CrudRepository<Country, Long> {
}
package com.danielme.springdatajpa.repository;

import com.danielme.springdatajpa.model.Confederation;
import org.springframework.data.repository.CrudRepository;

public interface ConfederationCrudRepository extends CrudRepository<Confederation, Long> {

}

¡Voilà! Ahora contamos con las operaciones de CrudRepository para las entidades Country y Confederation.

Probando CrudRepository

Usemos los repositorios en una nueva clase de prueba:

@SpringBootTest
class CountryCrudRepositoryTest {

    @Autowired
    private CountryCrudRepository countryRepository;

    @Autowired
    private ConfederationCrudRepository confederationRepository;

    @Test
    void testCreate() {
        Country country = new Country();
        country.setName("Cuba");
        country.setPopulation(11113215);
        country.setOcde(false);
        country.setCapital("Havana City");
        country.setUnitedNationsAdmission(LocalDate.of(1945, 10, 24));
        Confederation concacaf = confederationRepository.findById(CONCACAF_ID).get();
        country.setConfederation(concacaf);

        countryRepository.save(country);

        assertThat(country.getId()).isNotNull();
    }

}

testCreate añade un país a la base de datos. Country requiere de la confederación futbolística correspondiente al país y que obtenemos con ConfederationCrudRepository. Solicitamos el guardado llamando a save. Tras la operación, el objeto Country contiene el identificador que la base de datos le asignó, hecho que corrobora la última línea.

El método save guarda una entidad sin importar si es nueva o una ya existente. Spring Data JPA decidirá si llamar al método merge o al persist del gestor de entidades (*). Debes saber que persist siempre es reemplazable por merge, pero aquí te explico un caso en el que se desaconseja hacerlo. De todas formas, con Spring Data JPA es un detalle irrelevante.

(*) Si no estás familiarizado con el funcionamiento del gestor de entidades (la interfaz que ofrece las operaciones básicas de JPA), te recomiendo este artículo:

Aunque Spring Data JPA entierra el gestor bajo sus abstracciones, es fundamental que comprendas su funcionamiento si quieres hacer un uso responsable de JPA.

Volvamos a nuestras pruebas. Actualicemos la población de España:

@Test
void testUpdatePopulation() {
    Country country = countryRepository.findById(DatasetConstants.SPAIN_ID).get();
    int newPopulation = 47432805;
    country.setPopulation(newPopulation);

    countryRepository.save(country);

    Country countryAfterSave = countryRepository.findById(DatasetConstants.SPAIN_ID).get();
    assertThat(countryAfterSave.getPopulation()).isEqualTo(newPopulation);
}

Obtenemos la entidad, la editamos y solicitamos su guardado con save. Todo muy lógico, de hecho el test es válido; pero a veces la llamada a save es innecesaria. Lo veremos en el próximo capítulo.

La ejecución de las pruebas anteriores tiene un efecto colateral que afectará a las demás: modifican los registros almacenados en la base de datos. Todos los tests deben ser independientes y ejecutables en cualquier orden.

Lo que haré será vaciar las tablas y volver a cargar los registros definidos en el fichero data.sql tras la ejecución de pruebas que alteren los datos. Aunque parece una tarea complicada, gracias a la anotación @Sql de Spring es bien sencillo:

@Sql(value = {"/reset.sql", "/data.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)

@Sql ejecuta en orden los scripts /src/test/resources/reset.sql y /src/test/resources/data.sql después de la prueba marcada con ella (Sql.ExecutionPhase.AFTER_TEST_METHOD). El valor predeterminado de executionPhase es ExecutionPhase.BEFORE_TEST_METHOD (antes de la prueba).

@Sql también puede marcar una clase de prueba para que se aplique a todos los tests que contenga.

@SpringBootTest
@Sql(value = {"/reset.sql", "/data.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
class CountryCrudRepositoryTest {

Sin duda, lo más cómodo sería asegurar la ejecución de ambos scripts anotando con @Sql todas las clases de prueba. Dicho esto, solo usaré @Sql cuando sea imprescindible, ya que no quiero demorar la ejecución de las pruebas.

JpaRepository

Los módulos de Spring Data suelen incluir repositorios genéricos con funcionalidades específicas de la tecnología que tratan. En Spring Data JPA encontramos JpaRepository.

¡El diagrama se complica! Calma. JpaRepository es un tipo de ListCrudRepository con métodos adicionales. Varios de ellos deben obviarse porque están marcados obsoletos. También hereda de ListPagingAndSortingRepository y QueryByExampleExecutor. Dado que trataremos ambas interfaces en futuros capítulos, vamos a ignorarlas.

Antes de Spring Data 3, JpaRepository heredaba de CrudRepository y PagingAndSortingRepository en vez de ListCrudRepository y ListPagingAndSortingRepository. Por ello contaba con estos métodos que sobrescribian a los declarados en las interfaces «padre» para que devolvieran List en lugar de Iterable:

List<T> findAll();
List<T> findAllById(Iterable<ID> ids);
<S extends T> List<S> saveAll(Iterable<S> entities);

Existe un método para recuperar una entidad que se limita a invocar al getReference del gestor de entidades:

T getReferenceById(ID id)

La «referencia» retornada consiste en un objeto proxy que representa a la entidad y que solo contiene su identificador. Por tanto, la creación de la referencia no implica una consulta a la base de datos que obtenga todos los datos de la entidad. Eso sí, cuando pidamos al proxy cualquier atributo distinto del identificador, se obtendrá el registro desde la tabla.

Estamos ante una operación útil para afinar el rendimiento en ciertos escenarios. El más evidente que se me ocurre es el establecimiento de una relación simple, tarea que hicimos en el método CountryCrudRepositoryTest#testCreate con estas líneas:

Confederation concacaf = confederationRepository.findById(CONCACAF_ID).get();
country.setConfederation(concacaf);

Date cuenta de que solo queremos la entidad representada por el objeto concacaf para relacionarla con country. A su vez, lo único que JPA precisa de concacaf es su identificador porque será la clave ajena a la tabla confederations del nuevo registro en countries correspondiente a country. Por este motivo es un desperdicio recuperar la entidad concacaf completa desde la base de datos con findById, acción que implica ejecutar una SELECT. Es suficiente con el objeto «referencia» que contiene el identificador:

Confederation concacaf = confederationRepository.getReferenceById(CONCACAF_ID);
country.setConfederation(concacaf);

La operación flush del gestor de entidades fuerza la sincronización inmediata de las entidades del contexto de persistencia con la base de datos. Será raro que tengamos que recurrir a este método, pues Hibernate efectúa la sincronización automáticamente. Lo comprobaremos en el próximo capítulo.

void flush()Es lo que parece: invoca al método flush.
<S extends T> List<S> saveAll(Iterable<S> entities) Llama a save para cada entidad.
<S extends T> List<S> saveAllAndFlush(Iterable<S> entities)Invoca saveAll para aplicar save a cada entidad. Tras ello, llama a flush.

Otro grupo de métodos de JpaRepository son las operaciones de borrado en lote (batch):

void deleteAllInBatch();
void deleteAllInBatch(Iterable<T> entities);
void deleteAllInBatch();

Parecen más sofisticadas de lo que son. Borran las entidades en una sola sentencia DELETE de SQL. Si bien esto es más rápido que hacerlo de una en una llamando al remove del gestor de entidades, hay un inconveniente que puede ser importante: las entidades que ya estuvieran en el contexto de persistencia no se ven afectadas por el borrado. Esto significa que los métodos de tipo @PreRemove y @PostRemove asociados a esas entidades se ignoran. Tampoco se propaga la operación de borrado hacia las entidades relacionadas aunque así lo hayamos establecido en la configuración de la clase de las entidades que estamos eliminando.

Personalizar los repositorios genéricos

A priori, las operaciones de los repositorios genéricos como CrudRepository nos vienen muy bien. El problema es que cuando heredamos de CrudRepository, o de cualquier interfaz, es imposible excluir métodos; es todo o nada. En consecuencia, es posible que agreguemos a nuestros repositorios operaciones innecesarias o, peor aún, que no queremos. Por ejemplo, alerté párrafos atrás acerca del empleo de findAll y deleteAll.

Por fortuna, podemos incorporar en un repositorio métodos de los repositorios genéricos con tan solo copiar su signatura. En realidad estamos declarando «consultas derivadas», tema del capítulo cinco.

Imagina que deseamos que el repositorio de las confederaciones ofrezca algunos métodos de consulta de CrudRepository. No queremos el resto, luego heredar de CrudRepository no es una opción. Así que heredamos de Repository y copiamos los métodos de CrudRepository que necesitemos:

public interface ConfederationCustomCrudRepository extends Repository<Confederation, Long> {

    Optional<Confederation> findById(Long id);

    boolean existsById(Long id);

    long count();

}

Deseo concedido 🧞‍♂️✨

Crea tus propios repositorios genéricos

Si tuviéramos varias clases de tipo entidad en la situación de Confederation (deben contar con algunos métodos de lectura de CrudRepository), lo más práctico será crear un repositorio genérico reutilizable a imagen y semejanza de CrudRepository. Esto es, uno anotado con @NoRepositoryBean y tipado para las clases T (la entidad) e ID (el identificador):

@NoRepositoryBean
public interface ReadCommonRepository<T, ID> extends Repository<T, ID> {

   Optional<T> findById(ID id);

    boolean existsById(ID id);

    long count();
}

Ahora, usaremos ReadCommonRepository como el repositorio padre de aquellos que precisen de sus operaciones:

public interface ConfederationReadRepository extends ReadCommonRepository<Confederation, Long> {
}

Puedes crear los repositorios genéricos que consideres oportunos. Contendrán cualquier tipo de operación permitida por Spring Data JPA.

Resumen

Recopilo los fundamentos del capítulo:

  • Un repositorio es una interfaz que hereda de la interfaz Repository y que está tipada para un tipo de entidad y su identificador. Otra posibilidad es usar la anotación @RepositoryDefinition.
  • Existen repositorios genéricos con operaciones predefinidas listos para ser heredados. También puedes copiar de ellos los métodos que quieras en tus repositorios.
  • Puedes crear tus propios repositorios genéricos.
  • Ten cuidado si heredas de otros repositorios, no sea que recibas métodos que no quieres. Por ejemplo, para la mayoría de entidades no querrás findAll (sin paginación) y deleteAll.

Código de ejemplo

El proyecto se encuentra en GitHub. Se explica en el capítulo dos. Para más información sobre cómo utilizar GitHub, consultar este artículo.



Otros tutoriales relacionados con Spring

Introducción a Spring Boot: API REST y Spring Data JPA

Spring Boot: Gestión de errores en aplicaciones web y REST

Testing en Spring Boot con JUnit 4 \ 5. Mockito, MockMvc, REST Assured, bases de datos embebidas

Spring JDBC Template: simplificando el uso de SQL

Testing Spring Boot: Docker con Testcontaines y JUnit 5

Spring Security \ Spring Boot: Segurización API REST con BASIC y base de datos (SQL y Spring Data).

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.