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.
Añadiendo la dependencia y configurando la conexión
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.
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.
Estructura
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
Entidades
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; }
Relaciones entre entidades
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; }
Repositorios JPA
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.
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(); } ...
Seleccionando el DAO correspondiente
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.
Carga de datos relacionados
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); }
Paginación
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() ); }
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); }
Actualizaciones, inserciones y borrados
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)); }
El borrado es igual de sencillo, simplemente tendremos que llamar al método deleteById:
@Override public void delete(long id) { bookJpaRepository.deleteById(id); }
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)); } }
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.