Guida a JPA con Hibernate – Relationship Mapping

Introduzione

In questo articolo, ci immergeremo nel Relationship Mapping con JPA e Hibernate in Java.

La Java Persistence API (JPA) è lo standard di persistenza dell’ecosistema Java. Ci permette di mappare il nostro modello di dominio direttamente alla struttura del database e quindi ci dà la flessibilità di manipolare gli oggetti nel nostro codice – invece di pasticciare con ingombranti componenti JDBC come Connection, ResultSet, ecc.

Faremo una guida completa per usare JPA con Hibernate come suo fornitore. In questo articolo, tratteremo la mappatura delle relazioni.

  • Guida a JPA con Hibernate – Mappatura di base
  • Guida a JPA con Hibernate – Mappatura delle relazioni (sei qui)
  • Guida a JPA con Hibernate: Inheritance Mapping (coming soon!)
  • Guida a JPA con Hibernate – Querying (Coming soon!)

Il nostro esempio

Prima di iniziare, ricordiamoci l’esempio che abbiamo usato nella parte precedente di questa serie. L’idea era di mappare il modello di una scuola con studenti che seguono corsi tenuti da insegnanti.

Ecco come appare questo modello:

Come possiamo vedere, ci sono alcune classi con certe proprietà. Queste classi hanno relazioni tra loro. Entro la fine di questo articolo, avremo mappato tutte queste classi in tabelle di database, conservando le loro relazioni.

Inoltre, saremo in grado di recuperarle e manipolarle come oggetti, senza il fastidio di JDBC.

Relazioni

Prima di tutto, definiamo una relazione. Se guardiamo il nostro diagramma di classe possiamo vedere alcune relazioni:

Insegnanti e corsi – studenti e corsi – corsi e materiale didattico.

Ci sono anche connessioni tra studenti e indirizzi, ma non sono considerate relazioni. Questo perché un Address non è un’entità (cioè non è mappato su una tabella propria). Quindi, per quanto riguarda JPA, non è una relazione.

Ci sono alcuni tipi di relazioni:

  • One-to-Many
  • Many-to-One
  • One-to-One
  • Many-to-Many

affrontiamo queste relazioni una per una.

One-to-Many/Many-to-One

Iniziamo con le relazioni One-to-Many e Many-to-One, che sono strettamente correlate. Si potrebbe dire che sono i lati opposti della stessa medaglia.

Cos’è una relazione One-to-Many?

Come dice il nome, è una relazione che collega un’entità a molte altre entità.

Nel nostro esempio, questa sarebbe una Teacher e la loro Courses. Un insegnante può dare più corsi, ma un corso è dato da un solo insegnante (questa è la prospettiva Many-to-One – molti corsi per un insegnante).

Un altro esempio potrebbe essere sui social media – una foto può avere molti commenti, ma ognuno di questi commenti appartiene a quella foto.

Prima di entrare nei dettagli di come mappare questa relazione, creiamo le nostre entità:

@Entitypublic class Teacher { private String firstName; private String lastName;}@Entitypublic class Course { private String title;}

Ora, i campi della classe Teacher dovrebbero includere una lista di corsi. Poiché vorremmo mappare questa relazione in un database, che non può includere una lista di entità all’interno di un’altra entità – la annoteremo con un’annotazione @OneToMany:

@OneToManyprivate List<Course> courses;

Abbiamo usato un List come tipo di campo qui, ma avremmo potuto scegliere un Set o un Map (anche se questo richiede un po’ più di configurazione).

Come fa JPA a riflettere questa relazione nel database? Generalmente, per questo tipo di relazione, dobbiamo usare una chiave esterna in una tabella.

JPA lo fa per noi, dato il nostro input su come dovrebbe gestire la relazione. Questo viene fatto tramite l’annotazione @JoinColumn:

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

