Útmutató a JPA-hoz a Hibernate-tel – Kapcsolattérképezés

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

Mié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.

Vélemény, hozzászólás?

Az e-mail-címet nem tesszük közzé.