06 - JPA

Hasta ahora, hemos utilizado JdbcTemplate para conectarnos a la bbdd y tratar los datos, aunque no es la forma más habitual de hacerlo.

La persistencia de datos en aplicaciones es un aspecto fundamental para el desarrollo de sistemas robustos y escalables. En el entorno de Java, JPA (Java Persistence Api) ha surgido como una solución estándar para el mapeo objeto-relacional (ORM), ofreciendo a los desarrolladores una forma eficiente y coherente de interactuar con bases de datos relacionales utilizando objetos Java.

JPA facilita la representación de entidades de persistencia como objetos en el código Java, permitiendo su almacenamiento y recuperación en una base de datos de manera transparente. Al abstraer la lógica de persistencia, JPA simplifica considerablemente el manejo de la capa de acceso a datos, promoviendo un código más limpio, mantenible y portable entre diferentes proveedores de bases de datos.

JPA define la gestión de datos en aplicaciones mediante la representación de objetos en una base de datos relacional. JPA proporciona un conjunto de interfaces y clases abstractas que permiten a los desarrolladores interactuar con la base de datos de una manera orientada a objetos, sin tener que preocuparse por detalles específicos de la implementación subyacente de la base de datos.

Existen diferentes implementaciones de JPA, entre las que destacan:

  • Hibernate: Es una de las implementaciones JPA más populares. Ofrece funcionalidades avanzadas y flexibilidad en el mapeo objeto-relacional, además de herramientas adicionales que simplifican el desarrollo y la gestión de la base de datos.

  • EclipseLink: Otra implementación JPA robusta, potente y de alto rendimiento. Ofrece características de mapeo avanzadas, soporte para estándares JPA y herramientas de persistencia útiles.

  • Apache OpenJPA: Una implementación JPA que proviene del proyecto OpenJPA de Apache. Proporciona funcionalidades completas de JPA, cumpliendo con los estándares de la API.

Estas implementaciones ofrecen funcionalidades similares en términos de mapeo objeto-relacional y operaciones CRUD, pero pueden diferir en sus características específicas, herramientas adicionales y rendimiento en ciertos contextos.

Al utilizar JPA, los desarrolladores pueden escribir código independiente de la base de datos subyacente, lo que les permite cambiar de proveedor de bases de datos sin tener que modificar considerablemente el código de la aplicación, lo que resulta en sistemas más flexibles y mantenibles a largo plazo.

En Spring Boot tenemos opciones para trabajar con JPA. Si bien Spring Data JPA es una forma común y conveniente de comenzar, podemos personalizar el enfoque dependiendo de las necesidades específicas del proyecto.

Por ejemplo, además de Spring Data JPA, podríamos optar por configurar JPA manualmente agregando las dependencias necesarias de forma individual. Esto nos ofrece un mayor control sobre cada componente que utilizamos.

Además, podemos elegir implementaciones específicas de JPA según nuestras preferencias o requisitos del proyecto. Aunque Hibernate es la implementación JPA predeterminada en Spring Boot, podemos optar por otras implementaciones como EclipseLink o Apache OpenJPA.

En nuestro caso, vamos a utilizar la configuración común de JPA en un proyecto Spring. Al incluir Spring Data JPA en un proyecto Spring Boot, obtenemos una configuración predeterminada que facilita la interacción con bases de datos utilizando JPA.

Este starter no solo proporciona Spring Data JPA, sino que también configura automáticamente otros componentes esenciales como Spring JDBC, Spring Transactions, Spring AOP y Spring Aspects, si se requieren para el contexto de persistencia de datos.

Como siempre, lo primero que tenemos que hacer es añadir la dependencia de JPA a nuestro archivo pom.xml:

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-jpa</artifactId>
		</dependency>

Ahora ya estamos listos para usar JPA.

En nuestro archivo application.properties ya tenemos la configuración de la conexión a la bbdd, con lo que no habría que tocar nada. En cualquier caso, vamos a añadir un par de opciones que nos serán útiles:

spring.datasource.url=jdbc:mariadb://localhost:3306/movies
spring.datasource.username=root
spring.datasource.password=root
spring.jpa.hibernate.ddl-auto=none
# Habilitar logs de consultas SQL
spring.jpa.show-sql=true

La propiedad spring.jpa.hibernate.ddl-auto es una configuración en aplicaciones Spring que determina cómo Hibernate maneja la creación y actualización de la estructura de la base de datos. Tiene varios valores que permiten definir cómo se realiza esta gestión:

  • none: Este valor desactiva cualquier acción automática de creación o actualización de la base de datos por parte de Hibernate. Con este modo, Hibernate no hace ningún cambio en la estructura de la base de datos existente.

  • create: Al utilizar este valor, Hibernate creará la estructura de la base de datos si no existe. Si la base de datos ya está presente, Hibernate la eliminará y la volverá a crear, lo que conlleva la pérdida de datos existentes.

  • create-drop: Similar a create, Hibernate crea la estructura de la base de datos si no existe. Sin embargo, al cerrar la sesión de Hibernate o detener la aplicación, Hibernate eliminará la base de datos, lo que puede ser útil para entornos de desarrollo o pruebas.

  • update: Este valor indica a Hibernate que actualice la estructura de la base de datos según los cambios en las entidades. No elimina los datos existentes, pero puede modificar o eliminar columnas, tablas, etc., para reflejar los cambios en el modelo de datos.
En nuestro caso, estamos conectándonos a una bbdd que ya existe. Si quisiésemos que JPA creara la bbdd de forma automática, tendríamos que cambiar el valor de spring.jpa.hibernate.ddl-auto a create, por ejemplo.

A veces, tu configuración del SGBD no permite crear la bbdd de forma automática. Una solución sencilla es crear la bbdd desde fuera y ejecutar Spring para crear las tablas.

Aunque Hibernate puede inferir el dialecto de la base de datos basándose en la URL de conexión, especificar explícitamente el dialecto a través de spring.jpa.properties.hibernate.dialect es una práctica recomendada para asegurar una configuración precisa y completa. Por ejemplo, en nuestro caso podríamos añadir:

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MariaDBDialect
spring.jpa.hibernate.ddl-auto=create

La última línea (spring.jpa.show-sql=true) es una configuración que se utiliza en aplicaciones Spring con Hibernate como implementación JPA. Cuando se establece a true, esta propiedad le indica a Hibernate que imprima las consultas SQL generadas por la aplicación en la consola, lo que nos puede ser útil mientras estamos en desarrollo.

Vamos a añadir un nuevo package dao.db.jpa con la implementación JPA de nuestros DAOs. Dentro, tendremos (además de las implementaciones concretas) otros 3 packages:

  • dao.db.jpa.entity: Entidades JPA
  • dao.db.jpa.mapper: Mapeadores entre las entidades JPA y nuestro modelo de dominio
  • dao.db.jpa.repository: Repositorios JPA

JPA utiliza sus propias entidades que mapean una tabla de la bbdd. Para ello, sólo tenemos que añadir la anotación @Entity a nuestras entidades.

Para distinguir estas entidades de nuestros modelos de dominio les añadiremos Entity al nombre. Así, por ejemplo, la entidad de libros quedaría:

@Entity
@Data
@NoArgsConstructor
public class BookEntity {

JPA intentará conectar la entidad con la tabla correspondiente. El problema es que nuestras entidades llevan el sufijo Entity, con lo que no encontrará ninguna tabla llamada BookEntity. Por suerte, la solución es muy sencilla: indicarle el nombre de la tabla mediante la anotación @Table:

@Entity
@Table(name = "books")
@Data
@NoArgsConstructor
public class BookEntity {

Lo siguiente será añadir anotaciones a nuestros campos para indicar algunas propiedades para que JPA sea capaz de mapear directamente desde la bbdd. En primer lugar, añadiremos @ID y @GeneratedValue.

La anotación @GeneratedValue en JPA, junto con @Id, se utiliza para especificar cómo se generan los valores para una clave primaria en una entidad persistente. En particular, @GeneratedValue se usa para indicar la estrategia que se utilizará para generar los valores de las claves primarias de manera automática por parte de la base de datos.

La estrategia GenerationType.IDENTITY especifica que la generación de valores de clave primaria se realizará utilizando una columna de identidad de la base de datos, lo que significa que la base de datos se encargará de generar automáticamente valores únicos para la clave primaria cuando se inserten nuevas filas en la tabla asociada a la entidad:

@Entity
@Table(name = "books")
@Data
@NoArgsConstructor
public class BookEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String isbn;
    private String titleEs;
    ...

Fíjate en el campo titleEs. JPA no es capaz de asociar el atributo a un campo de la tabla. Normal, ya que en la bbdd el campo se llama title_es. Por suerte, contamos con la anotación @Column para indicarle el nombre de la columna en la bbdd:

@Entity
@Table(name = "books")
@Data
@NoArgsConstructor
public class BookEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String isbn;
    @Column(name = "title_es")
    private String titleEs;
    ...

De esta forma, nuestra entidad BookEntity quedaría:

@Entity
@Table(name = "books")
@Data
@NoArgsConstructor
public class BookEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String isbn;
    @Column(name = "title_es")
    private String titleEs;
    @Column(name = "title_en")
    private String titleEn;
    @Column(name = "synopsis_es")
    private String synopsisEs;
    @Column(name = "synopsis_en")
    private String synopsisEn;
    private BigDecimal price;
    private float discount;
    private String cover;

    private PublisherEntity publisher;
    private CategoryEntity category;
    private List<AuthorEntity> authors;
    private List<GenreEntity> genres;
}

Y, por ejemplo, CategoryEntity:

@Entity
@Table(name = "categories")
@Data
@NoArgsConstructor
public class CategoryEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(name = "name_es")
    private String nameEs;
    @Column(name = "name_en")
    private String nameEn;
    private String slug;
}

En JPA podemos representar relaciones entre entidades para facilitar el tratamiento de datos. Para hacerlo, contamos con una serie de anotaciones según el tipo de relación que queramos representar:

  • @OneToOne: Define una relación uno a uno entre dos entidades.
  • @OneToMany: Define una relación de uno a muchos, donde una entidad tiene una asociación con múltiples entidades de otro tipo.
  • @ManyToOne: Establece una relación muchos a uno, indicando que varias entidades de una clase están relacionadas con una única entidad de otra clase.
  • @ManyToMany: Define una relación muchos a muchos entre dos entidades, lo que implica que una entidad puede estar asociada con múltiples entidades de otro tipo y viceversa.

En general, no queremos relaciones bidireccionales, con lo que añadiremos las relaciones sólo un sentido. En nuestro caso, será BookEntity la que definirá las relaciones con el resto de entidades.

Empecemos con las relaciones Publisher y Category. Ambas serán de tipo @ManyToOne y tendremos que indicar el campo de enganche (la clave ajena en la tabla books). Para eso, utilizaremos @JoinColumn;

@Entity
@Table(name = "books")
@Data
@NoArgsConstructor
public class BookEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    ...
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "publisher_id")
    private PublisherEntity publisher;
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id")
    private CategoryEntity category;
    private List<AuthorEntity> authors;
    private List<GenreEntity> genres;
}

Fíjate en el código de arriba. Hemos añadido la opción fetch = FetchType.LAZY para indicar que queremos que ese campo se recupere con carga perezosa (lazy loading). Más adelante profundizaremos en el lazy loading.

Vamos ahora con las relaciones con autores y géneros. En este caso, el tipo es @ManyToMany. En este tipo de relaciones tenemos que indicarle la tabla de enganche y las claves ajenas de dicha tabla. Para eso, utilizamos la anotación @JoinTable:

@Entity
@Table(name = "books")
@Data
@NoArgsConstructor
public class BookEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    ...
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "publisher_id")
    private PublisherEntity publisher;
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id")
    private CategoryEntity category;
    @ManyToMany(fetch = FetchType.LAZY)
    @JoinTable(
            name = "books_authors",
            joinColumns = @JoinColumn(name = "book_id"),
            inverseJoinColumns = @JoinColumn(name = "author_id")
    )
    private List<AuthorEntity> authors;
    @ManyToMany(fetch = FetchType.LAZY)
    @JoinTable(
            name = "books_genres",
            joinColumns = @JoinColumn(name = "book_id"),
            inverseJoinColumns = @JoinColumn(name = "genre_id")
    )
    private List<GenreEntity> genres;
}

En Spring Data JPA, para poder operar con los datos necesitamos definir Repositorios JPA (no confundir con nuestros repositorios). Básicamente se tratan de interfaces que heredan de JpaRepository, dónde tenemos que indicar la entidad del repositorio y el tipo de la clave principal:

public interface BookJpaRepository extends JpaRepository<BookEntity, Long> {

}

Spring Data JPA simplifica enormemente la interacción con la base de datos al proporcionar una implementación predeterminada de métodos CRUD en la interfaz JpaRepository. Cuando extiendes esta interfaz para crear tu repositorio, obtienes métodos como save, findById, findAll, delete, count, entre otros, listos para ser usados sin necesidad de escribir la implementación de cada uno.

Además de estos métodos predefinidos, puedes definir métodos en tu interfaz de repositorio siguiendo una convención de nombres específica. Spring Data JPA analiza el nombre del método y genera consultas SQL correspondientes automáticamente.

Por ejemplo, nuestro método para buscar libros por ISBN sería:

public interface BookJpaRepository extends JpaRepository<BookEntity, Long> {
    BookEntity findByIsbn(String isbn);

}

Spring Data JPA interpretará este método y generará una consulta SQL de forma automática para buscar libros por ISBN. La convención de nombres juega un papel crucial aquí: el prefijo findBy indica que quieres buscar entidades por un campo específico (isbn en este caso). Spring Data JPA analiza el nombre del método, separa findBy, interpreta Isbn como el nombre del campo y genera la consulta correspondiente.

Esta abstracción ahorra tiempo y esfuerzo al eliminar la necesidad de escribir consultas SQL repetitivas. En lugar de preocuparte por la sintaxis SQL, puedes centrarte en definir métodos descriptivos en tu interfaz de repositorio para manejar las consultas de manera más intuitiva y enfocarte en la lógica de tu aplicación.

Ten en cuenta que para que ésto funcione tienes que seguir la nomenclatura de Spring Data JPA para los métodos. Aquí puedes ver una referencia de esa nomenclatura.
Otra cosa importante es que los métodos findBy en Spring Data JPA devuelven un Optional en lugar de una instancia directa de la entidad.

El siguiente paso será crear una nueva implementación de nuestro DAO para que utilice ese repositorio. Pero antes, necesitamos mapeadores para mapear entidades JPA a nuestros modelos y viceversa:

@Mapper(uses = {PublisherJpaMapper.class, AuthorJpaMapper.class, GenreJpaMapper.class, CategoryJpaMapper.class})
public interface BookJpaMapper {

    BookJpaMapper INSTANCE = Mappers.getMapper(BookJpaMapper.class);

    Book toBookWithDetails(BookEntity bookEntity);

    @Mapping(target = "authors", ignore = true)
    @Mapping(target = "genres", ignore = true)
    @Mapping(target = "publisher", ignore = true)
    @Mapping(target = "category", ignore = true)
    Book toBook(BookEntity bookEntity);

    BookEntity toBookEntity(Book book);

}

En este caso, utlizamos MapStruct para simplificar el código (aunque podríamos también hacerlo a mano o utilizar cualquier otro mapeador). Si te fijas, esta vez tenemos dos mapeadores de BookEntity. Uno con los campos asociados (autores, géneros, editorial y categoría) y otro que los ignora. Esto evitará que JPA intente cargar las entidades asociadas debido al lazy loading.

