Guía de JPA con Hibernate - Mapeo de relaciones

En este tutorial, nos sumergiremos en el mapeo de relaciones con JPA e Hibernate en Java, con ejemplos de anotaciones de muchos a muchos, de uno a muchos, de muchos a uno y de uno a uno.

Introducción

En este artículo, nos sumergiremos en Asignación de relaciones con JPA e Hibernate en Java.

La API de persistencia de Java (JPA) es el estándar de persistencia del ecosistema de Java. Nos permite mapear nuestro modelo de dominio directamente a la estructura de la base de datos y luego nos brinda la flexibilidad de manipular objetos en nuestro código, en lugar de jugar con componentes JDBC engorrosos como Connection, ResultSet, etc.

Haremos una guía completa para usar JPA con Hibernate como su proveedor. En este artículo, cubriremos las asignaciones de relaciones.

Nuestro ejemplo

Antes de comenzar, recordemos el ejemplo que usamos en la parte anterior de esta serie. La idea era mapear el modelo de una escuela con alumnos tomando cursos impartidos por profesores.

Así es como se ve este modelo:

domain model

Como podemos ver, hay algunas clases con ciertas propiedades. Estas clases tienen relaciones entre ellas. Al final de este artículo, habremos asignado todas esas clases a las tablas de la base de datos, conservando sus relaciones.

Además, podremos recuperarlos y manipularlos como objetos, sin la molestia de JDBC.

Relaciones

En primer lugar, definamos una relación. Si miramos nuestro diagrama de clases, podemos ver algunas relaciones:

Profesores y cursos - alumnos y cursos - cursos y materiales del curso.

También hay conexiones entre estudiantes y direcciones, pero no se consideran relaciones. Esto se debe a que una Dirección no es una entidad (es decir, no está asignada a una tabla propia). Entonces, en lo que respecta a JPA, no es una relación.

Hay algunos tipos de relaciones:

  • Uno a muchos
  • Muchos a uno
  • Cara a cara
  • Muchos a muchos

Abordemos estas relaciones una por una.

