Bevezetés
Ebben a cikkben a JPA-val és a Hibernate-tel való kapcsolattérképezésbe merülünk el Java-ban.
A Java Persistence API (JPA) a Java ökoszisztéma perszisztencia-szabványa. Lehetővé teszi, hogy a tartományi modellünket közvetlenül az adatbázis struktúrájára képezzük le, és ezután rugalmasan manipulálhatjuk az objektumokat a kódunkban – ahelyett, hogy olyan nehézkes JDBC komponensekkel bajlódnánk, mint a Connection, ResultSet stb.
Egy átfogó útmutatót készítünk a JPA használatáról a Hibernate-tel mint szállítóval. Ebben a cikkben a kapcsolati leképezésekkel foglalkozunk.
- Útmutató a JPA-hoz a Hibernate-tel – Alap leképezés
- Útmutató a JPA-hoz a Hibernate-tel – Kapcsolati leképezés (itt vagy)
- Útmutató a JPA-hoz a Hibernate-tel: Inheritance Mapping (hamarosan itt lesz!)
- Guide to JPA with Hibernate – Querying (hamarosan itt lesz!)
Példánk
A kezdés előtt emlékezzünk vissza a sorozat előző részében használt példára. Az ötlet egy iskola modelljének leképezése volt, ahol a diákok a tanárok által tartott kurzusokat veszik fel.
Íme, így néz ki ez a modell:

Amint látjuk, van néhány osztály bizonyos tulajdonságokkal. Ezek az osztályok között kapcsolatok vannak. A cikk végére mindezeket az osztályokat leképezzük adatbázis-táblákra, megőrizve kapcsolataikat.
Ezeken túlmenően képesek leszünk objektumként lekérdezni és manipulálni őket, a JDBC gondjai nélkül.
Kapcsolatok
Először is definiáljunk egy kapcsolatot. Ha megnézzük az osztálydiagramunkat, láthatunk néhány kapcsolatot:
Tanárok és kurzusok – hallgatók és kurzusok – kurzusok és tananyagok.
A hallgatók és címek között is vannak kapcsolatok, de ezek nem számítanak kapcsolatoknak. Ennek az az oka, hogy egy Address nem entitás (azaz nincs saját táblára leképezve). Tehát, ami a JPA-t illeti, ez nem kapcsolat.
A kapcsolatoknak van néhány típusa:
- One-to-Many
- Many-to-One
- One-to-One
- Many-to-Many
Vegyük sorra ezeket a kapcsolatokat.
One-to-Many/Many-to-One
Az One-to-Many és a Many-to-One kapcsolatokkal kezdjük, amelyek szorosan kapcsolódnak egymáshoz. Úgy is mondhatnánk, hogy ezek ugyanannak az éremnek az ellentétes oldalai.
Mi az Egy a sokhoz kapcsolat?
Amint a neve is mutatja, ez egy olyan kapcsolat, amely egy entitást sok más entitással kapcsol össze.
Példánkban ez egy Teacher és az ő Courses-jük lenne. Egy tanár több kurzust is tarthat, de egy kurzust csak egy tanár tart (ez a sok az egyhez perspektíva – sok kurzus egy tanárhoz).
Egy másik példa lehet a közösségi médiában – egy fotóhoz sok hozzászólás tartozhat, de minden egyes hozzászólás ehhez az egy fotóhoz tartozik.
Mielőtt belemerülnénk e kapcsolat leképezésének részleteibe, hozzuk létre az entitásokat:
@Entitypublic class Teacher { private String firstName; private String lastName;}@Entitypublic class Course { private String title;}Most, a Teacher osztály mezőinek tartalmazniuk kell a kurzusok listáját. Mivel ezt a kapcsolatot egy adatbázisban szeretnénk leképezni, amely nem tartalmazhat egy másik entitáson belüli entitások listáját – egy @OneToMany annotációval fogjuk megjegyezni:
@OneToManyprivate List<Course> courses;Mezőtípusként itt egy List-et használtunk, de választhattunk volna egy Set vagy egy Map-t is (bár ez egy kicsit több konfigurációt igényel).
Hogyan tükrözi a JPA ezt a kapcsolatot az adatbázisban? Általában az ilyen típusú kapcsolatokhoz idegen kulcsot kell használnunk egy táblában.
A JPA megteszi ezt helyettünk, figyelembe véve a mi bemenetünket arra vonatkozóan, hogy hogyan kezelje a kapcsolatot. Ez a @JoinColumn annotáción keresztül történik:
@OneToMany@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private List<Course> courses;Ezzel az annotációval a JPA-nak megmondjuk, hogy a COURSE táblának rendelkeznie kell egy TEACHER_ID idegen kulcs oszlopkal, amely hivatkozik a TEACHER tábla ID oszlopára.
Adjunk hozzá néhány adatot ezekhez a táblákhoz:
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');És most ellenőrizzük, hogy a kapcsolat az elvárásoknak megfelelően működik-e:
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");Láthatjuk, hogy a Teacher példány lekérdezésekor automatikusan összegyűjtjük a tanár kurzusait.
Ha nem ismeri a Java-ban történő tesztelést, akkor érdemes elolvasnia a Unit Testing in Java with JUnit 5 című részt!
Owning Side and Bidirectionality
Az előző példában a Teacher osztályt az egy a sokhoz kapcsolat birtokló oldalának nevezzük. Ez azért van így, mert ez határozza meg a két tábla közötti összekötő oszlopot.
A Course-t ebben a kapcsolatban hivatkozó oldalnak nevezzük.
A Course-t a kapcsolat birtokló oldalává tehettük volna, ha helyette a Teacher mezőt a @ManyToOne mezővel képezzük le a Course osztályban:
@ManyToOne@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private Teacher teacher;A Teacher osztályban most nincs szükség a kurzusok listájára. A kapcsolat fordítva működött volna:
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");Ezúttal a @ManyToOne annotációt használtuk, ugyanúgy, ahogy a @OneToMany-t. 
Megjegyzés: Jó gyakorlat, hogy a kapcsolat birtokos oldalát abba az osztályba/táblába tesszük, ahol az idegen kulcsot fogjuk tartani.
Ez esetben tehát a kódnak ez a második verziója a jobb. De mi van akkor, ha továbbra is azt akarjuk, hogy a Teacher osztályunk hozzáférést biztosítson a Course listájához?
Ezt megtehetjük egy kétirányú kapcsolat definiálásával:
@Entitypublic class Teacher { // ... @OneToMany(mappedBy = "teacher") private List<Course> courses;}@Entitypublic class Course { // ... @ManyToOne @JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID") private Teacher teacher;}Megtartjuk a @ManyToOne leképezésünket a Course entitáson. Azonban egy Course-ek listáját is leképezzük a Teacher entitáshoz.
Mit itt fontos megjegyezni, az a mappedBy flag használata a @OneToMany annotációban a hivatkozó oldalon.
Enélkül nem lenne kétirányú kapcsolatunk. Két egyirányú kapcsolatunk lenne. Mindkét entitás idegen kulcsokat képezne le a másik entitáshoz.
Mivel azt mondjuk a JPA-nak, hogy a mezőt már egy másik entitás képezi le. A Course entitás teacher mezője képezi le.
Eager vs Lazy Loading
Egy másik dolog, amit érdemes megjegyezni, az eager és a lazy loading. Mivel minden kapcsolatunkat leképeztük, bölcs dolog elkerülni, hogy a szoftver memóriáját túl sok entitás betöltésével terheljük, ha ez nem szükséges.
Tegyük fel, hogy a Course egy nehéz objektum, és valamilyen művelethez az összes Teacher objektumot betöltjük az adatbázisból. Ehhez a művelethez nem kell lekérnünk vagy használnunk a kurzusokat, de a Teacher objektumokkal együtt mégis betöltődnek.
Ez pusztító hatással lehet az alkalmazás teljesítményére. Technikailag ez megoldható az Adatátviteli objektum tervezési minta használatával, és a Teacher információk lekérdezésével a kurzusok nélkül.
Ez azonban hatalmas túlzás lehet, ha a mintával csak annyit nyerünk, hogy kizárjuk a kurzusokat.
Hála Istennek, a JPA előre gondolt, és alapértelmezés szerint lustán töltik be az egy a sokhoz kapcsolatokat.
Ez azt jelenti, hogy a kapcsolat nem fog azonnal betöltődni, hanem csak akkor, amikor és ha valóban szükség van rá.
Példánkban ez azt jelentené, hogy amíg nem hívjuk meg a Teacher#courses metódust, addig a kurzusok nem kerülnek lehívásra az adatbázisból.
Ezzel szemben a Many-to-One kapcsolatok alapértelmezés szerint buzgóak, ami azt jelenti, hogy a kapcsolat az entitással egy időben töltődik be.
Ezeket a tulajdonságokat mindkét annotáció fetch argumentumának beállításával megváltoztathatjuk:
@OneToMany(mappedBy = "teacher", fetch = FetchType.EAGER)private List<Course> courses;@ManyToOne(fetch = FetchType.LAZY)private Teacher teacher;Ez megfordítaná az eredeti működést. A kurzusok mohón töltődnének be, amint betöltünk egy Teacher objektumot. Ezzel szemben a teacher nem töltődne be, amikor a courses objektumot lehívjuk, ha éppen nincs rá szükség.
Optionalitás
Most beszéljünk az opcionalitásról.
Egy kapcsolat lehet opcionális vagy kötelező.
Az egy a sokhoz oldalról nézve – mindig opcionális, és nem tudunk vele mit kezdeni. A Sok-az-egyhez oldalon viszont lehetőségünk van arra, hogy kötelezővé tegyük.
Egy kapcsolat alapértelmezés szerint opcionális, vagyis menthetünk egy Course-t anélkül, hogy tanárral rendelnénk hozzá:
Course course = new Course("C# 101");entityManager.persist(course);Most tegyük kötelezővé ezt a kapcsolatot. Ehhez használjuk a @ManyToOne megjegyzés optional argumentumát, és állítsuk false-ra (alapértelmezés szerint true):
@ManyToOne(optional = false)@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private Teacher teacher;Így többé nem menthetünk el egy kurzust anélkül, hogy tanárt rendelnénk hozzá:
Course course = new Course("C# 101");assertThrows(Exception.class, () -> entityManager.persist(course));De ha adunk neki egy tanárt, ismét jól működik:
Teacher teacher = new Teacher();teacher.setLastName("Doe");teacher.setFirstName("Will");Course course = new Course("C# 101");course.setTeacher(teacher);entityManager.persist(course);Hát, legalábbis úgy tűnik. Ha lefuttattuk volna a kódot, akkor egy kivételt dobott volna:
javax.persistence.PersistenceException: org.hibernate.PersistentObjectException: detached entity passed to persist: com.fdpro.clients.stackabuse.jpa.domain.CourseMiért van ez? Egy érvényes Teacher objektumot állítottunk be a Course objektumba, amelyet megpróbálunk tartósítani. Azonban nem perzisztáltuk a Teacher objektumot, mielőtt megpróbáltuk volna perzisztálni a Course objektumot.
Ezért a Teacher objektum nem egy kezelt entitás. Javítsuk ki ezt, és próbáljuk meg újra:
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();Ezt a kódot futtatva mindkét entitást perzisztenssé tesszük, és megőrzi a köztük lévő kapcsolatot.
Cascading Operations
Viszont csinálhattunk volna mást is – kaszkádolhattunk volna, és így propagáltuk volna a Teacher objektum perzisztenciáját, amikor a Course objektumot perzisztenssé tesszük.
Ennek több értelme van, és úgy működik, ahogyan azt elvárnánk, mint az első példában, ami kivételt dobott.
Ezért módosítjuk az annotáció cascade flagjét:
@ManyToOne(optional = false, cascade = CascadeType.PERSIST)@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private Teacher teacher;Így a Hibernate tudja, hogy a szükséges objektumot ebben a relációban is persistálni kell.
A kaszkád műveletnek több típusa van: PERSIST, MERGE, REMOVE, REFRESH, DETACH és ALL (amely az előzőeket egyesíti).
A kaszkád argumentumot a kapcsolat egytől sokig oldalára is feltehetjük, így a műveletek a tanároktól a kurzusaikra is kaszkádként kerülnek.
One-to-One
Most, hogy az One-to-Many/Many-to-One kapcsolatokon és azok beállításain keresztül megteremtettük a kapcsolatleképezés alapjait a JPA-ban, áttérhetünk az One-to-One kapcsolatokra.
Ezúttal ahelyett, hogy az egyik oldalon egy entitás, a másikon pedig egy csomó entitás között lenne kapcsolat, ezúttal mindkét oldalon legfeljebb egy entitás lesz.
Ez például egy Course és annak CourseMaterial közötti kapcsolat. Először is képezzük le a CourseMaterial-et, amit még nem tettünk meg:
@Entitypublic class CourseMaterial { @Id private Long id; private String url;}Az egyetlen entitás egyetlen másik entitáshoz való leképezésének annotációja – nem meglepő módon – a @OneToOne.
Mielőtt beállítanánk a modellünkben, emlékezzünk arra, hogy egy kapcsolatnak van egy birtokló oldala – lehetőleg az az oldal, amelyik az idegen kulcsot fogja tartani az adatbázisban.
Példánkban ez a CourseMaterial lenne, mivel van értelme, hogy hivatkozik egy Course-re (bár fordítva is lehetne):
@OneToOne(optional = false)@JoinColumn(name = "COURSE_ID", referencedColumnName = "ID")private Course course;Nincs értelme, hogy legyen anyagunk anélkül, hogy ne lenne egy kurzus, amely felölelné azt. Ezért a kapcsolat nem optional ilyen irányú.
Apropó irány, tegyük a kapcsolatot kétirányúvá, hogy hozzáférhessünk egy kurzus anyagához, ha az rendelkezik ilyennel. A Course osztályban adjuk hozzá:
@OneToOne(mappedBy = "course")private CourseMaterial material;Itt azt mondjuk a Hibernate-nek, hogy a Course-en belüli anyagot már leképezte a CourseMaterial entitás course mezője.
Ezeken kívül itt nincs optional attribútum, mivel az alapértelmezés szerint true, és el tudnánk képzelni egy anyag nélküli kurzust (egy nagyon lusta tanártól).
A kapcsolat kétirányúvá tételén túlmenően kaszkád műveleteket is hozzáadhatnánk, vagy az entitásokat lelkesen vagy lustán tölthetnénk be.
Many-to-Many
Most, de nem utolsósorban: Many-to-Many kapcsolatok. Ezeket azért tartottuk a végére, mert egy kicsit több munkát igényelnek, mint az előzőek.
Egy adatbázisban a Many-to-Many kapcsolat egy középső táblát foglal magában, amely mindkét másik táblára hivatkozik.
A JPA szerencsénkre a munka nagy részét elvégzi, nekünk csak néhány annotációt kell kidobnunk, a többit pedig elintézi helyettünk.
A példánkban tehát a Many-to-Many kapcsolat a Student és Course példányok közötti kapcsolat lesz, mivel egy hallgató több kurzusra is járhat, és egy kurzust több hallgató is követhet.
A Many-to-Many kapcsolat leképezéséhez a @ManyToMany annotációt fogjuk használni. Ezúttal azonban egy @JoinTable annotációt is használunk a kapcsolatot reprezentáló táblázat létrehozásához:
@ManyToMany@JoinTable( name = "STUDENTS_COURSES", joinColumns = @JoinColumn(name = "COURSE_ID", referencedColumnName = "ID"), inverseJoinColumns = @JoinColumn(name = "STUDENT_ID", referencedColumnName = "ID"))private List<Student> students;Most nézzük át, hogy mi történik itt. Az annotáció néhány paramétert vesz fel. Először is nevet kell adnunk a táblának. Mi úgy választottuk, hogy STUDENTS_COURSES.
Ezután meg kell mondanunk a Hibernate-nek, hogy mely oszlopokat kell összekapcsolni a STUDENTS_COURSES feltöltéséhez. Az első paraméter, a joinColumns azt határozza meg, hogy a kapcsolat tulajdonosi oldalának join oszlopát (idegen kulcsát) hogyan konfiguráljuk a táblázatban. Ebben az esetben a birtokló oldal egy Course.
A másik oldalon a inverseJoinColumns paraméter ugyanezt teszi, de a hivatkozó oldalra (Student).
Állítsunk be egy adathalmazt hallgatókkal és kurzusokkal:
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); Természetesen ez nem fog működni a dobozból. Hozzá kell adnunk egy módszert, amely lehetővé teszi, hogy diákokat adjunk hozzá egy kurzushoz. Módosítsuk egy kicsit a Course osztályt:
public class Course { private List<Student> students = new ArrayList<>(); public void addStudent(Student student) { this.students.add(student); }}Most már kiegészíthetjük az adathalmazunkat:
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);A kód lefutása után a Course, Teacher és Student példányaink, valamint kapcsolataik megmaradnak. Például, kérjünk le egy diákot egy persistált kurzusból, és ellenőrizzük, hogy minden rendben van-e:
Course courseWithMultipleStudents = entityManager.find(Course.class, 1L);assertThat(courseWithMultipleStudents).isNotNull();assertThat(courseWithMultipleStudents.students()) .hasSize(2) .extracting(Student::firstName) .containsExactly("John", "Will");A kapcsolatot természetesen továbbra is leképezhetjük kétirányúnak, ugyanúgy, ahogy az előző kapcsolatoknál tettük.
A műveleteket kaszkádosíthatjuk is, valamint meghatározhatjuk, hogy az entitások lustán vagy buzgón töltődjenek be (a Many-to-Many kapcsolatok alapértelmezés szerint lusták).
Következtetés
Ezzel zárjuk ezt a cikket a JPA-val leképezett entitások kapcsolatairól. A Many-to-One, One-to-Many, Many-to-Many és One-to-One kapcsolatokkal foglalkoztunk. Ezen kívül megvizsgáltuk a kaszkád műveleteket, a kétirányúságot, az opcionalitást és az eager/lazy loading fetch-típusokat.
A sorozat kódja megtalálható a GitHubon.