Ahora ya podemos crear nuestra nueva implementación del DAO, utilizando en las respuestas el mapeador que nos interese:

@Component
@RequiredArgsConstructor
public class BookDaoJpa implements BookDaoDb {

    private final BookJpaRepository bookJpaRepository;

    @Override
    public Optional<Book> findByIsbn(String isbn) {
        return Optional.ofNullable(bookJpaRepository.findByIsbn(isbn))
                .map(BookJpaMapper.INSTANCE::toBookWithDetails);
    }
    ...
    @Override
    public List<Book> getAll() {
        return bookJpaRepository
                .findAll()
                .stream()
                .map(BookJpaMapper.INSTANCE::toBook)
                .toList();
    }
    ...

Si ejecutamos la aplicación ahora, veremos que nos salta un error. Ésto se debe a que tenemos dos implementaciones de nuestra interfaz BookDaoJdbc y Spring no sabe cuál de las dos inyectar en el repositorio:

public class BookDaoJdbc implements BookDaoDb {
   ...
}

public class BookDaoJpa implements BookDaoDb {
   ...
}

public class BookRepositoryImpl implements BookRepository {

    private final BookDaoDb bookDaoDb;
    ...
}

Debemos indicar a Spring qué implementación concreta queremos utilizar. Lo podemos hacer de varias formas, por ejemplo usando Qualifier. En nuestro caso, vamos a utilizar la anotación Primary en una de las implementaciones (en este caso la de JPA) para indicarle a Spring que tendrá preferencia sobre el resto de implementaciones a la hora de inyectarla:

@Component
@Primary
@RequiredArgsConstructor
public class BookDaoJpa implements BookDaoDb {
   ...

Ahora debería funcionar de nuevo la aplicación usando Spring Data Jpa.

Cuando hemos definido nuestra entidad BookEntity, hemos indicado que queremos hacer lazy loading en la carga de entidades relacionadas. Las dos opciones que tenemos son:

  • LAZY (carga perezosa): La carga de la relación se realiza cuando se accede a ella, es decir, los datos relacionados no se cargan inmediatamente cuando se consulta la entidad principal. Solo se cargan cuando se hace explícitamente referencia a la relación (por ejemplo, accediendo a una propiedad de la entidad relacionada). Esto puede mejorar el rendimiento si la relación no se necesita inmediatamente, pero puede llevar a consultas adicionales si se accede a la relación más tarde.
  • EAGER (carga ansiosa o impaciente): La carga de la relación se realiza de forma inmediata cuando se consulta la entidad principal, es decir, los datos relacionados se cargan junto con la entidad principal. Esto puede ser útil cuando se sabe que siempre se necesitarán los datos relacionados, pero puede afectar el rendimiento si no se manejan adecuadamente, especialmente con relaciones complejas o grandes volúmenes de datos.

En nuestro caso, cuando recuperamos un listado de libros sólo queremos los datos básicos, sin las entidades relacionadas, con lo que usamos la primera opción:

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "publisher_id")
    private PublisherEntity publisher;
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id")
    private CategoryEntity category;
    @ManyToMany(fetch = FetchType.LAZY)
    @JoinTable(
            name = "books_authors",
            joinColumns = @JoinColumn(name = "book_id"),
            inverseJoinColumns = @JoinColumn(name = "author_id")
    )
    private List<AuthorEntity> authors;
    @ManyToMany(fetch = FetchType.LAZY)
    @JoinTable(
            name = "books_genres",
            joinColumns = @JoinColumn(name = "book_id"),
            inverseJoinColumns = @JoinColumn(name = "genre_id")
    )
    private List<GenreEntity> genres;

Puede que pienses que tampoco perderíamos mucho rendimiento si recuperáramos los datos del editor y la categoría mediante dos JOINS. En ese caso, podríamos optar por hacer la carga impaciente:

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "publisher_id")
    private PublisherEntity publisher;
    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "category_id")

