Introducción
En este artículo, nos sumergiremos en el Mapeo de relaciones con JPA e Hibernate en Java.
La API de persistencia de Java (JPA) es el estándar de persistencia del ecosistema Java. Nos permite mapear nuestro modelo de dominio directamente a la estructura de la base de datos y luego nos da la flexibilidad de manipular objetos en nuestro código – en lugar de meternos con engorrosos componentes JDBC como Connection
, ResultSet
, etc.
Vamos a hacer una guía completa para usar JPA con Hibernate como su proveedor. En este artículo, cubriremos los mapeos de relaciones.
- Guía de JPA con Hibernate – Mapeo básico
- Guía de JPA con Hibernate – Mapeo de relaciones (estás aquí)
- Guía de JPA con Hibernate: Mapeo de Herencias (¡próximamente!)
- Guía de JPA con Hibernate – Consultas (¡próximamente!)
Nuestro Ejemplo
Antes de empezar, vamos a recordar el ejemplo que utilizamos en la parte anterior de esta serie. Se trataba de mapear el modelo de una escuela con alumnos que toman cursos impartidos por profesores.
Aquí tenemos el aspecto de este modelo:
Como podemos ver, hay unas cuantas clases con ciertas propiedades. Estas clases tienen relaciones entre ellas. Al final de este artículo, habremos mapeado todas esas clases a tablas de la base de datos, conservando sus relaciones.
Además, podremos recuperarlas y manipularlas como objetos, sin el engorro de JDBC.
Relaciones
En primer lugar, vamos a definir una relación. Si miramos nuestro diagrama de clases podemos ver unas cuantas relaciones:
Profesores y cursos – alumnos y cursos – cursos y materiales de los cursos.
También hay relaciones entre alumnos y direcciones, pero no se consideran relaciones. Esto se debe a que un Address
no es una entidad (es decir, no está mapeado a una tabla propia). Así que, en lo que respecta a JPA, no es una relación.
Hay algunos tipos de relaciones:
- De uno a muchos
- De muchos a uno
- De uno a uno
- De muchos a muchos
Abordemos estas relaciones una por una.
Uno a muchos/Muchos a uno
Comenzaremos con las relaciones Uno a muchos y Muchos a uno, que están estrechamente relacionadas. Podríamos decir que son las caras opuestas de la misma moneda.
¿Qué es una relación Uno-a-Muchos?
Como su nombre indica, es una relación que une una entidad con muchas otras entidades.
En nuestro ejemplo, se trataría de un Teacher
y su Courses
. Un profesor puede impartir múltiples cursos, pero un curso es impartido por un solo profesor (esa es la perspectiva Many-to-One – muchos cursos a un solo profesor).
Otro ejemplo podría ser en las redes sociales – una foto puede tener muchos comentarios, pero cada uno de esos comentarios pertenece a esa única foto.
Antes de entrar en los detalles de cómo mapear esta relación, vamos a crear nuestras entidades:
@Entitypublic class Teacher { private String firstName; private String lastName;}@Entitypublic class Course { private String title;}
Ahora, los campos de la clase Teacher
deben incluir una lista de cursos. Como queremos mapear esta relación en una base de datos, que no puede incluir una lista de entidades dentro de otra entidad, la anotaremos con una anotación @OneToMany
:
@OneToManyprivate List<Course> courses;
Aquí hemos utilizado un List
como tipo de campo, pero podríamos haber optado por un Set
o un Map
(aunque éste requiere un poco más de configuración).
¿Cómo refleja JPA esta relación en la base de datos? Generalmente, para este tipo de relación, debemos utilizar una clave foránea en una tabla.
JPA hace esto por nosotros, dada nuestra entrada sobre cómo debe manejar la relación. Esto se hace a través de la anotación @JoinColumn
:
@OneToMany@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private List<Course> courses;
Usar esta anotación le dirá a JPA que la tabla COURSE
debe tener una columna de clave foránea TEACHER_ID
que haga referencia a la columna ID
de la tabla TEACHER
.
Agreguemos algunos datos a esas tablas:
insert into TEACHER(ID, LASTNAME, FIRSTNAME) values(1, 'Doe', 'Jane');insert into COURSE(ID, TEACHER_ID, TITLE) values(1, 1, 'Java 101');insert into COURSE(ID, TEACHER_ID, TITLE) values(2, 1, 'SQL 101');insert into COURSE(ID, TEACHER_ID, TITLE) values(3, 1, 'JPA 101');
Y ahora comprobemos si la relación funciona como se espera:
Teacher foundTeacher = entityManager.find(Teacher.class, 1L);assertThat(foundTeacher.id()).isEqualTo(1L);assertThat(foundTeacher.lastName()).isEqualTo("Doe");assertThat(foundTeacher.firstName()).isEqualTo("Jane");assertThat(foundTeacher.courses()) .extracting(Course::title) .containsExactly("Java 101", "SQL 101", "JPA 101");
Vemos que los cursos del profesor se recogen automáticamente, cuando recuperamos la instancia Teacher
.
¡Si no estás familiarizado con las pruebas en Java, puede que te interese leer Pruebas unitarias en Java con JUnit 5!
Lado propio y bidireccionalidad
En el ejemplo anterior, la clase Teacher
se llama lado propio de la relación Uno-a-Muchos. Esto se debe a que define la columna de unión entre las dos tablas.
La clase Course
se llama el lado de referencia en esa relación.
Podríamos haber hecho que Course
fuera el lado propietario de la relación mapeando el campo Teacher
con @ManyToOne
en la clase Course
en su lugar:
@ManyToOne@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private Teacher teacher;
No hay necesidad de tener una lista de cursos en la clase Teacher
. La relación habría funcionado al revés:
Course foundCourse = entityManager.find(Course.class, 1L);assertThat(foundCourse.id()).isEqualTo(1L);assertThat(foundCourse.title()).isEqualTo("Java 101");assertThat(foundCourse.teacher().lastName()).isEqualTo("Doe");assertThat(foundCourse.teacher().firstName()).isEqualTo("Jane");
Esta vez, usamos la anotación @ManyToOne
, de la misma manera que usamos @OneToMany
.
Nota: Es una buena práctica poner el lado propietario de una relación en la clase/tabla donde se tendrá la clave foránea.
Así que, en nuestro caso esta segunda versión del código es mejor. Pero, ¿qué pasa si todavía queremos que nuestra clase Teacher
ofrezca acceso a su lista Course
?
Podemos hacerlo definiendo una relación bidireccional:
@Entitypublic class Teacher { // ... @OneToMany(mappedBy = "teacher") private List<Course> courses;}@Entitypublic class Course { // ... @ManyToOne @JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID") private Teacher teacher;}
Mantenemos nuestro mapeo @ManyToOne
en la entidad Course
. Sin embargo, también mapeamos una lista de Course
s a la entidad Teacher
.
Lo que es importante notar aquí es el uso de la bandera mappedBy
en la anotación @OneToMany
en el lado de la referencia.
Sin ella, no tendríamos una relación bidireccional. Tendríamos dos relaciones unidireccionales. Ambas entidades estarían mapeando claves foráneas para la otra entidad.
Con ella, le estamos diciendo a JPA que el campo ya está mapeado por otra entidad. Está mapeado por el campo teacher
de la entidad Course
.
Eager vs Lazy Loading
Otra cosa que merece la pena destacar es la carga eager y lazy. Con todas nuestras relaciones mapeadas, es prudente evitar el impacto en la memoria del software poniendo demasiadas entidades si es innecesario.
Imagina que Course
es un objeto pesado, y cargamos todos los objetos Teacher
de la base de datos para alguna operación. No necesitamos recuperar o utilizar los cursos para esta operación, pero todavía se están cargando junto a los objetos Teacher
.
Esto puede ser devastador para el rendimiento de la aplicación. Técnicamente, esto puede resolverse utilizando el patrón de diseño de objetos de transferencia de datos y recuperando la información Teacher
sin los cursos.
Sin embargo, esto puede ser una exageración masiva si todo lo que estamos ganando del patrón es excluir los cursos.
Por suerte, JPA se adelantó e hizo que las relaciones Uno-a-Muchos se cargaran de forma perezosa por defecto.
Esto significa que la relación no se cargará de inmediato, sino sólo cuando y si realmente se necesita.
En nuestro ejemplo, eso significaría que hasta que no llamemos al método Teacher#courses
, los cursos no están siendo recuperados de la base de datos.
Por el contrario, las relaciones Many-to-One son eager por defecto, lo que significa que la relación se carga al mismo tiempo que la entidad.
Podemos cambiar estas características estableciendo el argumento fetch
de ambas anotaciones:
@OneToMany(mappedBy = "teacher", fetch = FetchType.EAGER)private List<Course> courses;@ManyToOne(fetch = FetchType.LAZY)private Teacher teacher;
Eso invertiría el funcionamiento inicial. Los cursos se cargarían ansiosamente, tan pronto como carguemos un objeto Teacher
. Por el contrario, el teacher
no se cargaría cuando recuperamos courses
si no es necesario en ese momento.
Opcionalidad
Ahora, hablemos de la opcionalidad.
Una relación puede ser opcional u obligatoria.
Considerando el lado Uno-a-Muchos – siempre es opcional, y no podemos hacer nada al respecto. El lado Muchos-a-Uno, en cambio, nos ofrece la opción de hacerlo obligatorio.
Por defecto, la relación es opcional, lo que significa que podemos guardar un Course
sin asignarle un profesor:
Course course = new Course("C# 101");entityManager.persist(course);
Ahora, hagamos esta relación obligatoria. Para ello, utilizaremos el argumento optional
de la anotación @ManyToOne
y lo pondremos a false
(por defecto es true
):
@ManyToOne(optional = false)@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private Teacher teacher;
Así, ya no podemos guardar un curso sin asignarle un profesor:
Course course = new Course("C# 101");assertThrows(Exception.class, () -> entityManager.persist(course));
Pero si le damos un profesor, vuelve a funcionar bien:
Teacher teacher = new Teacher();teacher.setLastName("Doe");teacher.setFirstName("Will");Course course = new Course("C# 101");course.setTeacher(teacher);entityManager.persist(course);
Bueno, al menos, eso parece. Si hubiéramos ejecutado el código, se habría lanzado una excepción:
javax.persistence.PersistenceException: org.hibernate.PersistentObjectException: detached entity passed to persist: com.fdpro.clients.stackabuse.jpa.domain.Course
¿Por qué? Hemos puesto un objeto Teacher
válido en el objeto Course
que intentamos persistir. Sin embargo, no hemos persistido el objeto Teacher
antes de intentar persistir el objeto Course
.
Por lo tanto, el objeto Teacher
no es una entidad gestionada. Arreglemos esto e intentémoslo de nuevo:
Teacher teacher = new Teacher();teacher.setLastName("Doe");teacher.setFirstName("Will");entityManager.persist(teacher);Course course = new Course("C# 101");course.setTeacher(teacher);entityManager.persist(course);entityManager.flush();
Ejecutando este código se persistirán ambas entidades y se conservará la relación entre ellas.
Operaciones en cascada
Sin embargo, podríamos haber hecho otra cosa – podríamos haber hecho cascada, y así propagar la persistencia del objeto Teacher
al persistir el objeto Course
.
Esto tiene más sentido y funciona de la manera que esperaríamos como en el primer ejemplo que lanzó una excepción.
Para hacer esto, modificaremos la bandera cascade
de la anotación:
@ManyToOne(optional = false, cascade = CascadeType.PERSIST)@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private Teacher teacher;
De esta manera, Hibernate sabe que debe persistir el objeto necesario en esta relación también.
Hay múltiples tipos de operaciones en cascada: PERSIST
, MERGE
, REMOVE
, REFRESH
, DETACH
, y ALL
(que combina todas las anteriores).
También podemos poner el argumento de cascada en el lado Uno a Muchos de la relación, para que las operaciones vayan en cascada desde los profesores a sus cursos también.
One-to-One
Ahora que hemos establecido las bases del mapeo de relaciones en JPA a través de las relaciones One-to-Many/Many-to-One y sus configuraciones, podemos pasar a las relaciones One-to-One.
Esta vez, en lugar de tener una relación entre una entidad en un lado y un montón de entidades en el otro, tendremos un máximo de una entidad en cada lado.
Esto es, por ejemplo, la relación entre un Course
y su CourseMaterial
. Primero mapeemos CourseMaterial
, cosa que aún no hemos hecho:
@Entitypublic class CourseMaterial { @Id private Long id; private String url;}
La anotación para mapear una única entidad a otra única entidad es, sorprendentemente, @OneToOne
.
Antes de configurarla en nuestro modelo, recordemos que una relación tiene un lado propio – preferiblemente el lado que tendrá la clave foránea en la base de datos.
En nuestro ejemplo, sería CourseMaterial
ya que tiene sentido que haga referencia a un Course
(aunque podríamos ir al revés):
@OneToOne(optional = false)@JoinColumn(name = "COURSE_ID", referencedColumnName = "ID")private Course course;
No tiene sentido tener material sin un curso que lo englobe. Por eso la relación no es optional
en esa dirección.
Hablando de dirección, hagamos la relación bidireccional, para poder acceder al material de un curso si lo tiene. En la clase Course
, añadamos:
@OneToOne(mappedBy = "course")private CourseMaterial material;
Aquí le estamos diciendo a Hibernate que el material dentro de un Course
ya está mapeado por el campo course
de la entidad CourseMaterial
.
Además, aquí no hay atributo optional
ya que es true
por defecto, y podríamos imaginar un curso sin material (de un profesor muy vago).
Además de hacer la relación bidireccional, también podríamos añadir operaciones en cascada o hacer que las entidades se carguen de forma ansiosa o perezosa.
Many-to-Many
Ahora, por último: Las relaciones muchos-a-muchos. Las dejamos para el final porque requieren un poco más de trabajo que las anteriores.
Efectivamente, en una base de datos, una relación Many-to-Many implica una tabla intermedia que hace referencia a las otras dos tablas.
Por suerte para nosotros, JPA hace la mayor parte del trabajo, sólo tenemos que lanzar unas cuantas anotaciones, y se encarga del resto por nosotros.
Por lo tanto, para nuestro ejemplo, la relación Many-to-Many será la existente entre las instancias Student
y Course
, ya que un estudiante puede asistir a múltiples cursos, y un curso puede ser seguido por múltiples estudiantes.
Para mapear una relación Many-to-Many utilizaremos la anotación @ManyToMany
. Sin embargo, esta vez también utilizaremos una anotación @JoinTable
para configurar la tabla que representa la relación:
@ManyToMany@JoinTable( name = "STUDENTS_COURSES", joinColumns = @JoinColumn(name = "COURSE_ID", referencedColumnName = "ID"), inverseJoinColumns = @JoinColumn(name = "STUDENT_ID", referencedColumnName = "ID"))private List<Student> students;
Ahora, repasa lo que ocurre aquí. La anotación toma algunos parámetros. En primer lugar, debemos dar un nombre a la tabla. Hemos elegido que sea STUDENTS_COURSES
.
Después, tendremos que decirle a Hibernate qué columnas debe unir para poblar STUDENTS_COURSES
. El primer parámetro, joinColumns
define cómo configurar la columna de unión (clave externa) del lado propietario de la relación en la tabla. En este caso, el lado propietario es un Course
.
Por otro lado, el parámetro inverseJoinColumns
hace lo mismo, pero para el lado referenciador (Student
).
Configuremos un conjunto de datos con estudiantes y cursos:
Student johnDoe = new Student();johnDoe.setFirstName("John");johnDoe.setLastName("Doe");johnDoe.setBirthDateAsLocalDate(LocalDate.of(2000, FEBRUARY, 18));johnDoe.setGender(MALE);johnDoe.setWantsNewsletter(true);johnDoe.setAddress(new Address("Baker Street", "221B", "London"));entityManager.persist(johnDoe);Student willDoe = new Student();willDoe.setFirstName("Will");willDoe.setLastName("Doe");willDoe.setBirthDateAsLocalDate(LocalDate.of(2001, APRIL, 4));willDoe.setGender(MALE);willDoe.setWantsNewsletter(false);willDoe.setAddress(new Address("Washington Avenue", "23", "Oxford"));entityManager.persist(willDoe);Teacher teacher = new Teacher();teacher.setFirstName("Jane");teacher.setLastName("Doe");entityManager.persist(teacher);Course javaCourse = new Course("Java 101");javaCourse.setTeacher(teacher);entityManager.persist(javaCourse);Course sqlCourse = new Course("SQL 101");sqlCourse.setTeacher(teacher);entityManager.persist(sqlCourse);
Por supuesto, esto no funcionará de forma inmediata. Tendremos que añadir un método que nos permita añadir alumnos a un curso. Modifiquemos un poco la clase Course
:
public class Course { private List<Student> students = new ArrayList<>(); public void addStudent(Student student) { this.students.add(student); }}
Ahora, podemos completar nuestro conjunto de datos:
Course javaCourse = new Course("Java 101");javaCourse.setTeacher(teacher);javaCourse.addStudent(johnDoe);javaCourse.addStudent(willDoe);entityManager.persist(javaCourse);Course sqlCourse = new Course("SQL 101");sqlCourse.setTeacher(teacher);sqlCourse.addStudent(johnDoe);entityManager.persist(sqlCourse);
Una vez ejecutado este código, persistirá nuestras instancias Course
, Teacher
y Student
así como sus relaciones. Por ejemplo, recuperemos un alumno de un curso persistido y comprobemos si todo está bien:
Course courseWithMultipleStudents = entityManager.find(Course.class, 1L);assertThat(courseWithMultipleStudents).isNotNull();assertThat(courseWithMultipleStudents.students()) .hasSize(2) .extracting(Student::firstName) .containsExactly("John", "Will");
Por supuesto, podemos seguir mapeando la relación como bidireccional de la misma manera que hicimos con las relaciones anteriores.
También podemos realizar operaciones en cascada así como definir si las entidades deben cargarse de forma perezosa o ansiosa (las relaciones Many-to-Many son perezosas por defecto).
Conclusión
Con esto concluye este artículo sobre relaciones de entidades mapeadas con JPA. Hemos cubierto las relaciones Muchos-a-Uno, Uno-a-Muchos, Muchos-a-Muchos y Uno-a-Uno. Además, hemos explorado las operaciones en cascada, la bidireccionalidad, la opcionalidad y los fetch-types eager/lazy loading.
El código de esta serie se puede encontrar en GitHub.