Uno a muchos/Muchos a uno {#uno a muchos muchos a uno}

Comenzaremos con las relaciones Uno-a-Muchos y Muchos-a-Uno, que están estrechamente relacionadas. Podría continuar y decir que son los lados opuestos de la misma moneda.

¿Qué es una relación de uno a muchos?

Como su nombre lo indica, es una relación que vincula una entidad con muchas otras entidades.

En nuestro ejemplo, sería un Profesor y sus Cursos. Un profesor puede impartir varios cursos, pero un solo profesor imparte un curso (esa es la perspectiva Muchos a uno: muchos cursos para un 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 foto.

Antes de profundizar en los detalles de cómo mapear esta relación, creemos nuestras entidades:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@Entity
public class Teacher {
    private String firstName;
    private String lastName;
}

@Entity
public class Course {
    private String title;
}

Ahora, los campos de la clase Profesor deben incluir una lista de cursos. Dado que nos gustaría 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:

1
2
@OneToMany
private List<Course> courses;

Hemos usado una Lista como tipo de campo aquí, pero podríamos haber optado por un Conjunto o un Mapa (aunque este requiere [un poco más de configuración](http:// www.java2s.com/Tutorial/Java/0355__JPA/OneToManyMapCollection.htm)).

¿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, dado nuestro aporte sobre cómo debe manejar la relación. Esto se hace a través de la anotación @JoinColumn:

1
2
3
@OneToMany
@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")
private List<Course> courses;

El uso de esta anotación le dirá a JPA que la tabla COURSE debe tener una columna de clave externa TEACHER_ID que haga referencia a la columna ID de la tabla TEACHER.

Agreguemos algunos datos a esas tablas:

1
2
3
4
5
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 vamos a comprobar si la relación funciona como se esperaba:

1
2
3
4
5
6
7
8
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");

Podemos ver que los cursos del profesor se recopilan automáticamente cuando recuperamos la instancia Profesor.

If you're unfamiliar with testing in Java, you might be interested in reading Pruebas unitarias en Java con JUnit 5!

Lado propietario y bidireccionalidad

En el ejemplo anterior, la clase Profesor se llama el lado propietario de la relación Uno-a-muchos. Esto se debe a que define la columna de unión entre las dos tablas.

El Curso se llama el lado de referencia en esa relación.

Podríamos haber convertido Curso en el lado propietario de la relación asignando el campo Profesor con @ManyToOne en la clase Curso en su lugar:

1
2
3
@ManyToOne
@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")
private Teacher teacher;

No hay necesidad de tener una lista de cursos en la clase Profesor ahora. La relación habría funcionado al revés:

1
2
3
4
5
6
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 colocar el lado propietario de una relación en la clase/tabla donde se mantendrá la clave externa.

Entonces, en nuestro caso, esta segunda versión del código es mejor. Pero, ¿qué pasa si todavía queremos que nuestra clase Profesor ofrezca acceso a su lista Curso?

Podemos hacerlo definiendo una relación bidireccional:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@Entity
public class Teacher {
    // ...

    @OneToMany(mappedBy = "teacher")
    private List<Course> courses;
}

@Entity
public class Course {
    // ...
    
    @ManyToOne
    @JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")
    private Teacher teacher;
}

Mantenemos nuestro mapeo @ManyToOne en la entidad Course. Sin embargo, también asignamos una lista de ‘Cursos’ a la entidad ‘Profesor’.

Lo que es importante tener en cuenta aquí es el uso de la bandera mappedBy en la anotación @OneToMany en el lado de 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 él, le estamos diciendo a JPA que el campo ya está asignado por otra entidad. Está mapeado por el campo profesor de la entidad Curso.

Ansioso frente a carga diferida

Otra cosa que vale la pena señalar es la carga ansiosa y perezosa. Con todas nuestras relaciones mapeadas, es aconsejable evitar afectar la memoria del software poniendo demasiadas entidades si no es necesario.

Imagine que ‘Curso’ es un objeto pesado y cargamos todos los objetos ‘Profesor’ de la base de datos para alguna operación. No necesitamos recuperar o usar los cursos para esta operación, pero todavía se cargan junto con los objetos Profesor.

This can be devastating for the application's performance. Technically, this can be solved by using the Patrón de diseño de objeto de transferencia de datos and retrieving Teacher information without the courses.

Sin embargo, esto puede ser una exageración enorme si todo lo que estamos obteniendo del patrón es excluir los cursos.

Afortunadamente, JPA se adelantó e hizo que las relaciones One-to-Many se cargaran perezosamente de forma predeterminada.

Esto significa que la relación no se cargará de inmediato, sino solo cuando sea necesario.

En nuestro ejemplo, eso significaría que hasta que llamemos al método Profesor#cursos, los cursos no se obtendrán de la base de datos.

Por el contrario, las relaciones Muchos a uno son ansiosas por defecto, lo que significa que la relación se carga al mismo tiempo que la entidad.

Podemos cambiar estas características configurando el argumento fetch de ambas anotaciones:

1
2
3
4
5
@OneToMany(mappedBy = "teacher", fetch = FetchType.EAGER)
private List<Course> courses;

@ManyToOne(fetch = FetchType.LAZY)
private Teacher teacher;

Eso sería a la inversa de la forma en que funcionó inicialmente. Los cursos se cargarán rápidamente, tan pronto como carguemos un objeto Profesor. Por el contrario, el profesor no se cargará cuando busquemos cursos si no es necesario en ese momento.

Opcionalidad

Ahora, hablemos de la opcionalidad.

Una relación puede ser opcional u obligatoria.

Teniendo en cuenta el lado One-to-Many, siempre es opcional y no podemos hacer nada al respecto. El lado Many-to-One, por otro lado, nos ofrece la opción de hacerlo obligatorio.

Por defecto, la relación es opcional, lo que significa que podemos guardar un Curso sin asignarle un profesor:

1
2
Course course = new Course("C# 101");
entityManager.persist(course);

Ahora, hagamos que esta relación sea obligatoria. Para hacer eso, usaremos el argumento ‘opcional’ de la anotación ‘@ManyToOne’ y lo estableceremos en ‘falso’ (es ‘verdadero’ por defecto):

1
2
3
@ManyToOne(optional = false)
@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")
private Teacher teacher;

Por lo tanto, ya no podemos guardar un curso sin asignarle un profesor:

1
2
Course course = new Course("C# 101");
assertThrows(Exception.class, () -> entityManager.persist(course));

Pero si le damos un maestro, vuelve a funcionar bien:

1
2
3
4
5
6
7
8
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 parecería. Si hubiéramos ejecutado el código, se habría lanzado una excepción:

1
javax.persistence.PersistenceException: org.hibernate.PersistentObjectException: detached entity passed to persist: com.fdpro.clients.wikihtp.jpa.domain.Course

¿Por qué es esto? Hemos establecido un objeto ‘Profesor’ válido en el objeto ‘Curso’ que estamos tratando de persistir. Sin embargo, no hemos persistido el objeto Profesor antes de intentar persistir el objeto Curso.

Por lo tanto, el objeto Profesor no es una entidad administrada. Arreglemos eso y volvamos a intentarlo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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();

La ejecución de este código persistirá en ambas entidades y conservará la relación entre ellas.

Operaciones en cascada {#operaciones en cascada}

Sin embargo, podríamos haber hecho otra cosa: podríamos haber en cascada y, por lo tanto, propagado la persistencia del objeto Profesor cuando conservamos el objeto Curso.

Esto tiene más sentido y funciona de la manera que esperábamos en el primer ejemplo que generó una excepción.

Para ello, modificaremos la bandera cascada de la anotación:

1
2
3
@ManyToOne(optional = false, cascade = CascadeType.PERSIST)
@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")
private Teacher teacher;

De esta forma, Hibernate también sabe persistir el objeto necesario en esta relación.

Hay varios tipos de operaciones en cascada: PERSIST, MERGE, REMOVE, REFRESH, DETACH y ALL (que combina todas las anteriores).

También podemos poner el argumento de la cascada en el lado Uno-a-Muchos de la relación, de modo que las operaciones también se transmitan en cascada desde los profesores a sus cursos.

uno a uno

Ahora que hemos establecido las bases del mapeo de relaciones en JPA a través de las relaciones Uno-a-Muchos/Muchos-a-Uno y sus configuraciones, podemos pasar a las relaciones Uno-a-Uno.

Esta vez, en lugar de tener una relación entre una entidad de un lado y un montón de entidades del otro, tendremos un máximo de una entidad de cada lado.

Esta es, por ejemplo, la relación entre un Curso y su Material del Curso. Primero mapeemos CourseMaterial, que aún no hemos hecho:

1
2
3
4
5
6
@Entity
public class CourseMaterial {
    @Id
    private Long id;
    private String url;
}

La anotación para mapear una sola entidad a otra sola entidad es, sorprendentemente, @OneToOne.

Antes de configurarlo en nuestro modelo, recordemos que una relación tiene un lado propietario, preferiblemente el lado que contendrá la clave externa 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):

1
2
3
@OneToOne(optional = false)
@JoinColumn(name = "COURSE_ID", referencedColumnName = "ID")
private Course course;

No tiene sentido tener material sin un curso que lo abarque. Es por eso que la relación no es opcional en esa dirección.

Hablando de dirección, hagamos la relación bidireccional, para que podamos acceder al material de un curso si lo tiene. En la clase Curso, agreguemos:

1
2
@OneToOne(mappedBy = "course")
private CourseMaterial material;

Aquí, le estamos diciendo a Hibernate que el material dentro de un Curso ya está mapeado por el campo curso de la entidad Material del Curso.

Además, aquí no hay un atributo opcional ya que es verdadero por defecto, y podríamos imaginar un curso sin material (de un profesor muy perezoso).

Además de hacer que la relación sea bidireccional, también podríamos agregar operaciones en cascada o hacer que las entidades se carguen con entusiasmo o pereza.

Muchos a muchos {#muchos a muchos}

Ahora, por último, pero no menos importante: relaciones muchos a muchos. Dejamos estos para el final porque requieren un poco más de trabajo que los anteriores.

Efectivamente, en una base de datos, una relación Muchos a Muchos involucra una tabla intermedia que hace referencia a ambas otras tablas.

Afortunadamente para nosotros, JPA hace la mayor parte del trabajo, solo tenemos que lanzar algunas anotaciones y se encarga del resto por nosotros.

Entonces, para nuestro ejemplo, la relación Muchos a Muchos será la que existe entre las instancias Estudiante y Curso, ya que un estudiante puede asistir a varios cursos y varios estudiantes pueden seguir un curso.

Para mapear una relación Muchos a Muchos usaremos la anotación @ManyToMany. Sin embargo, esta vez, también usaremos una anotación @JoinTable para configurar la tabla que representa la relación:

1
2
3
4
5
6
7
@ManyToMany
@JoinTable(
  name = "STUDENTS_COURSES",
  joinColumns = @JoinColumn(name = "COURSE_ID", referencedColumnName = "ID"),
  inverseJoinColumns = @JoinColumn(name = "STUDENT_ID", referencedColumnName = "ID")
)
private List<Student> students;

Ahora, revisa lo que está pasando aquí. La anotación toma algunos parámetros. En primer lugar, debemos darle un nombre a la tabla. Lo hemos elegido para que sea STUDENTS_COURSES.

Después de eso, necesitaremos decirle a Hibernate qué columnas unir para completar 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 Curso.

Por otro lado, el parámetro inverseJoinColumns hace lo mismo, pero para el lado de referencia (Student).

Configuremos un conjunto de datos con estudiantes y cursos:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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á fuera de la caja. Tendremos que agregar un método que nos permita agregar estudiantes a un curso. Modifiquemos un poco la clase Course:

1
2
3
4
5
6
7
8
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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 que este código se haya ejecutado, persistirá en nuestras instancias de Curso, Profesor y Estudiante, así como sus relaciones. Por ejemplo, recuperemos a un estudiante de un curso persistente y verifiquemos si todo está bien:

1
2
3
4
5
6
7
Course courseWithMultipleStudents = entityManager.find(Course.class, 1L);

assertThat(courseWithMultipleStudents).isNotNull();
assertThat(courseWithMultipleStudents.students())
  .hasSize(2)
  .extracting(Student::firstName)
  .containsExactly("John", "Will");

Por supuesto, todavía podemos mapear la relación como bidireccional de la misma manera que hicimos con las relaciones anteriores.

También podemos conectar operaciones en cascada, así como definir si las entidades deben cargarse de forma perezosa o ansiosa (las relaciones Muchos a Muchos son perezosas de forma predeterminada).

Conclusión

Eso concluye este artículo sobre las relaciones de las 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 tipos de búsqueda de carga ansiosa/perezosa.

El código de esta serie se puede encontrar en GitHub.