Esto presenta un par de problemas. Primero, siempre recuperará los datos de la editorial y la categoría, aunque no los necesitemos (por ejemplo, cuando devolvamos un listado de libros).

Además, JPA no garantiza hacer la sentencia con JOINs. De hecho, si miras en la terminal las SQL que ejecuta, verás que realiza 3 consultas. Una para recuperar los libros y otras dos para recuperar la editorial y la categoría.

Ésto se conoce como n+1 Queries, y es típico al desarrollar aplicaciones con frameworks de persistencia, como Hibernate, Spring Data JPA… Para evitarlo tenemos varias opciones. Lo primero, vamos a dejar las relaciones con Publisher y Category con lazy loading de nuevo:

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "publisher_id")
    private PublisherEntity publisher;
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id")

De esta forma, sólo cargará las entidades asociadas cuando sea necesario. Además, para forzar al framework a usar JOINs en las consultas, añadiremos una anotación a los métodos del repositorio JPA que devuelvan las entidades con sus relaciones, como findByIsbn:

public interface BookJpaRepository extends JpaRepository<BookEntity, Long> {

    @EntityGraph(attributePaths = {"publisher", "category"})
    BookEntity findByIsbn(String isbn);

La anotación EntityGraph usada sobre un método de un repositorios JPA, indica que tiene que recuperar los datos de las entidades relacionadas indicadas en attributePaths aunque en la entidad esté definido como fetch.LAZY. Además, lo hará mediante JOINs.

Otra opción sería crear nuestro propio SQL con JOINs:

public interface BookJpaRepository extends JpaRepository<BookEntity, Long> {

    @Query("SELECT b FROM BookEntity b " +
            "JOIN FETCH b.publisher p " +
            "JOIN FETCH b.category c " +
            "WHERE b.isbn = :isbn")
    BookEntity findByIsbn(String isbn);

Ten en cuenta, que si optamos por esta estrategia debemos indicar en cada método qué entidades queremos recuperar (incluso los métodos que nos proporciona Spring Data JPA). En nuestro caso, también usamos el método findById, con lo que deberíamos también modificarlo:

public interface BookJpaRepository extends JpaRepository<BookEntity, Long> {
    
    @EntityGraph(attributePaths = {"publisher", "category"})
    BookEntity findByIsbn(String isbn);

    @Override
    @EntityGraph(attributePaths = {"publisher", "category"})
    Optional<BookEntity> findById(Long id);

}

Podemos usar la anotación @Override en el método findById, ya que estamos sobreescribiendo un método de una clase antecesora proporcionada por Spring Data JPA con la implementación de dicho método.

Paginar resultados con Spring Data JPA es tan sencillo como utilizar el método findAll pasándole un objeto Pageable. Este método nos devolverá un objeto de tipo Page, con un conjunto de datos y otra información asociada.

Para crear el objeto, usamos su implementación PageRequest pasándole el número de página y el tamaño de página:

    @Override
    public List<Book> getAll(int page, int size) {
        Pageable pageable = PageRequest.of(page, size);
        Page<BookEntity> bookPage= bookJpaRepository.findAll(pageable);
       ...
    }

Antes hemos dicho que el objeto Page que devuelve el método findAll contiene más información que el listado de objetos paginados. Entre otra, tenemos el número de entidades totales. En nuestra aplicación, creamos un caso de uso donde devolvíamos ese total:

public interface BookCountUseCase {

    int execute();
}

Y lo utilizábamos en el controlador para recuperar ese dato y montar nuestra respuesta:

