Introducere
În acest articol, ne vom scufunda în Maparea relațiilor cu JPA și Hibernate în Java.
Apif de persistență Java (JPA) este standardul de persistență al ecosistemului Java. Ne permite să mapăm modelul nostru de domeniu direct pe structura bazei de date și apoi ne oferă flexibilitatea de a manipula obiecte în codul nostru – în loc să ne încurcăm cu componente JDBC greoaie, cum ar fi Connection
, ResultSet
, etc.
Vom realiza un ghid cuprinzător de utilizare a JPA cu Hibernate ca furnizor al acestuia. În acest articol, vom aborda mapările de relații.
- Ghid pentru JPA cu Hibernate – Mapare de bază
- Ghid pentru JPA cu Hibernate – Mapare de relații (ești aici)
- Ghid pentru JPA cu Hibernate: Inheritance Mapping (în curând!)
- Guide to JPA with Hibernate – Querying (în curând!)
Exemplul nostru
Înainte de a începe, să ne reamintim exemplul pe care l-am folosit în partea anterioară a acestei serii. Ideea a fost să cartografiem modelul unei școli cu elevi care urmează cursuri ținute de profesori.
Iată cum arată acest model:
După cum putem vedea, există câteva clase cu anumite proprietăți. Aceste clase au relații între ele. Până la sfârșitul acestui articol, vom fi cartografiat toate aceste clase în tabele de baze de date, păstrând relațiile dintre ele.
În plus, vom putea să le recuperăm și să le manipulăm ca obiecte, fără a ne mai chinui cu JDBC.
Relații
În primul rând, să definim o relație. Dacă ne uităm la diagrama clasei noastre, putem vedea câteva relații:
Profesori și cursuri – studenți și cursuri – cursuri și materiale de curs.
Există, de asemenea, legături între studenți și adrese, dar acestea nu sunt considerate relații. Acest lucru se datorează faptului că un Address
nu este o entitate (adică nu este mapat într-un tabel propriu). Deci, în ceea ce privește JPA, nu este o relație.
Există câteva tipuri de relații:
- One-to-Many
- Many-to-One
- One-to-One
- Many-to-Many
Să abordăm aceste relații una câte una.
One-to-Many/Many-to-One
Vom începe cu relațiile One-to-Many și Many-to-One, care sunt strâns legate între ele. Ați putea spune că sunt fețele opuse ale aceleiași monede.
Ce este o relație Unu-la-Mulțime?
După cum îi spune și numele, este o relație care leagă o entitate de multe alte entități.
În exemplul nostru, aceasta ar fi un Teacher
și Courses
al lor. Un profesor poate ține mai multe cursuri, dar un curs este ținut de un singur profesor (aceasta este perspectiva Many-to-One – multe cursuri pentru un singur profesor).
Un alt exemplu ar putea fi în social media – o fotografie poate avea multe comentarii, dar fiecare dintre aceste comentarii aparține acelei fotografii.
Înainte de a ne scufunda în detalii despre cum să cartografiem această relație, haideți să ne creăm entitățile:
@Entitypublic class Teacher { private String firstName; private String lastName;}@Entitypublic class Course { private String title;}
Acum, câmpurile clasei Teacher
ar trebui să includă o listă de cursuri. Deoarece am dori să cartografiem această relație într-o bază de date, care nu poate include o listă de entități în cadrul altei entități – o vom adnota cu o adnotare @OneToMany
:
@OneToManyprivate List<Course> courses;
Am folosit un List
ca tip de câmp aici, dar am fi putut opta pentru un Set
sau un Map
(deși acesta din urmă necesită un pic mai multă configurare).
Cum reflectă JPA această relație în baza de date? În general, pentru acest tip de relație, trebuie să folosim o cheie străină într-o tabelă.
JPA face acest lucru pentru noi, având în vedere informațiile noastre privind modul în care ar trebui să gestioneze relația. Acest lucru se face prin intermediul adnotării @JoinColumn
:
@OneToMany@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private List<Course> courses;
Utilizarea acestei adnotări îi va spune lui JPA că tabelul COURSE
trebuie să aibă o coloană cheie externă TEACHER_ID
care face referire la coloana ID
a tabelului TEACHER
.
Să adăugăm câteva date în aceste tabele:
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');
Și acum să verificăm dacă relația funcționează conform așteptărilor:
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");
Vezi că cursurile profesorului sunt adunate automat, atunci când recuperăm instanța Teacher
.
Dacă nu sunteți familiarizați cu testarea în Java, ați putea fi interesați să citiți Testarea unitară în Java cu JUnit 5!
Latura proprietară și bidirecționalitatea
În exemplul anterior, clasa Teacher
este numită partea proprietară a relației Unu-la-Mulțime. Acest lucru se datorează faptului că definește coloana de îmbinare între cele două tabele.
Casa Course
se numește partea de referențiere în această relație.
Am fi putut face ca Course
să fie partea proprietară a relației prin maparea câmpului Teacher
cu @ManyToOne
în clasa Course
în schimb:
@ManyToOne@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private Teacher teacher;
Nu este nevoie să avem acum o listă de cursuri în clasa Teacher
. Relația ar fi funcționat invers:
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");
De data aceasta, am folosit adnotarea @ManyToOne
, în același mod în care am folosit @OneToMany
.
Rețineți: Este o bună practică să puneți partea proprietară a unei relații în clasa/tabelul în care va fi ținută cheia străină.
Deci, în cazul nostru, această a doua versiune a codului este mai bună. Dar, ce se întâmplă dacă dorim totuși ca clasa noastră Teacher
să ofere acces la lista sa Course
?
Potem face acest lucru prin definirea unei relații bidirecționale:
@Entitypublic class Teacher { // ... @OneToMany(mappedBy = "teacher") private List<Course> courses;}@Entitypublic class Course { // ... @ManyToOne @JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID") private Teacher teacher;}
Păstrăm maparea noastră @ManyToOne
pe entitatea Course
. Cu toate acestea, cartografiem, de asemenea, o listă de Course
s pe entitatea Teacher
.
Ceea ce este important de remarcat aici este utilizarea steagului mappedBy
în adnotarea @OneToMany
pe partea de referențiere.
Fără acesta, nu am avea o relație bidirecțională. Am avea două relații unidirecționale. Ambele entități ar cartografia chei străine pentru cealaltă entitate.
Cu el, îi spunem lui JPA că acel câmp este deja cartografiat de o altă entitate. Este cartografiat de câmpul teacher
al entității Course
.
Eager vs. Lazy Loading
Un alt lucru care merită menționat este încărcarea nerăbdătoare și leneșă. Cu toate relațiile noastre cartografiate, este înțelept să evităm impactul asupra memoriei software-ului prin introducerea a prea multe entități, dacă nu este necesar.
Imaginați-vă că Course
este un obiect greu, iar noi încărcăm toate obiectele Teacher
din baza de date pentru o anumită operațiune. Nu avem nevoie să recuperăm sau să folosim cursurile pentru această operațiune, dar ele sunt totuși încărcate alături de obiectele Teacher
.
Acest lucru poate fi devastator pentru performanța aplicației. Din punct de vedere tehnic, acest lucru poate fi rezolvat prin utilizarea modelului de proiectare a obiectelor de transfer de date și prin recuperarea informațiilor Teacher
fără cursuri.
Dar, acest lucru poate fi o exagerare masivă dacă tot ceea ce câștigăm din acest model este excluderea cursurilor.
Din fericire, JPA s-a gândit dinainte și a făcut ca relațiile Unu-la-Mulțime să se încarce leneș în mod implicit.
Acest lucru înseamnă că relația nu va fi încărcată imediat, ci doar când și dacă este cu adevărat necesară.
În exemplul nostru, acest lucru ar însemna că, până când nu apelăm la metoda Teacher#courses
, cursurile nu sunt preluate din baza de date.
În schimb, relațiile Many-to-One sunt în mod implicit eager, ceea ce înseamnă că relația este încărcată în același timp cu entitatea.
Putem schimba aceste caracteristici prin setarea argumentului fetch
al ambelor adnotări:
@OneToMany(mappedBy = "teacher", fetch = FetchType.EAGER)private List<Course> courses;@ManyToOne(fetch = FetchType.LAZY)private Teacher teacher;
Aceasta ar inversa modul în care a funcționat inițial. Cursurile ar fi încărcate cu nerăbdare, de îndată ce încărcăm un obiect Teacher
. În schimb, teacher
nu ar fi încărcat atunci când aducem courses
dacă nu este necesar în acel moment.
Opționalitate
Acum, să vorbim despre opționalitate.
Orice relație poate fi opțională sau obligatorie.
Considerând partea One-to-Many – este întotdeauna opțională și nu putem face nimic în legătură cu aceasta. Pe de altă parte, latura Many-to-One ne oferă opțiunea de a o face obligatorie.
În mod implicit, relația este opțională, ceea ce înseamnă că putem salva un Course
fără să-i atribuim un profesor:
Course course = new Course("C# 101");entityManager.persist(course);
Acum, haideți să facem această relație obligatorie. Pentru a face acest lucru, vom folosi argumentul optional
al adnotării @ManyToOne
și îl vom seta la false
(este true
în mod implicit):
@ManyToOne(optional = false)@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private Teacher teacher;
Așa, nu mai putem salva un curs fără a-i atribui un profesor:
Course course = new Course("C# 101");assertThrows(Exception.class, () -> entityManager.persist(course));
Dar dacă îi dăm un profesor, funcționează din nou bine:
Teacher teacher = new Teacher();teacher.setLastName("Doe");teacher.setFirstName("Will");Course course = new Course("C# 101");course.setTeacher(teacher);entityManager.persist(course);
Păi, cel puțin, așa s-ar părea. Dacă am fi rulat codul, ar fi fost aruncată o excepție:
javax.persistence.PersistenceException: org.hibernate.PersistentObjectException: detached entity passed to persist: com.fdpro.clients.stackabuse.jpa.domain.Course
De ce se întâmplă asta? Am setat un obiect Teacher
valid în obiectul Course
pe care încercăm să-l persistăm. Cu toate acestea, nu am persistat obiectul Teacher
înainte de a încerca să persistăm obiectul Course
.
Acum, obiectul Teacher
nu este o entitate gestionată. Să reparăm acest lucru și să încercăm din nou:
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();
Executarea acestui cod va persista ambele entități și va persista relația dintre ele.
Operații în cascadă
Cu toate acestea, am fi putut face un alt lucru – am fi putut face o cascadă și, astfel, am fi propagat persistența obiectului Teacher
atunci când persistăm obiectul Course
.
Acest lucru are mai mult sens și funcționează așa cum ne-am aștepta ca în primul exemplu care a aruncat o excepție.
Pentru a face acest lucru, vom modifica flag-ul cascade
al adnotării:
@ManyToOne(optional = false, cascade = CascadeType.PERSIST)@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private Teacher teacher;
În acest fel, Hibernate știe să persiste și obiectul necesar în această relație.
Există mai multe tipuri de operații în cascadă: PERSIST
, MERGE
, REMOVE
, REFRESH
, DETACH
și ALL
(care le combină pe toate cele anterioare).
De asemenea, putem să punem argumentul de cascadă pe partea One-to-Many a relației, astfel încât operațiile să fie în cascadă și de la profesori la cursurile lor.
Unu-la-unu
Acum că am pus bazele cartografierii relațiilor în JPA prin intermediul relațiilor Unu-la-Mulțime/Multe-la-unu și a setărilor acestora, putem trece la relațiile Unu-la-unu.
De data aceasta, în loc să avem o relație între o entitate pe de o parte și o grămadă de entități pe de altă parte, vom avea maximum o entitate pe fiecare parte.
Aceasta este, de exemplu, relația dintre un Course
și CourseMaterial
al său. Să cartografiem mai întâi CourseMaterial
, ceea ce nu am făcut încă:
@Entitypublic class CourseMaterial { @Id private Long id; private String url;}
Anotarea pentru cartografierea unei singure entități către o singură altă entitate este, în mod neașteptat, @OneToOne
.
Înainte de a o configura în modelul nostru, să ne amintim că o relație are o parte proprietară – de preferință partea care va deține cheia străină în baza de date.
În exemplul nostru, aceasta ar fi CourseMaterial
, deoarece are sens ca aceasta să facă referire la un Course
(deși am putea merge și invers):
@OneToOne(optional = false)@JoinColumn(name = "COURSE_ID", referencedColumnName = "ID")private Course course;
Nu are rost să avem un material fără un curs care să-l cuprindă. De aceea, relația nu este optional
în această direcție.
Vorbind de direcție, haideți să facem relația bidirecțională, astfel încât să putem accesa materialul unui curs, dacă acesta are unul. În clasa Course
, să adăugăm:
@OneToOne(mappedBy = "course")private CourseMaterial material;
Aici, îi spunem lui Hibernate că materialul dintr-un Course
este deja cartografiat de câmpul course
al entității CourseMaterial
.
De asemenea, nu există nici un atribut optional
aici, deoarece acesta este true
în mod implicit, și ne-am putea imagina un curs fără material (de la un profesor foarte leneș).
În plus față de a face relația bidirecțională, am putea, de asemenea, să adăugăm operații în cascadă sau să facem ca entitățile să se încarce nerăbdător sau leneș.
Multe la mai multe
Acum, ultimul dar nu cel din urmă: Relațiile Many-to-Many. Le-am păstrat pentru final pentru că necesită ceva mai multă muncă decât cele anterioare.
Efectiv, într-o bază de date, o relație Many-to-Many implică o tabelă intermediară care face referire la ambele tabele.
Din fericire pentru noi, JPA face cea mai mare parte din muncă, trebuie doar să aruncăm câteva adnotări și se ocupă de restul pentru noi.
Deci, pentru exemplul nostru, relația Many-to-Many va fi cea dintre instanțele Student
și Course
, deoarece un student poate participa la mai multe cursuri, iar un curs poate fi urmat de mai mulți studenți.
Pentru a cartografia o relație Many-to-Many vom folosi adnotarea @ManyToMany
. Cu toate acestea, de data aceasta, vom folosi și o adnotare @JoinTable
pentru a configura tabelul care reprezintă relația:
@ManyToMany@JoinTable( name = "STUDENTS_COURSES", joinColumns = @JoinColumn(name = "COURSE_ID", referencedColumnName = "ID"), inverseJoinColumns = @JoinColumn(name = "STUDENT_ID", referencedColumnName = "ID"))private List<Student> students;
Acum, analizați ce se întâmplă aici. Adnotarea primește câțiva parametri. Mai întâi de toate, trebuie să dăm un nume tabelului. Noi am ales ca acesta să fie STUDENTS_COURSES
.
După aceea, va trebui să îi spunem lui Hibernate ce coloane să unească pentru a popula STUDENTS_COURSES
. Primul parametru, joinColumns
, definește modul de configurare a coloanei de îmbinare (foreign key) a părții deținătoare a relației din tabel. În acest caz, partea proprietară este un Course
.
Pe de altă parte, parametrul inverseJoinColumns
face același lucru, dar pentru partea de referențiere (Student
).
Să configurăm un set de date cu studenți și cursuri:
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);
Desigur, acest lucru nu va funcționa din start. Va trebui să adăugăm o metodă care să ne permită să adăugăm studenți la un curs. Să modificăm puțin clasa Course
:
public class Course { private List<Student> students = new ArrayList<>(); public void addStudent(Student student) { this.students.add(student); }}
Acum, putem completa setul nostru de date:
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);
După ce acest cod a rulat, va persista instanțele noastre Course
, Teacher
și Student
, precum și relațiile dintre ele. De exemplu, haideți să recuperăm un student dintr-un curs persistat și să verificăm dacă totul este în regulă:
Course courseWithMultipleStudents = entityManager.find(Course.class, 1L);assertThat(courseWithMultipleStudents).isNotNull();assertThat(courseWithMultipleStudents.students()) .hasSize(2) .extracting(Student::firstName) .containsExactly("John", "Will");
Desigur, putem în continuare să mapăm relația ca fiind bidirecțională în același mod în care am făcut-o pentru relațiile anterioare.
De asemenea, putem efectua operații în cascadă, precum și să definim dacă entitățile trebuie să se încarce leneș sau nerăbdător (relațiile Many-to-Many sunt leneșe în mod implicit).
Concluzie
Aceasta încheie acest articol despre relațiile dintre entitățile mapate cu JPA. Am acoperit relațiile Many-to-One, One-to-Many, Many-to-Many și One-to-One. În plus, am explorat operațiile în cascadă, bidirecționalitatea, opționalitatea și fetch-types de încărcare nerăbdătoare/ leneșă.
Codul pentru această serie poate fi găsit pe GitHub.
.