Guía de JPA con Hibernate – Mapeo de relaciones

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 Courses 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.

Deja una respuesta

Tu dirección de correo electrónico no será publicada.