    @GetMapping
    public ResponseEntity<PaginatedResponse<BookCollection>> getAll(
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(required = false) Integer size) {

        int pageSize = (size != null) ? size : Integer.parseInt(defaultPageSize);
        List<BookCollection> bookCollections = bookGetAllUseCase
                .execute(page - 1, pageSize)
                .stream()
                .map(BookMapper.INSTANCE::toBookCollection)
                .toList();
        int total = bookCountUseCase.execute();

        PaginatedResponse<BookCollection> response = new PaginatedResponse<>(bookCollections, total, page, pageSize, baseUrl + URL);
        return new ResponseEntity<>(response, HttpStatus.OK);
    }

Al utilizar Spring Data JPA, si dejáramos el código así haría dos veces la sentencia “SELECT count(*) FROM books”, ya que Spring Data JPA la realiza para montar el objeto Page (puedes comprobarlo mirando en la terminal las sentencias SQL que se ejecutan).

Para solucionarlo, vamos a crear un nuevo modelo de dominio que contendrá un listado de entidades y el total:

@Data
@AllArgsConstructor
public class ListWithCount<T> {

    private List<T> list;
    private long count;
}

De esta forma, cambiaremos la firma del método getAll con paginación de nuestro DAO para devolver ese nuevo modelo:

public interface GenericDaoDb<T> {

    List<T> getAll();
    ListWithCount<T> getAll(int page, int size);
    Optional<T> findById(long id);
    long insert(T t);
    void update(T t);
    void delete(long id);
    long count();
    T save(T t);
}

Así en nuestras implementaciones devolveremos ambos datos, el listado de resultados y el total de elementos. Por ejemplo, nuestra implementación BookDaoJdbc quedaría:

    @Override
    public ListWithCount<Book> getAll(int page, int size) {
        String sql = """
                        SELECT * FROM books
                        LIMIT ? OFFSET ?
                     """;
        List<Book> books = jdbcTemplate.query(sql, new BookRowMapper(), size, page * size);
        int total = (int) this.count();
        return new ListWithCount<Book>(books, total);
    }

    @Override
    public long count() {
        String sql = """
                        SELECT COUNT(*) FROM books
                     """;
        return jdbcTemplate.queryForObject(sql, Long.class);
    }

Mientras que BookDaoJpa sería:

    @Override
    public ListWithCount<Book> getAll(int page, int size) {
        Pageable pageable = PageRequest.of(page, size);
        Page<BookEntity> bookPage= bookJpaRepository.findAll(pageable);
        return new ListWithCount<Book>(
                bookPage.stream()
                        .map(BookJpaMapper.INSTANCE::toBook)
                        .toList(),
                bookPage.getTotalElements()
        );
    }

En la implementación JPA Utilizamos el método getTotalElements de Page para obtener el total de elementos

Con este cambio, ya no necesitamos llamar al caso de uso que nos devuelve el total de elementos, con lo que no repetiríamos la sentencia SQL al utilizar JPA:

    public ResponseEntity<PaginatedResponse<BookCollection>> getAll(
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(required = false) Integer size) {

        int pageSize = (size != null) ? size : Integer.parseInt(defaultPageSize);
        String baseUrl = PropertiesConfig.getSetting("app.base.url") + URL;
        ListWithCount<Book> bookList = bookGetAllUseCase.execute(page - 1, pageSize);
        PaginatedResponse<BookCollection> response = new PaginatedResponse<>(
                bookList
                        .getList()
                        .stream()
                        .map(BookMapper.INSTANCE::toBookCollection)
                        .toList(),
                bookList.getCount(), page, pageSize, baseUrl);
        return new ResponseEntity<>(response, HttpStatus.OK);
    }

Para actualizar o insertar recursos, JPA utiliza el método save(). Lo único que tenemos que hacer en la implementación del DAO es mapear nuestros modelos de datos de la capa de dominio a entidades de JPA y guardar el recurso:

    @Override
    public Book save(Book book) {
        BookEntity bookEntity = BookJpaMapper.INSTANCE.toBookEntity(book);
        return BookJpaMapper.INSTANCE.toBook(bookJpaRepository.save(bookEntity));
    }

JPA devuelve el recurso recién creado/actualizado, con lo que en ambos casos podríamos devolver en el controlador dicho recurso en formato JSON (probablemente te tocará modificar el servicio y el controlador para indicar el tipo de recurso que devuelve cada método).

El borrado es igual de sencillo, simplemente tendremos que llamar al método deleteById:

    @Override
    public void delete(long id) {
        bookJpaRepository.deleteById(id);
    }

Para borrar un recurso, también podríamos utilizar el método delete, aunque en este caso deberíamos pasarle la entidad completa

Con todo, nuestra implementación BookDaoJpa quedaría:

@Component
@Primary
@RequiredArgsConstructor
public class BookDaoJpa implements BookDaoDb {

    private final BookJpaRepository bookJpaRepository;

    @Override
    public Optional<Book> findByIsbn(String isbn) {
        return Optional.ofNullable(bookJpaRepository.findByIsbn(isbn))
                .map(BookJpaMapper.INSTANCE::toBookWithDetails);
    }

    @Override
    public void deleteAuthors(long id) {
        bookJpaRepository.findById(id)
                .ifPresent(bookEntity -> bookEntity.getAuthors().clear());
    }

    @Override
    public void insertAuthors(long id, List<Author> authors) {
        bookJpaRepository.findById(id)
                .ifPresent(bookEntity -> bookEntity.setAuthors(
                        authors.stream()
                                .map(AuthorJpaMapper.INSTANCE::toAuthorEntity)
                                .toList()
                ));
    }

    @Override
    public void deleteGenres(long id) {
        bookJpaRepository.findById(id)
                .ifPresent(bookEntity -> bookEntity.getGenres().clear());
    }

    @Override
    public void insertGenres(long id, List<Genre> genres) {
        bookJpaRepository.findById(id)
                .ifPresent(bookEntity -> bookEntity.setGenres(
                        genres.stream()
                                .map(GenreJpaMapper.INSTANCE::toGenreEntity)
                                .toList()
                ));
    }

    @Override
    public List<Book> getAll() {
        return bookJpaRepository
                .findAll()
                .stream()
                .map(BookJpaMapper.INSTANCE::toBook)
                .toList();
    }

    @Override
    public ListWithCount<Book> getAll(int page, int size) {
        Pageable pageable = PageRequest.of(page, size);
        Page<BookEntity> bookPage= bookJpaRepository.findAll(pageable);
        return new ListWithCount<Book>(
                bookPage.stream()
                        .map(BookJpaMapper.INSTANCE::toBook)
                        .toList(),
                bookPage.getTotalElements()
        );
    }

    @Override
    public Optional<Book> findById(long id) {
        return bookJpaRepository.findById(id)
                .map(BookJpaMapper.INSTANCE::toBookWithDetails);
    }

    @Override
    public long insert(Book book) {
        return bookJpaRepository.save(BookJpaMapper.INSTANCE.toBookEntity(book)).getId();
    }

    @Override
    public void update(Book book) {
        bookJpaRepository.save(BookJpaMapper.INSTANCE.toBookEntity(book));
    }

    @Override
    public void delete(long id) {
        bookJpaRepository.deleteById(id);
    }

    @Override
    public long count() {
        return bookJpaRepository.count();
    }

    @Override
    public Book save(Book book) {
        BookEntity bookEntity = BookJpaMapper.INSTANCE.toBookEntity(book);
        return BookJpaMapper.INSTANCE.toBook(bookJpaRepository.save(bookEntity));
    }
}

Puede que pienses que con tanto mapeo de entidades de JPA a nuestros modelos de dominio, realicemos sentencias SQL de más. Por ejemplo, cuando actualizamos un libro, primero lo buscamos para ver si existe. Eso implica que obtenemos un BookEntity, lo mapeamos a Book, modificamos los campos necesarios y volvemos a mapearlo a BookEntity para guardar los cambios, con lo que JPA debería, antes de hacer el save volver a recuperar el recurso para asegurarse que existe.

En realidad, eso no ocurre, ya que, si recuerdas el tema de la capa dominio, ya hablamos del mecanismo que tiene para manejar entidades: EntityManager como gestor de entidades y dirty checking como mecanismo para marcar los cambios de éstas.

  • clase/daw/dws/2eval/jpa.txt
  • Última modificación: 2024/12/18 13:01
  • por cesguiro