Utilizzando questa annotazione si dirà a JPA che la tabella COURSE deve avere una colonna foreign key TEACHER_ID che faccia riferimento alla colonna ID della tabella TEACHER.

Aggiungiamo alcuni dati a queste tabelle:

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');

E ora controlliamo se la relazione funziona come previsto:

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");

Vediamo che i corsi dell’insegnante sono raccolti automaticamente, quando recuperiamo l’istanza Teacher.

Se non hai familiarità con i test in Java, potresti essere interessato a leggere Unit Testing in Java con JUnit 5!

Lato proprietario e bidirezionalità

Nell’esempio precedente, la classe Teacher è chiamata il lato proprietario della relazione One-To-Many. Questo perché definisce la colonna di unione tra le due tabelle.

La classe Course è chiamata il lato di riferimento in questa relazione.

Abbiamo potuto rendere Course il lato proprietario della relazione mappando il campo Teacher con @ManyToOne nella classe Course invece:

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

Non c’è bisogno di avere una lista di corsi nella classe Teacher ora. La relazione avrebbe funzionato al contrario:

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");

Questa volta, abbiamo usato l’annotazione @ManyToOne, nello stesso modo in cui abbiamo usato @OneToMany.

Nota: è una buona pratica mettere il lato proprietario di una relazione nella classe/tabella dove si terrà la chiave esterna.

Quindi, nel nostro caso questa seconda versione del codice è migliore. Ma cosa succede se vogliamo ancora che la nostra classe Teacher offra l’accesso alla sua lista Course?

Possiamo farlo definendo una relazione bidirezionale:

@Entitypublic class Teacher { // ... @OneToMany(mappedBy = "teacher") private List<Course> courses;}@Entitypublic class Course { // ... @ManyToOne @JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID") private Teacher teacher;}

Manteniamo la nostra mappatura @ManyToOne sull’entità Course. Tuttavia, mappiamo anche una lista di Course sull’entità Teacher.

Quello che è importante notare qui è l’uso del flag mappedBy nell’annotazione @OneToMany sul lato di riferimento.

Senza questo, non avremmo una relazione bidirezionale. Avremmo due relazioni unidirezionali. Entrambe le entità starebbero mappando le chiavi esterne per l’altra entità.

Con essa, stiamo dicendo a JPA che il campo è già mappato da un’altra entità. È mappato dal campo teacher dell’entità Course.

Eager vs Lazy Loading

Un’altra cosa che vale la pena notare è il caricamento eager e lazy. Con tutte le nostre relazioni mappate, è saggio evitare di impattare la memoria del software mettendovi troppe entità se non è necessario.

Immaginate che Course sia un oggetto pesante, e carichiamo tutti gli oggetti Teacher dal database per qualche operazione. Non abbiamo bisogno di recuperare o utilizzare i corsi per questa operazione, ma vengono comunque caricati insieme agli oggetti Teacher.

Questo può essere devastante per le prestazioni dell’applicazione. Tecnicamente, questo può essere risolto usando il Data Transfer Object Design Pattern e recuperando le informazioni Teacher senza i corsi.

Tuttavia, questo può essere un enorme sovraccarico se tutto ciò che otteniamo dal pattern è l’esclusione dei corsi.

Grazie a Dio, JPA ha pensato in anticipo e ha reso le relazioni One-to-Many caricate pigramente per default.

Questo significa che la relazione non sarà caricata subito, ma solo quando e se effettivamente necessario.

Nel nostro esempio, questo significherebbe che finché non chiamiamo il metodo Teacher#courses, i corsi non vengono recuperati dal database.

Al contrario, le relazioni Many-to-One sono eager per default, cioè la relazione viene caricata nello stesso momento in cui viene caricata l’entità.

Possiamo cambiare queste caratteristiche impostando l’argomento fetch di entrambe le annotazioni:

@OneToMany(mappedBy = "teacher", fetch = FetchType.EAGER)private List<Course> courses;@ManyToOne(fetch = FetchType.LAZY)private Teacher teacher;

Questo invertirebbe il modo in cui funzionava inizialmente. I corsi verrebbero caricati avidamente, non appena carichiamo un oggetto Teacher. Al contrario, il teacher non verrebbe caricato quando prendiamo courses se non è necessario in quel momento.

Opzionalità

Ora, parliamo di opzionalità.

Una relazione può essere opzionale o obbligatoria.

Considerando il lato One-to-Many – è sempre opzionale, e non possiamo farci nulla. Il lato Many-to-One, invece, ci offre la possibilità di renderla obbligatoria.

Di default, la relazione è opzionale, il che significa che possiamo salvare un Course senza assegnargli un insegnante:

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

Ora, rendiamo questa relazione obbligatoria. Per farlo, usiamo l’argomento optional dell’annotazione @ManyToOne e lo impostiamo a false (di default è true):

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

Così, non possiamo più salvare un corso senza assegnargli un insegnante:

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

Ma se gli diamo un insegnante, funziona di nuovo bene:

Teacher teacher = new Teacher();teacher.setLastName("Doe");teacher.setFirstName("Will");Course course = new Course("C# 101");course.setTeacher(teacher);entityManager.persist(course);

Beh, almeno, così sembra. Se avessimo eseguito il codice, sarebbe stata lanciata un’eccezione:

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

Perché? Abbiamo impostato un oggetto Teacher valido nell’oggetto Course che stiamo cercando di persistere. Tuttavia, non abbiamo persistito l’oggetto Teacher prima di provare a persistere l’oggetto Course.

Quindi, l’oggetto Teacher non è un’entità gestita. Correggiamo il problema e riproviamo:

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();

Eseguendo questo codice, entrambe le entità saranno persistite e la relazione tra di esse sarà mantenuta.

Operazioni a cascata

Potremmo però fare un’altra cosa – avremmo potuto fare il cascading, e quindi propagare la persistenza dell’oggetto Teacher quando persistiamo l’oggetto Course.

Questo ha più senso e funziona come ci aspetteremmo nel primo esempio che ha lanciato un’eccezione.

Per fare questo, modificheremo il flag cascade dell’annotazione:

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

In questo modo, Hibernate sa di persistere anche l’oggetto necessario in questa relazione.

Ci sono più tipi di operazioni in cascata: PERSIST, MERGE, REMOVE, REFRESH, DETACH, e ALL (che combina tutte le precedenti).

Possiamo anche mettere l’argomento cascade sul lato One-to-Many della relazione, in modo che le operazioni siano in cascata dagli insegnanti ai loro corsi.

One-to-One

Ora che abbiamo posto le basi della mappatura delle relazioni in JPA attraverso le relazioni One-to-Many/Many-to-One e le loro impostazioni, possiamo passare alle relazioni One-to-One.

Questa volta, invece di avere una relazione tra un’entità da una parte e un mucchio di entità dall’altra, avremo al massimo un’entità per lato.

Questa è, per esempio, la relazione tra un Course e il suo CourseMaterial. Per prima cosa mappiamo CourseMaterial, cosa che non abbiamo ancora fatto:

@Entitypublic class CourseMaterial { @Id private Long id; private String url;}

L’annotazione per la mappatura di una singola entità a una singola altra entità è, sorprendentemente, @OneToOne.

Prima di impostarla nel nostro modello, ricordiamo che una relazione ha un lato proprietario – preferibilmente il lato che terrà la chiave esterna nel database.

Nel nostro esempio, sarebbe CourseMaterial perché ha senso che faccia riferimento a Course (anche se potremmo fare il contrario):

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

Non ha senso avere del materiale senza un corso che lo comprenda. Ecco perché la relazione non è optional in quella direzione.

Parlando di direzione, rendiamo la relazione bidirezionale, così possiamo accedere al materiale di un corso se ne ha uno. Nella classe Course, aggiungiamo:

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

Qui, stiamo dicendo a Hibernate che il materiale all’interno di un Course è già mappato dal campo course dell’entità CourseMaterial.

Inoltre, qui non c’è l’attributo optional perché è true di default, e potremmo immaginare un corso senza materiale (da un insegnante molto pigro).

Oltre a rendere la relazione bidirezionale, potremmo anche aggiungere operazioni a cascata o fare in modo che le entità si carichino avidamente o pigramente.

Many-to-Many

Ora, ultimo ma non meno importante: Relazioni molti a molti. Le abbiamo tenute per la fine perché richiedono un po’ più lavoro delle precedenti.

Effettivamente, in un database, una relazione Molti a Molti comporta una tabella centrale che fa riferimento ad entrambe le altre tabelle.

Per nostra fortuna, JPA fa la maggior parte del lavoro, dobbiamo solo buttare lì qualche annotazione, e lui gestisce il resto per noi.

Così, per il nostro esempio, la relazione Many-to-Many sarà quella tra le istanze Student e Course poiché uno studente può frequentare più corsi, e un corso può essere seguito da più studenti.

Per mappare una relazione Many-to-Many useremo l’annotazione @ManyToMany. Tuttavia, questa volta, useremo anche un’annotazione @JoinTable per impostare la tabella che rappresenta la relazione:

@ManyToMany@JoinTable( name = "STUDENTS_COURSES", joinColumns = @JoinColumn(name = "COURSE_ID", referencedColumnName = "ID"), inverseJoinColumns = @JoinColumn(name = "STUDENT_ID", referencedColumnName = "ID"))private List<Student> students;

Ora, analizziamo cosa sta succedendo qui. L’annotazione richiede alcuni parametri. Prima di tutto, dobbiamo dare un nome alla tabella. Abbiamo scelto che sia STUDENTS_COURSES.

Dopo di che, dovremo dire a Hibernate quali colonne unire per popolare STUDENTS_COURSES. Il primo parametro, joinColumns definisce come configurare la colonna di unione (foreign key) del lato proprietario della relazione nella tabella. In questo caso, il lato proprietario è un Course.

D’altra parte, il parametro inverseJoinColumns fa lo stesso, ma per il lato di riferimento (Student).

Impostiamo un set di dati con studenti e corsi:

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);

