Hasta ahora, hemos utilizado nuestras propias clases (DBUtil, DAOs…) para conectarnos a la bbdd y tratar los datos, pero ésto 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, la Java Persistence API (JPA) 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:
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-boot-starter-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-boot-starter-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-boot-starter-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.
Para conectar con nuestra bbdd tenemos que añadir algunas propiedades en nuestro archivo application.properties:
# Configuración de Hibernate y base de datos 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
Las 3 primeras líneas son la url de conexión, el usuario y la contraseña con el que nos conectamos a la bbdd.
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:
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. Ésto nos será útil más adelante para entender como funciona la carga perezosa (lazy loading) en Hibernate.
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:
@Entity @Data @NoArgsConstructor public class MovieEntity {
El problema, es que nuestras entidades llevan el sufijo Entity para distinguirlas de las entidades de dominio. Por suerte, la solución es muy sencilla: indicarle el nombre de la tabla mediante la anotación @Table:
@Entity @Table(name = "movies") @Data @NoArgsConstructor public class MovieEntity {
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 = "movies") @Data @NoArgsConstructor public class MovieEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; private String title; private int year; private int runtime;
Vamos a crear ahora el resto de entidades. Empezaremos con los directores:
@Entity @Table(name = "directors") @Data @NoArgsConstructor public class DirectorEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private int id; private String name; private int birthYear; private Integer deathYear; }
Si lo dejamos así, veremos que JPA no es capaz de rellenar los campos birthYear y deathYear. Normal, ya que en la bbdd los campos se llaman birth_year y death_year. Por suerte, contamos con la anotación @Column para indicarle el nombre de la columna en la bbdd. Además, podemos añadir el atributo nullable para indicar que ese campo puede contener nulos:
@Entity @Table(name = "directors") @Data @NoArgsConstructor public class DirectorEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private int id; private String name; @Column(name = "birth_year") private int birthYear; @Column(name = "death_year", nullable = true) private Integer deathYear; }
Nuestra entidad actores será casi igual:
@Entity @Table(name = "actors") @Data @NoArgsConstructor public class ActorEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private int id; private String name; @Column(name = "birth_year") private int birthYear; @Column(name = "death_year", nullable = true) private Integer deathYear; }
Vamos a voler a MovieEntity. Por ahora, hemos creado los campos básicos de la tabla: id, title, year y runtime ¿Cómo añadimos la relación con los directores y los personajes? Para hacerlo, contamos con una serie de anotaciones según el tipo de relación que queramos representar:
En general, no queremos relaciones bidireccionales, con lo que vamos a añadir, por ahora, la anotación @ManyToOne en MovieEntity para indicar la relación con DirectorEntity:
@Entity @Table(name = "movies") @Data @NoArgsConstructor public class MovieEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; private String title; private int year; private int runtime; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "director_id") private DirectorEntity directorEntity;
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). Además, hemos indicado el campo por el que están relacionadas ambas tablas mediante la anotación @JoinColumn.
Si quisiésemos añadir la relación entre actores y películas (sin tener en cuenta los personajes), podríamos hacerlo usando la anotación @JointTable, donde indicaríamos la tabla intermedia con las columnas de enganche:
@ManyToMany(fetch = FetchType.LAZY) @JoinTable( name = "actors_movies", joinColumns = @JoinColumn(name = "movie_id"), inverseJoinColumns = @JoinColumn(name = "actor_id") ) private List<ActorEntity> actorEntities;
En cualquier caso, nosotros vamos a crear una relación con la entidad CharacterMovieEntity, de forma similar a como lo hicimos con los directores, aunque cambiando el tipo de relación a @OneToMany. Para eso, primero modificamos la entidad de personajes para relacionarla con los actores:
@Entity @Table(name = "actors_movies") @Data @NoArgsConstructor public class CharacterMovieEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private int id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "actor_id") private ActorEntity actorEntity; }
Ahora, añadimos la relación en la entidad de películas:
@Entity @Table(name = "movies") @Data @NoArgsConstructor public class MovieEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; private String title; private int year; private int runtime; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "director_id") private DirectorEntity directorEntity; @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = "movie_id") private List<CharacterMovieEntity> characterMovieEntities;
Fíjate que en la nueva relación estamos indicando que el campo movie_id de nuestra tabla actors_movies se relaciona con el campo id de la tabla movies. En el caso de las relaciones @OneToMany el atributo name hace referencia al nombre de la clave ajena de la segunda tabla.
Además, indicamos que los cambios (actualizaciones y borrados) queremos que sean en cascada. La opción orphanRemoval = true nos servirá para indicar que borre los registros de la tabla actors_movies cuando se borre una película. Ésto es debido a que, al no haber una relación bidireccional entre MovieEntity y CharacterMovieEntity, JPA actualiza la tabla movie_actors con movie_id = null antes de hacer cualquier cambio.
Pruedes probar a crear la bbdd (con otro nombre) de forma automática para comprobar que te crea las mismas tablas (con las mismas claves foráneas) que la nuestra original.
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
Ahora que ya tenemos creadas nuestras entidades es hora de hacer lo mismo con los repositorios. Aunque en JPA las clases que tratan directamente con los datos se llaman repositorios, nosotros vamos a mantener el nombre de DAOs (acuérdate que los repositorios trabajan con los modelos de datos de la capa de negocio, con lo que no tienen nada que ver con las bbdd). Por lo tanto, lo que tendremos que hacer es modificar nuestros DAO para hacer que hereden de JPARepository indicando la entidad que manejan y el tipo de datos de la clave primaria de esa entidad. Además, ya no serán clases, si no interfaces:
@Repository public interface MovieDAO extends JpaRepository<MovieEntity, Integer> { }
Como ves, 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, si quisiésemos buscar películas por título, bastaría con declarar el método en nuestra interfaz MovieDAO:
@Repository public interface MovieDAO extends JpaRepository<MovieEntity, Integer> { List<MovieEntity> findByTitle(String title); }
Spring Data JPA interpretará este método y generará una consulta SQL para buscar películas por título. La convención de nombres juega un papel crucial aquí: el prefijo findBy indica que quieres buscar entidades por un campo específico (title en este caso). Spring Data JPA analiza el nombre del método, separa findBy, interpreta Title 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.
Vamos a probar a devolver un listado de películas. Pero antes, vamos a hacer un pequeño cambio en los mapeadores para entender como funciona la carga perezosa con Spring. Por ahora, comenta todos los mapeadores de la clase MovieMapper y deshabilita la búsqueda por id, inserción, actualización y borrado de las mismas en el controlador y servicio (haz que el repositorio devuelva null o 0 en esos métodos, por ejemplo).
Primero, modificamos el mapeador de MovieEntity a Movie para indicarle que el director y los personajes sean nulos. En cuanto al mapeo de Movie a MoveListWeb lo dejamos como está. Aprovecharemos también para crear un método que mapee un listado de MovieEntity a un listado de Movie:
@Mapping(target = "director", ignore = true) @Mapping(target = "characterMovies", ignore = true) Movie toMovie(MovieEntity movieEntity); @Mapping(target = "director", ignore = true) @Mapping(target = "characterMovies", ignore = true) List<Movie> toMovieList(List<MovieEntity> movieEntities); MovieListWeb toMovieListWeb(Movie movie);
Además, vamos a modificar el método getTotalNumberOfRecords() del repositorio para usar el método predefinido MovieDAO.count(). El problema es que ese método devuelve un tipo long en lugar de int, con lo que nos tocará modificarlo en el repositorio, servicio y controlador:
Repositorio:
@Override public long getTotalNumberOfRecords() { return movieDAO.count(); }
Servicio:
@Override public long getTotalNumberOfRecords() { return movieRepository.getTotalNumberOfRecords(); }
Controlador:
@ResponseStatus(HttpStatus.OK) @GetMapping("") public Response getAll(@RequestParam(required = false) Integer page, @RequestParam(required = false) Integer pageSize) { pageSize = (pageSize != null)? pageSize : PAGE_SIZE; List<Movie> movies = (page != null)? movieService.getAll(page, pageSize) : movieService.getAll(); List<MovieListWeb> moviesWeb = movies.stream() .map(MovieMapper.mapper::toMovieListWeb) .toList(); long totalRecords = movieService.getTotalNumberOfRecords(); Response response = Response.builder() .data(moviesWeb) .totalRecords(totalRecords) .build(); if(page != null) { response.paginate(page, pageSize, urlBase); } return response; }
En nuestra implementación del repositorio simplemente tenemos que llamar al método findAll del DAO.
¿Y cómo implementamos la paginación? Por suerte, JPA ya tiene implementado esa opción pasándole un objeto de tipo Pageable al método. Podemos utilizar el método of() de la clase PageRequest que implementa la interfaz anterior para crear el objeto Pageable. El problema es que JPA asume que la primera página es la 0, con lo que si queremos mantener el orden (primera página = 1), deberemos restar uno al número de página que nos pasen:
@Override public List<Movie> getAll(Integer page, Integer pageSize) { List<MovieEntity> movieEntities; if(page != null && page > 0) { Pageable pageable = PageRequest.of(page - 1, pageSize); movieEntities = movieDAO.findAll(pageable).stream().toList(); } else { movieEntities = movieDAO.findAll(); } return MovieMapper.mapper.toMovieList(movieEntities); }
Si todo ha ido bien, deberías poder ser capaz de listar las películas con su id, título y el número de registros:
Si te fijas en la terminal del IDE, nos muestra las sentencias SQL que se ejecutan, al tener esa opción habilitada en application.properties:
Vamos ahora con la búsqueda de una pelicula. En principio, es bastante sencillo, ya que sólo debemos llamar al método findById() de JPA y mapear lo que nos devuelva (Optional<MovieEntity>) a Movie:
@Override public Optional<Movie> find(int id) { return Optional.ofNullable(MovieMapper.mapper.toMovie(movieDAO.findById(id).get())); }
Probamos la salida:
Pero nosotros queremos mostrar también los datos del director y los personajes. Para eso, vamos a ver como funciona la carga perezosa en JPA.
Como sabes, la carga perezosa (lazy loading) es un mecanismo por el cual sólo accedemos a los recursos cuando los necesitamos, con lo que, en este caso, nos ahorramos hacer sentencias SQL de más, ya que sólo traeremos los datos del director y personajes de una película cuando los vayamos a necesitar.
Si ves las sentencias SQL que se han ejecutado cuando se trae una película por id, verás que sólo ha ejecutado una, ya que en las entidades hemos indicado que queremos carga perezosa para los recursos director y personajes:
Vamos a probar la carga perezosa. En nuestro repositorio accedemos al método getDirectorEntity() para acceder a ese recurso:
@Override public Optional<Movie> find(int id) { MovieEntity movieEntity = movieDAO.findById(id).orElse(null); if(movieEntity != null) { movieEntity.getDirectorEntity(); } return Optional.ofNullable(MovieMapper.mapper.toMovie(movieEntity)); }
Vemos las sentencias que se ejecutan:
¿Qué está pasando? Parece ser que no está funcionando la carga perezosa, ya que no se trae los datos del director. En realidad, sí que está funcionando, pero todavía no hemos accedido a ningún dato del director. Por ejemplo, vamos a acceder al nombre del director:
@Override public Optional<Movie> find(int id) { MovieEntity movieEntity = movieDAO.findById(id).orElse(null); if(movieEntity != null) { movieEntity.getDirectorEntity().getName(); } return Optional.ofNullable(MovieMapper.mapper.toMovie(movieEntity)); }
Ahora sí que ejecuta la sentencia SQL para traerse los datos del director. Como ves, JPA intenta no acceder a los datos hasta que son realmente necesarios. Sabiendo eso, volvemos a dejar el método como estaba y cambiamos nuestro mapeador para traer los datos del director y de los personajes:
@Override public Optional<Movie> find(int id) { MovieEntity movieEntity = movieDAO.findById(id).orElse(null); if(movieEntity == null) { return Optional.empty(); } return Optional.of(MovieMapper.mapper.toMovie(movieEntity)); }
@Mapping(target = "director", expression = "java(DirectorMapper.mapper.toDirector(movieEntity.getDirectorEntity()))") @Mapping(target = "characterMovies", expression = "java(CharacterMovieMapper.mapper.toCharacterMovies(movieEntity.getCharacterMovieEntities()))") Movie toMovie(MovieEntity movieEntity);
Perfecto, ahora vemos los datos del director y personajes, además de las sentencias ejecutadas correctamente:
Vuelve ahora a cargar el listado de películas y mira las sentencias SQL que se ejecutan:
¡Está ejecutando todas las sentencias SQL de directores y personajes por cada película del listado! En realidad, ésto tiene que ver con como funciona MapStruct. Si ves la implementación que crea, llama a nuestro método toMovie cuando mapea el listado de películas:
public List<Movie> toMovieList(List<MovieEntity> movieEntities) { if (movieEntities == null) { return null; } else { List<Movie> list = new ArrayList(movieEntities.size()); Iterator var3 = movieEntities.iterator(); while(var3.hasNext()) { MovieEntity movieEntity = (MovieEntity)var3.next(); list.add(this.toMovie(movieEntity)); } return list; } }
Por suerte, hay una forma muy sencilla de solucionarlo. Cambiamos el nombre al método toMovie por toMovieWithDirectorAndCharacterMovies. Creamos otro método toMovie ignorando el director y los personajes. Asignamos nombres específicos a los métodos de nuestro mapeador con @Named y le indicamos al método toMovieList que utilice el que nos interesa con @IterableMapping:
@Mapping(target = "director", ignore = true) @Mapping(target = "characterMovies", ignore = true) @Named("toMovie") Movie toMovie(MovieEntity movieEntity); @Mapping(target = "director", ignore = true) @Mapping(target = "characterMovies", ignore = true) @IterableMapping(qualifiedByName = "toMovie") @Named("toMovieList") List<Movie> toMovieList(List<MovieEntity> movieEntities); @Mapping(target = "director", expression = "java(DirectorMapper.mapper.toDirector(movieEntity.getDirectorEntity()))") @Mapping(target = "characterMovies", expression = "java(CharacterMovieMapper.mapper.toCharacterMovies(movieEntity.getCharacterMovieEntities()))") @Named("toMovieWithDirectorAndCharacterMovies") Movie toMovieWithDirectorAndCharacterMovies(MovieEntity movieEntity);
Sólo nos falta cambiar el mapeador que utilizaremos en el método find del repositorio:
@Override public Optional<Movie> find(int id) { MovieEntity movieEntity = movieDAO.findById(id).orElse(null); if(movieEntity == null) { return Optional.empty(); } return Optional.of(MovieMapper.mapper.toMovieWithDirectorAndCharacterMovies(movieEntity)); }
Listo. Ahora sí que debería ejecutar las sentencias SQL de búsqueda del director y personajes sólo cuando accedamos al detalle de una película.
Para actualizar o insertar recursos, JPA utiliza el método save(). Lo único que tenemos que hacer en las implementaciones de los repositorios es mapear nuestros modelos de datos de la capa de dominio a entidades de JPA y guardar el recurso:
@Override @Transactional public Movie insert(Movie movie) { MovieEntity movieEntity = movieDAO.save(MovieMapper.mapper.toMovieEntity(movie)); return MovieMapper.mapper.toMovieWithDirectorAndCharacterMovies(movieEntity); }
En este caso, 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).
Hay que tener en cuenta que para que todo funcione de manera correcta, el modelo de la capa de negocios debe estar completo (en nuestro caso, con el director y los personajes) para que JPA pueda establecer las relaciones entre las entidades.
El borrado es igual de sencillo, simplemente tendremos que llamar al método delete():
@Override @Transactional public void delete(Movie movie) { movieDAO.delete(MovieMapper.mapper.toMovieEntity(movie)); }