Al finalizar este tutorial el estudiante estará en capacidad de realizar la implementación de la persistencia y la lógica junto con las pruebas de una entidad, utilizando JPA y el framework Spring Boot.
Para realizar este tutorial Ud. debe:
Siguiendo el diagrama de clases que se muestra a continuación, vamos a construir progresivamente una aplicación que permita, a través de servicios Restful, crear, modificar, borrar, obtener los recursos Book, Author, Editorial, Reviews, Prize y Organization teniendo en cuenta las relaciones entre estos elementos.
El siguiente diagrama muestra el modelo conceptual del ejemplo:
El ejemplo ha sido creado para que tenga distintos tipos de relaciones entre los recursos. La siguiente tabla describe las relaciones. En paréntesis está el nombre del extremo de la relación que se convertirá en el atributo que se modela en Java. Por ejemplo, la clase Book
tendrá un atributo llamado reviews.
Asociación | Descripción |
Book - Review | Un libro tiene un conjunto de revisiones ( Una revisión le pertenece a un único libro. |
Book - Author | Un libro puede tener varios autores (al menos 1).( Un autor puede haber escrito varios libros (al menos uno) ( |
Book - Editorial | Un libro es editado por una única editorial ( Una editorial puede edita muchos libros (al menos 1) ( |
Author - Prize | Un autor puede haber recibido muchos premios o ninguno ( Un premio le pertenece a un único autor. |
Prize - Organization | Un premio es otorgado por una única organización ( Una organización otorga un único premio ( |
Gradualmente vamos a ir viendo cómo estas relaciones se manejan desde la persistencia hasta la capa de servicios.
El siguiente diagrama muestra la transformación del modelo conceptual al modelo de entidades del ejemplo de BookStore. Las decisiones que se toman son las siguientes:
BaseEntity
(no se incluye esto en el modelo por claridad).Vamos a explicar cada una de las anotaciones en el modelo de entidades. Empezamos con la clase BookEntity
que tiene tres asociaciones, con: Review, Author y Editorial
Cuando la clase BookEntity
se escriba en Java, además de tener sus atributos básicos, tendrá un atributo por cada asociación en la que BookEntity
es fuente. Los extremos opuestos de la asociación (del lado de la clase destino) tiene el nombre que se usará para modelar el atributo: reviews
, editorial
y authors
. Además de tener un tipo, cada uno de estos atributos debe tener una anotación que le permita a JPA crear adecuadamente las tablas.
Esta es una relación de uno a muchos (OneToMany).
En java, la clase BookEntity
tendrá la siguiente definición del atributo reviews
:
...
@OneToMany(mappedBy = "book", cascade = CascadeType.PERSIST, orphanRemoval = true)
private List<ReviewEntity> reviews = new ArrayList<>();
...
En java, la clase ReviewEntity
tendrá la siguiente definición del atributo book
:
...
@ManyToOne
private BookEntity book;
...
Note que la anotación @OneToMany
sobre reviews
tiene varios atributos:
mappedBy = "book"
para indicar que esta relación que estamos definiendo se refiere a la misma que en la clase ReviewEntity
está definida con el atributo "book". cascade = CascadeType.PERSIST
significa que cuando se persista un libro debe persistir cada una de sus reviews. En particular si un libro se borra se deben borrar todos sus reviews.orphanRemoval = true
significa que no puede haber un reviews si no está asociado con un libro. No puede estar "huérfano".Esta es una relación de muchos a muchos (ManyToMany).
En java, la clase BookEntity
tendrá la siguiente definición del atributo authors
:
...
@ManyToMany
private List<AuthorEntity> authors = new ArrayList<>();
...
En java, la clase AuthorEntity
tendrá la siguiente definición del atributo books
:
...
@ManyToMany(mappedBy = "authors")
private List<BookEntity> books = new ArrayList<>();
...
Note que en el extremo de authors
se está definiendo el atributo que corresponde en el otro extremo (books
).
Esta es una relación de muchos a uno (ManyToOne).
En java, la clase BookEntity
tendrá la siguiente definición del atributo editorial
:
..
@ManyToOne
private EditorialEntity editorial;
..
En java, la clase EditorialEntity
tendrá la siguiente definición del atributo book
:
..
@OneToMany(mappedBy = "editorial")
private List<BookEntity> books = new ArrayList<>();
..
Note que en el extremo de books
se está definiendo el atributo que corresponde en el otro extremo (editorial
).
El siguiente diagrama muestra la asociación que va de AuthorEntity
a PrizeEntity
y de ésta a OrganizationEntity
.
En java, la clase AuthorEntity
tendrá la siguiente definición del atributo books
:
...
@OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
private List<PrizeEntity> prizes = new ArrayList<>();
...
En java, la clase PrizeEntity
tendrá la siguiente definición del atributo author
:
...
@ManyToOne
private AuthorEntity author;
...
En java, la clase PrizeEntity
tendrá la siguiente definición del atributo organization
:
...
@OneToOne
private OrganizationEntity organization;
...
La clase BaseEntity
es una clase abstracta que es una superclase de todas las clases entidades del proyecto. Como podemos observar, esta contiene los imports de las anotaciones que usamos. En primer lugar @Data
de la librería lombok nos sirve para autogenerar setters y getters de los atributos de la clase. Luego nos encontramos con la anotación @MappedSuperclass
, que nos indica que esta es la clase de la cual las entidades van a heredar, además de omitir la creación de una tabla en la base de datos con esta clase.
Esta clase contiene el código que define el atributo id de tipo Long que corresponde a la llave primaria. Este atributo contiene varias anotaciones:
@PodamExclude
: no generar valores sobre este atributo cuando se utilice podam.@Id
: indica que el atributo es la llave primaria@GeneratedValue(strategy = GenerationType.IDENTITY):
el valor de este atributo será generado en la base de datos cada vez que se ingresa un registro. La estrategia es que el primer objeto ingresado tenga Id 1 y este se incremente en cada registro de la tabla.package co.edu.uniandes.dse.bookstore.entities;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.MappedSuperclass;
import lombok.Data;
import uk.co.jemos.podam.common.PodamExclude;
/**
* Entidad genérica de la que heredan todas las entidades. Contiene la
* referencia al atributo id
*
* @author ISIS2603
*/
@Data
@MappedSuperclass
public abstract class BaseEntity {
@PodamExclude
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
}
En los pasos anteriores revisamos las anotaciones relacionadas con las asociaciones entre las clases. Ahora vamos a revisar otras consideraciones y anotaciones para ser tenidas en cuenta cuando se define una entidad.
Cuando tenemos un atributo cuyo tipo de dato es un Date
de Java, debemos anotarlo con
@Temporal(TemporalType.DATE).
A través de esta anotación le asignamos a este campo una conexión entre el formato Date de Java y el formato de Fecha que maneja la base de datos. También tenemos sobre los atributos tipo DATE la anotación @PodamStrategyValue(DateStrategy.class).
A través de esta anotación le estamos informando a la librería Podam que use la estrategia DateStrategy para generar datos de este atributos de una forma específica, por ejemplo, día/mes/año u otro formato. Dentro de la clase DateStrategy
podemos ver la lógica del autogenerado de una fecha para más información. Esta información en detalle de cómo crear estrategias para autogenerado de valores la podemos encontrar en la documentación de Podam.
En los atributos que representan una asociación, utilizamos la anotación @PodamExclude
para indicarle a la librería Podam que excluya este atributo a la hora de hacer la generación aleatoria de valores de estos atributos en las pruebas.
Ahora miremos la implementación de una entidad para este proyecto. Empecemos viendo la clase AuthorEntity
. En esta encontraremos lo siguiente:
ackage co.edu.uniandes.dse.bookstore.entities;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.ManyToMany;
import javax.persistence.OneToMany;
import javax.persistence.Temporal;
import javax.persistence.TemporalType;
import co.edu.uniandes.dse.bookstore.podam.DateStrategy;
import lombok.Getter;
import lombok.Setter;
import uk.co.jemos.podam.common.PodamExclude;
import uk.co.jemos.podam.common.PodamStrategyValue;
/**
* Clase que representa un autor en la persistencia
*
* @author ISIS2603
*/
@Getter
@Setter
@Entity
public class AuthorEntity extends BaseEntity {
@Temporal(TemporalType.DATE)
@PodamStrategyValue(DateStrategy.class)
private Date birthDate;
@PodamExclude
@ManyToMany(mappedBy = "authors")
private List<BookEntity> books = new ArrayList<>();
@PodamExclude
@OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
private List<PrizeEntity> prizes = new ArrayList<>();
private String name;
private String description;
private String image;
}
En esta clase vamos a resaltar las siguientes anotaciones:
@Setter
, @Getter
. Anotaciones proporcionada por la librería lombok que incluye en la clase los getters y setters por cada atributo. Esta anotación reduce la cantidad de código repetitivo de la clase.@Entity
. Indica que la clase es una entidad que será mapeada a una tabla en la base de datos.BaseEntity
para heredar la definición de la llave única con el fin de no tener código repetitivo.En los atributos de la clase tenemos:
@ManyToMany
a través de esta anotación estamos haciendo una asociación muchos a muchos con otra entidad. En este caso un autor tiene muchos libros y al mismo tiempo un libro puede estar escrito por varios autores. Fíjese que esta anotación debe estar mapeada con un atributo tipo List<BookEntity>
, y esto mismo debe suceder en la clase BookEntity
de manera inversa, quedando List<AuthorEntity>
y también con la anotación @ManyToMany
.@OneToMany
a través de esta anotación estamos haciendo una asociación de tipo uno a muchos. En este caso, un autor puede tener asociado varios premios pero un premio solo puede estar asociado a un autor. Fíjese que de manera inversa, en la clase PrizeEntity,
encontramos la anotación @ManyToOne
que asocia muchos premios a un solo autor.Para terminar de enseñar las anotaciones más importantes, abordaremos la clase PrizeEntity
. Esta contiene el siguiente código:
package co.edu.uniandes.dse.bookstore.entities;
import java.util.Date;
import javax.persistence.Entity;
import javax.persistence.ManyToOne;
import javax.persistence.OneToOne;
import javax.persistence.Temporal;
import javax.persistence.TemporalType;
import lombok.Getter;
import lombok.Setter;
import uk.co.jemos.podam.common.PodamExclude;
/**
* Clase que representa un premio en la persistencia
*
* @author ISIS2603
*/
@Getter
@Setter
@Entity
public class PrizeEntity extends BaseEntity {
@Temporal(TemporalType.DATE)
private Date premiationDate;
@PodamExclude
@ManyToOne
private AuthorEntity author;
private String name;
private String description;
@PodamExclude
@OneToOne
private OrganizationEntity organization;
}
En esta clase vamos a resaltar las siguientes anotaciones:
@ManyToOne
está anotación nos indica la asociación de tipo muchos a uno. En este caso un premio puede tener solo un autor, pero un autor puede tener muchos premios asignados. Recuerde que en la clase Author vimos la reciprocidad de las asociaciones. Cuando hacemos una asociación @OneToMany
, debe tener en el otro extremo la asociación @ManyToOne
@OneToOne
esta anotación nos indica la asociación de tipo uno a uno. En este caso un premio tiene asignada una Organización, al igual que una Organización solo tiene asignado un premio.|Elementos importantes a resaltar de las anotaciones y atributos de una Entidad:
@PodamExclude
.@Temporal(TemporalType.DATE)
.@OneToMany
, en la otra clase debe estar el @ManyToOne
. Además de lo anterior es muy importante que uno de estas dos anotaciones, contenga el mappedBy
, el cual debe tener el nombre exacto del atributo en la otra clase. Por ejemplo, volviendo a AuthorEntity
, éste tiene la siguiente anotación en el campo prizes: @OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
. Fíjese que en la entidad PrizeEntit
y existe el atributo author y que además este es de tipo AuthorEntity
y tiene la anotación de @ManyToOne
.OrganizationEntity
encontrará un ejemplo de cómo hacer esto.BookEntity
el atributo reviews contiene la asociación @OneToMany de que un libro tiene asociados muchas reseñas. En este encontramos los parámetros cascade = CascadeType.PERSIST
y orphanRemoval = true
en la anotación. Estos indican respectivamente que la forma de persistir la información a la hora de guardar un libro, este también contendrá la información de sus reseñas. Cuando guardamos la entidad BookEntity
, la entidad ReviewEntity
también será guardada. El parámetro orphanRemoval nos permite borrar las entidades hijas de una asociación. En este caso, cuando un libro es borrado de la base de datos, no nos interesa guardar las reseñas que tenía este libro.OneToMany
o @ManyToMany
. LAZY trae los datos a memoria desde la base de datos cuando estos son solicitados. LAZY es útil cuando la base de datos es grande y una sola entidad tiene muchas otras entidades asociadas. EAGER en cambio, carga todas las asociaciones a memoria y siempre están listas para ser consultadas. EAGER consume más memoria, pero hace que las consultas sean más rápidas. Estas decisiones de diseño son importantes para crear una aplicación robusta y segura.En el paquete repositories
de nuestro proyecto encontramos toda la capa de persistencia. Cada entidad que hayamos creado debe tener un archivo de persistencia asignado. Este archivo corresponde a una interface que extiende de JpaRepository
, el cual nos maneja el CRUD (Create, Retrieve, Update, Delete) de la entidad. La siguiente figura muestra el UML de la capa de persistencia para el recurso Author
.
Revisemos el AuthorRepository
:
package co.edu.uniandes.dse.bookstore.repositories;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import co.edu.uniandes.dse.bookstore.entities.AuthorEntity;
@Repository
public interface AuthorRepository extends JpaRepository<AuthorEntity, Long> {
}
La interfaz está anotada con @Repository
, lo que indica que será un repositorio, es decir, que encapsulará el comportamiento de almacenamiento, recuperación y búsqueda que emula una colección de objetos.
La interfaz extiende de JpaRepository
. Esto permite que la clase tenga acceso a la API completa de CrudRepository
y PagingAndSortingRepository
. De este modo, se contará con las operaciones CRUD básicas (crear, obtener, actualizar y borrar) y también operaciones para paginación y ordenamiento.
En los parámetros de la interface genérica JpaRepository
se agrega el tipo de la entidad (en este caso AuthorEntity
) y el tipo de dato de la clave primaria (en este caso Long).
Cuando queramos hacer métodos específicos de búsqueda en nuestra base de datos, por ejemplo con la estructura SELECT * FROM Tabla WHERE columna=parámet
ro, debemos crear las definiciones de los métodos en nuestra interfaz. Tomaremos de ejemplo la clase BookRepository
que tiene el siguiente código:
package co.edu.uniandes.dse.bookstore.repositories;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import co.edu.uniandes.dse.bookstore.entities.BookEntity;
@Repository
public interface BookRepository extends JpaRepository<BookEntity, Long> {
List<BookEntity> findByIsbn(String isbn);
}
Como podemos observar, queremos crear una búsqueda de un libro a partir de su isbn
adicional al método que trae la interface JpaRepository
para buscar un libro por su llave primaria, por lo que debemos declarar el método findByIsbn
el cual es implementado automáticamente por la clase que implementa nuestra interface BookRepository
y que es creada por el framework.
En el paquete de services
encontramos toda la lógica de la aplicación y donde integraremos las reglas de negocio. Para entender el funcionamiento usaremos la clase AuthorService
. El siguiente diagrama muestra los paquetes y clases involucradas para el ejemplo del recurso Author
.
El siguiente código corresponde a la clase AuthorService
. Solamente tiene un método para obtener todos los autores que están en la base de datos. Note que hace un llamado utilizando la interface AuthorRepository
al método findAll()
que está definido en JpaRepository
.
package co.edu.uniandes.dse.bookstore.services;
import java.util.List;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import co.edu.uniandes.dse.bookstore.entities.AuthorEntity;
import co.edu.uniandes.dse.bookstore.repositories.AuthorRepository;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
/**
* Clase que implementa la conexion con la persistencia para la entidad de
* Author.
*
* @author ISIS2603
*/
@Slf4j
@Service
public class AuthorService {
@Autowired
AuthorRepository authorRepository;
/**
* Obtiene la lista de los registros de Author.
*
* @return Colección de objetos de AuthorEntity.
*/
@Transactional
public List<AuthorEntity> getAuthors() {
log.info("Inicia proceso de consultar todos los autores");
return authorRepository.findAll();
}
}
Veamos las anotaciones en la clase_
Autowired
que lo usamos para inyectar la clase de persistencia que necesitamos.Service
que se utiliza para denotar la capa de negocio de la aplicación de Spring y por último,Transactional
que se utiliza para denotar las funciones que hacen uso de la base de datos de manera transaccional (Cumpliendo atributos ACID).@Slf4j
que se utiliza para mantener un registro en consola de las transacciones de la aplicación, lo que se conoce como un Logger.Dentro de nuestra capa de lógica, asociaremos métodos de CRUD de nuestra base de datos añadiendo reglas de negocio cuando sean necesarias. Siguiendo con AuthorService tenemos las siguientes funciones:
/**
* Se encarga de crear un Author en la base de datos.
*
* @param author Objeto de AuthorEntity con los datos nuevos
* @return Objeto de AuthorEntity con los datos nuevos y su ID.
*/
@Transactional
public AuthorEntity createAuthor(AuthorEntity author) {
log.info("Inicia proceso de creación del autor");
return authorRepository.save(author);
}
Obtención de todos los Autores:
/**
* Obtiene la lista de los registros de Author.
*
* @return Colección de objetos de AuthorEntity.
*/
@Transactional
public List < AuthorEntity > getAuthors() {
log.info("Inicia proceso de consultar todos los autores");
return authorRepository.findAll();
}
/**
* Obtiene los datos de una instancia de Author a partir de su ID.
*
* @param authorId Identificador de la instancia a consultar
* @return Instancia de AuthorEntity con los datos del Author consultado.
*/
@Transactional
public AuthorEntity getAuthor(Long authorId) throws EntityNotFoundException {
log.info("Inicia proceso de consultar el autor con id = {0}", authorId);
Optional < AuthorEntity > authorEntity = authorRepository.findById(authorId);
if (authorEntity.isEmpty())
throw new EntityNotFoundException(ErrorMessage.AUTHOR_NOT_FOUND);
log.info("Termina proceso de consultar el autor con id = {0}", authorId);
return authorEntity.get();
}
/**
* Actualiza la información de una instancia de Author.
*
* @param authorId Identificador de la instancia a actualizar
* @param authorEntity Instancia de AuthorEntity con los nuevos datos.
* @return Instancia de AuthorEntity con los datos actualizados.
*/
@Transactional
public AuthorEntity updateAuthor(Long authorId, AuthorEntity author) throws EntityNotFoundException {
log.info("Inicia proceso de actualizar el autor con id = {0}", authorId);
Optional < AuthorEntity > authorEntity = authorRepository.findById(authorId);
if (authorEntity.isEmpty())
throw new EntityNotFoundException(ErrorMessage.AUTHOR_NOT_FOUND);
log.info("Termina proceso de actualizar el autor con id = {0}", authorId);
author.setId(authorId);
return authorRepository.save(author);
}
/**
* Elimina una instancia de Author de la base de datos.
*
* @param authorId Identificador de la instancia a eliminar.
* @throws BusinessLogicException si el autor tiene libros asociados.
*/
@Transactional
public void deleteAuthor(Long authorId) throws IllegalOperationException, EntityNotFoundException {
log.info("Inicia proceso de borrar el autor con id = {0}", authorId);
Optional < AuthorEntity > authorEntity = authorRepository.findById(authorId);
if (authorEntity.isEmpty())
throw new EntityNotFoundException(ErrorMessage.AUTHOR_NOT_FOUND);
List < BookEntity > books = authorEntity.get().getBooks();
if (!books.isEmpty())
throw new IllegalOperationException("Unable to delete the author because he/she has associated books");
List < PrizeEntity > prizes = authorEntity.get().getPrizes();
if (!prizes.isEmpty())
throw new IllegalOperationException("Unable to delete the author because he/she has associated prizes");
authorRepository.deleteById(authorId);
log.info("Termina proceso de borrar el autor con id = {0}", authorId);
}
En este último código manejamos la regla de negocio donde no se puede eliminar un autor si este tiene libros o premios asociados. Primero deben ser borrados los libros y premios del autor para borrar el autor en la base de datos. Fíjese que cuando una regla de negocio ocurre, arrojamos la excepción de IllegalOperationException
Las asociaciones de una entidad también deben ser incluidas en la capa de lógica. Para estos casos se crean generalmente clases aparte para modelar y probar las asociaciones por separado. Nos centraremos en explicar la asociación de Autor y Book en la clase AuthorBookService
.
La clase está definida de la siguiente forma:
@Slf4j
@Data
@Service
public class AuthorBookService {
@Autowired
private BookRepository bookRepository;
@Autowired
private AuthorRepository authorRepository;
...
}
Lo importante a resaltar, es que contiene la inyección de la capa de persistencia de ambas entidades, tanto la de Book como la de Author. Ahora revisaremos los métodos CRUD de la asociación junto a sus reglas de negocio:
Agregar un libro a un autor dados sus id:
@Transactional
public BookEntity addBook(Long authorId, Long bookId) throws EntityNotFoundException {
log.info("Inicia proceso de asociarle un libro al autor con id = {0}", authorId);
Optional < AuthorEntity > authorEntity = authorRepository.findById(authorId);
Optional < BookEntity > bookEntity = bookRepository.findById(bookId);
if (authorEntity.isEmpty())
throw new EntityNotFoundException(ErrorMessage.AUTHOR_NOT_FOUND);
if (bookEntity.isEmpty())
throw new EntityNotFoundException(ErrorMessage.BOOK_NOT_FOUND);
bookEntity.get().getAuthors().add(authorEntity.get());
log.info("Termina proceso de asociarle un libro al autor con id = {0}", authorId);
return bookEntity.get();
}
Aquí manejamos los errores cuando no se encuentra el autor o el libro especificado por parámetro.
Obtener libros de un Autor dado su id:
@Transactional
public List < BookEntity > getBooks(Long authorId) throws EntityNotFoundException {
log.info("Inicia proceso de consultar todos los libros del autor con id = {0}", authorId);
Optional < AuthorEntity > authorEntity = authorRepository.findById(authorId);
if (authorEntity.isEmpty())
throw new EntityNotFoundException(ErrorMessage.AUTHOR_NOT_FOUND);
List < BookEntity > books = bookRepository.findAll();
List < BookEntity > bookList = new ArrayList < > ();
for (BookEntity b: books) {
if (b.getAuthors().contains(authorEntity.get())) {
bookList.add(b);
}
}
log.info("Termina proceso de consultar todos los libros del autor con id = {0}", authorId);
return bookList;
}
Aquí revisamos en primer lugar que el autor dado por parámetro exista. Una vez confirmada su existencia, se consultan todos los libros y se miran cuales de ellos están asociados al autor especificado. Los que tienen el autor asociado son agregados a una lista de libros que será retornada al final.
Obtención de un libro de un Autor dados sus ids:
@Transactional
public BookEntity getBook(Long authorId, Long bookId) throws EntityNotFoundException, IllegalOperationException {
log.info("Inicia proceso de consultar el libro con id = {0} del autor con id = " + authorId, bookId);
Optional < AuthorEntity > authorEntity = authorRepository.findById(authorId);
Optional < BookEntity > bookEntity = bookRepository.findById(bookId);
if (authorEntity.isEmpty())
throw new EntityNotFoundException(ErrorMessage.AUTHOR_NOT_FOUND);
if (bookEntity.isEmpty())
throw new EntityNotFoundException(ErrorMessage.BOOK_NOT_FOUND);
log.info("Termina proceso de consultar el libro con id = {0} del autor con id = " + authorId, bookId);
if (bookEntity.get().getAuthors().contains(authorEntity.get()))
return bookEntity.get();
throw new IllegalOperationException("The book is not associated to the author");
}
Actualización de libros de un Autor:
@Transactional
public List < BookEntity > addBooks(Long authorId, List < BookEntity > books) throws EntityNotFoundException {
log.info("Inicia proceso de reemplazar los libros asociados al author con id = {0}", authorId);
Optional < AuthorEntity > authorEntity = authorRepository.findById(authorId);
if (authorEntity.isEmpty())
throw new EntityNotFoundException(ErrorMessage.AUTHOR_NOT_FOUND);
for (BookEntity book: books) {
Optional < BookEntity > bookEntity = bookRepository.findById(book.getId());
if (bookEntity.isEmpty())
throw new EntityNotFoundException(ErrorMessage.BOOK_NOT_FOUND);
if (!bookEntity.get().getAuthors().contains(authorEntity.get()))
bookEntity.get().getAuthors().add(authorEntity.get());
}
log.info("Finaliza proceso de reemplazar los libros asociados al author con id = {0}", authorId);
return books;
}
Desasociar libro de un autor dados sus ids:
@Transactional
public void removeBook(Long authorId, Long bookId) throws EntityNotFoundException {
log.info("Inicia proceso de borrar un libro del author con id = {0}", authorId);
Optional < AuthorEntity > authorEntity = authorRepository.findById(authorId);
if (authorEntity.isEmpty())
throw new EntityNotFoundException(ErrorMessage.AUTHOR_NOT_FOUND);
Optional < BookEntity > bookEntity = bookRepository.findById(bookId);
if (bookEntity.isEmpty())
throw new EntityNotFoundException(ErrorMessage.BOOK_NOT_FOUND);
bookEntity.get().getAuthors().remove(authorEntity.get());
log.info("Finaliza proceso de borrar un libro del author con id = {0}", authorId);
}
Es importante notar que para este ejemplo las asociaciones en la lógica también son recíprocas, es decir existe un AuthorBookService
y un BookAuthorService
, ya que cada una maneja la lógica y reglas de negocio según sus necesidades. Estuvimos observando la lógica de la relación ManyToMany entre Author y Book, las demás relaciones en la lógica tienen una forma parecida. Para ver una implementación de la asociación en la capa de lógica de tipo OneToMany, puede ver las clases de BookEditorialService
y EditorialBookService
, ya que una editorial tiene muchos libros pero un libro solo tiene asignada una editorial.
Vaya a la carpeta, src/test/java > paquete services. En esta encontraremos todos los archivos JUnit de prueba de lógica de nuestro proyecto. Si queremos crear un nuevo archivo de pruebas, lo hacemos con click derecho en el paquete > New > Other > JUnit > JUnit Test Case.
Seleccione la opción New JUnit Jupiter Test
. En el nombre de la clase escriba {{Nombre_Entidad}}ServiceTest
. Note que el sufijo ServiceTest será la convención de nombramiento para las clases de prueba de lógica.
Asegúrese de que la opción @BeforeEach setUp()
esté marcada. Luego en la opción Class under test
busque la clase {{Nombre_Entidad}}Service
y haga clic en Next. En el siguiente paso marque los métodos que usará en la prueba. Generalmente se debe probar todo el CRUD dentro de la prueba por lo que marcaremos todos los del Servicio.
Las clases se debe anotar con:
@ExtendWith(SpringExtension.class)
. Indica que la clase de pruebas extiende de Spring
@DataJpaTest
. Indica que en la prueba se involucra acceso a datos
@Transactional
. Indica que los métodos en la prueba serán transaccionales.
@Import({{Nombre_Entidad}}Service.class)
. Indica la clase del servicio que se usará en el test.
En cada clase se debe inyectar su servicio y el Entity Manager:
@Autowired
private {{Nombre_Entidad}}Service {{nombre_entidad}}Service;
@Autowired
private TestEntityManager entityManager;
La primera se usa para tener acceso a los métodos del servicio mientras que la segunda define un EntityManager
para las pruebas (acceso a métodos para persistir y recuperar datos de la persistencia).
Cada clase debe agregar una referencia a Podam:
private PodamFactory factory = new PodamFactoryImpl();
Podam es una librería que permite crear nuevos objetos con datos ficticios.
Generalmente se debe definir una lista de Entidades al servicio a probar:
private List<{{Nombre_Entidad}}Entity> {{nombre_entidad}}List = new ArrayList<>();
El siguiente paso es la configuración inicial de la prueba. Esto se hace en el método setUp
anotado por @BeforeEach
, lo que indica que este método se ejecuta antes de cada test. En este método se hace el llamado a los métodos clearData()
e insertData()
. El primero borra los datos de la tabla {{Nombre_Entidad}}Entity
y el segundo se encarga de insertar tres registros con ayuda del EntityManager
y se guardan en la lista {{nombre_entidad}}List
. Para seguir entendiendo los métodos de la clase de pruebas, usaremos AuthorServiceTest definida por el momento de la siguiente forma:
package co.edu.uniandes.dse.bookstore.service;
import static org.junit.jupiter.api.Assertions.*;
import java.util.ArrayList;
import java.util.List;
import javax.transaction.Transactional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import co.edu.uniandes.dse.bookstore.entities.AuthorEntity;
import co.edu.uniandes.dse.bookstore.entities.BookEntity;
import co.edu.uniandes.dse.bookstore.entities.PrizeEntity;
import co.edu.uniandes.dse.bookstore.exceptions.EntityNotFoundException;
import co.edu.uniandes.dse.bookstore.exceptions.IllegalOperationException;
import co.edu.uniandes.dse.bookstore.services.AuthorService;
import uk.co.jemos.podam.api.PodamFactory;
import uk.co.jemos.podam.api.PodamFactoryImpl;
@ExtendWith(SpringExtension.class)
@DataJpaTest
@Transactional
@Import(AuthorService.class)
class AuthorServiceTest {
@Autowired
private AuthorService authorService;
@Autowired
private TestEntityManager entityManager;
private PodamFactory factory = new PodamFactoryImpl();
private List < AuthorEntity > authorList = new ArrayList < > ();
@BeforeEach
void setUp() {
clearData();
insertData();
}
private void clearData() {
entityManager.getEntityManager().createQuery("delete from PrizeEntity").executeUpdate();
entityManager.getEntityManager().createQuery("delete from BookEntity").executeUpdate();
entityManager.getEntityManager().createQuery("delete from AuthorEntity").executeUpdate();
}
private void insertData() {
for (int i = 0; i < 3; i++) {
AuthorEntity authorEntity = factory.manufacturePojo(AuthorEntity.class);
entityManager.persist(authorEntity);
authorList.add(authorEntity);
}
AuthorEntity authorEntity = authorList.get(2);
BookEntity bookEntity = factory.manufacturePojo(BookEntity.class);
bookEntity.getAuthors().add(authorEntity);
entityManager.persist(bookEntity);
authorEntity.getBooks().add(bookEntity);
PrizeEntity prize = factory.manufacturePojo(PrizeEntity.class);
prize.setAuthor(authorList.get(1));
entityManager.persist(prize);
authorList.get(1).getPrizes().add(prize);
}
...
}
Como podemos observar, tenemos la inyección del servicio AuthorService y la del EntityManager, al igual que tenemos la referencia la Podam y una lista de AuthorEntity donde guardamos la información. El método setUp() se ejecuta antes de ejecutar la prueba y este a su vez llama los metodos clearData() e insertData(). En el metoo clearData() limpiamos todas las tablas que se relacionen con la entidad AuthorEntity, en este caso borramos PrizeEntity, BookEntity y AuthorEntity. Lueg de haber limpiado la base de datos, insertData() crea 3 AuthorEntity arbitrarios usando la libreria Podam. Recuerde que los atributos marcados con PodamExclude enla entidad no son autegenerados y siempre son inicializados en null. Seguiremos con la prueba de creación de un autor.
Test creación de un Autor:
@Test
void testCreateAuthor() {
AuthorEntity newEntity = factory.manufacturePojo(AuthorEntity.class);
AuthorEntity result = authorService.createAuthor(newEntity);
assertNotNull(result);
AuthorEntity entity = entityManager.find(AuthorEntity.class, result.getId());
assertEquals(newEntity.getId(), entity.getId());
assertEquals(newEntity.getName(), entity.getName());
assertEquals(newEntity.getBirthDate(), entity.getBirthDate());
assertEquals(newEntity.getDescription(), entity.getDescription());
}
Como podemos observar, creamos un AuthorEntity
con datos arbitrarios y luego buscamos que se haya persistido en la base de datos. Estos métodos de prueba no deben llevar private
o public
, todos deben estar sentenciados con void()
, además de tener todos la anotación @Test de JUnit.
Test obtener Autores:
@Test
void testGetAuthors() {
List < AuthorEntity > authorsList = authorService.getAuthors();
assertEquals(authorList.size(), authorsList.size());
for (AuthorEntity authorEntity: authorsList) {
boolean found = false;
for (AuthorEntity storedEntity: authorList) {
if (authorEntity.getId().equals(storedEntity.getId())) {
found = true;
}
}
assertTrue(found);
}
}
Como podemos observar, el código, obtiene todos los autores de la base de datos y los compara con los que tiene en memoria en authorList
. Deben estar los mismos registros por lo que este resultado al final debe ser true.
Test obtener Autor dado su Id:
@Test
void testGetAuthor() throws EntityNotFoundException {
AuthorEntity authorEntity = authorList.get(0);
AuthorEntity resultEntity = authorService.getAuthor(authorEntity.getId());
assertNotNull(resultEntity);
assertEquals(authorEntity.getId(), resultEntity.getId());
assertEquals(authorEntity.getName(), resultEntity.getName());
assertEquals(authorEntity.getBirthDate(), resultEntity.getBirthDate());
assertEquals(authorEntity.getDescription(), resultEntity.getDescription());
}
Como podemos observar, utilizamos el primer autor de la lista para consultar en la base de datos dado su Id. Estos deben coincidir en todos los atributos, por lo que hacemos los respectivos chequeos.
Test obtener un Autor que no existe:
@Test
void testGetInvalidAuthor() {
assertThrows(EntityNotFoundException.class, () - > {
authorService.getAuthor(0 L);
});
}
Como podemos observar, tenemos que buscar un registro en la base de datos que no existe, por lo que lo hacemos con el Id 0. De esto se debe esperar que tire la excepción de entidad no encontrada, por lo que el método debe estar escrito de esta forma. Si observamos con detenimiento, podrá notar que se hace uso de la notación lambda "->", el cual crea una función anónima en Java, es decir, el método assertThrows()
recibe 2 parametros, el primero es la excepción que se espera que lance, y el segundo es una función que dispare esta excepción. En este caso la función no recibe parámetros y solo hace la instrucción de authorService.getAuthor(0 L);.
Test actualizar Autor:
@Test
void testUpdateAuthor() throws EntityNotFoundException {
AuthorEntity authorEntity = authorList.get(0);
AuthorEntity pojoEntity = factory.manufacturePojo(AuthorEntity.class);
pojoEntity.setId(authorEntity.getId());
authorService.updateAuthor(authorEntity.getId(), pojoEntity);
AuthorEntity response = entityManager.find(AuthorEntity.class, authorEntity.getId());
assertEquals(pojoEntity.getId(), response.getId());
assertEquals(pojoEntity.getName(), response.getName());
assertEquals(pojoEntity.getBirthDate(), response.getBirthDate());
assertEquals(pojoEntity.getDescription(), response.getDescription());
}
En este caso creamos nuevos atributos arbitrarios con la librería Podam y los actualizamos a un registro en memoria. Finalmente, persistimos la información y verificamos que todo se haya actualizado correctamente.
Test actualizar un Autor invalido (No existente):
@Test
void testUpdateInvalidAuthor() {
assertThrows(EntityNotFoundException.class, () - > {
AuthorEntity pojoEntity = factory.manufacturePojo(AuthorEntity.class);
authorService.updateAuthor(0 L, pojoEntity);
});
}
Aquí también se hace uso de la notación lambda y tiene la misma lógica que testGetInvalidAuthor()
.
Por último las pruebas de borrado:
Test Borrar un Autor:
@Test
void testDeleteAuthor() throws EntityNotFoundException, IllegalOperationException {
AuthorEntity authorEntity = authorList.get(0);
authorService.deleteAuthor(authorEntity.getId());
AuthorEntity deleted = entityManager.find(AuthorEntity.class, authorEntity.getId());
assertNull(deleted);
}
En esta prueba borramos el registro y verificamos que al intentar buscarlo este sea null
al no existir en la base de datos.
Test Borrar un Autor Inválido:
@Test
void testDeleteInvalidAuthor() {
assertThrows(EntityNotFoundException.class, () - > {
authorService.deleteAuthor(0 L);
});
}
Test borrar un Autor con libros asociados:
@Test
void testDeleteAuthorWithBooks() {
assertThrows(IllegalOperationException.class, () - > {
authorService.deleteAuthor(authorList.get(2).getId());
});
}
Este último método está probando una regla de negocio. Se espera que el estudiante cree métodos similares con el objetivo de infringir reglas de negocio y conocer si se están lanzando las excepciones correspondientes.
Test borrar un Autor con premios asociados:
@Test
void testDeleteAuthorWithPrize() {
assertThrows(IllegalOperationException.class, () - > {
authorService.deleteAuthor(authorList.get(1).getId());
});
}
Este último método está probando una regla de negocio. Se espera que el estudiante cree métodos similares con el objetivo de infringir reglas de negocio y conocer si se están lanzando las excepciones correspondientes.