Ovviamente, questo non funzionerà immediatamente. Dovremo aggiungere un metodo che ci permetta di aggiungere studenti a un corso. Modifichiamo un po’ la classe Course:

public class Course { private List<Student> students = new ArrayList<>(); public void addStudent(Student student) { this.students.add(student); }}

Ora, possiamo completare il nostro set di dati:

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 volta che questo codice è stato eseguito, persisterà le nostre istanze Course, Teacher e Student e le loro relazioni. Per esempio, recuperiamo uno studente da un corso persistente e controlliamo se tutto va bene:

Course courseWithMultipleStudents = entityManager.find(Course.class, 1L);assertThat(courseWithMultipleStudents).isNotNull();assertThat(courseWithMultipleStudents.students()) .hasSize(2) .extracting(Student::firstName) .containsExactly("John", "Will");

Ovviamente, possiamo ancora mappare la relazione come bidirezionale come abbiamo fatto per le relazioni precedenti.

Possiamo anche effettuare operazioni a cascata e definire se le entità devono essere caricate pigramente o avidamente (le relazioni Many-to-Many sono pigre per default).

Conclusione

Conclude questo articolo sulle relazioni di entità mappate con JPA. Abbiamo coperto le relazioni Many-to-One, One-to-Many, Many-to-Many e One-to-One. Inoltre, abbiamo esplorato le operazioni a cascata, la bidirezionalità, l’opzionalità e il caricamento eager/lazy dei tipi di fetch.

Il codice di questa serie può essere trovato su GitHub.

Lascia un commento

Il tuo indirizzo email non sarà pubblicato.