Úvod
V tomto článku se budeme zabývat mapováním vztahů pomocí JPA a Hibernate v Javě.
Java Persistence API (JPA) je standardem persistence ekosystému Java. Umožňuje nám mapovat náš doménový model přímo na strukturu databáze a pak nám dává flexibilitu při manipulaci s objekty v našem kódu – namísto toho, abychom se trápili s těžkopádnými komponentami JDBC, jako jsou Connection
, ResultSet
atd.
Budeme dělat komplexního průvodce používáním JPA s Hibernate jako jeho dodavatelem. V tomto článku se budeme zabývat mapováním vztahů.
- Průvodce JPA s Hibernate – základní mapování
- Průvodce JPA s Hibernate – mapování vztahů (jste zde)
- Průvodce JPA s Hibernate:
- Průvodce JPA s Hibernate – dotazování (již brzy!)
Náš příklad
Než začneme, připomeňme si příklad, který jsme použili v předchozí části tohoto seriálu. Šlo o to zmapovat model školy se studenty, kteří navštěvují předměty zadávané učiteli.
Takto vypadá tento model:
Jak vidíme, existuje několik tříd s určitými vlastnostmi. Tyto třídy mají mezi sebou vztahy. Na konci tohoto článku budeme mít všechny tyto třídy namapované na databázové tabulky a zachováme jejich vztahy.
Dále je budeme moci načítat a manipulovat s nimi jako s objekty, bez potíží s JDBC.
Vztahy
Nejprve definujme vztah. Podíváme-li se na diagram našich tříd, vidíme několik vztahů:
Učitelé a kurzy – studenti a kurzy – kurzy a studijní materiály.
Existují také vazby mezi studenty a adresami, ale ty nejsou považovány za vztahy. Je to proto, že Address
není entita (tj. není namapována na vlastní tabulku). Takže pokud jde o JPA, není to vztah.
Existuje několik typů vztahů:
- One-to-Many
- Many-to-One
- One-to-One
- Many-to-Many
Pojďme se těmito vztahy zabývat postupně.
One-to-Many/Many-to-One
Začneme vztahy One-to-Many a Many-to-One, které spolu úzce souvisejí. Dalo by se říci, že jsou to opačné strany téže mince.
Co je to vztah One-to-Many?“
Jak již název napovídá, jedná se o vztah, který spojuje jednu entitu s mnoha dalšími entitami.
V našem příkladu by se jednalo o Teacher
a jejich Courses
. Učitel může vést více kurzů, ale kurz vede pouze jeden učitel (to je perspektiva Many-to-One – mnoho kurzů k jednomu učiteli).
Jiný příklad může být na sociálních sítích – fotografie může mít mnoho komentářů, ale každý z těchto komentářů patří k této jedné fotografii.
Než se ponoříme do detailů mapování tohoto vztahu, vytvořme naše entity:
@Entitypublic class Teacher { private String firstName; private String lastName;}@Entitypublic class Course { private String title;}
Nyní by pole třídy Teacher
měla obsahovat seznam kurzů. Protože bychom chtěli tento vztah mapovat v databázi, která nemůže obsahovat seznam entit v rámci jiné entity – anotujeme jej pomocí anotace @OneToMany
:
@OneToManyprivate List<Course> courses;
Jako typ pole jsme zde použili List
, ale mohli jsme zvolit Set
nebo Map
(i když to vyžaduje trochu více konfigurace).
Jak JPA tento vztah zohlední v databázi? Obecně platí, že pro tento typ vztahu musíme použít cizí klíč v tabulce.
JPA to udělá za nás s ohledem na naše zadání, jak má vztah zpracovat. To se provádí pomocí anotace @JoinColumn
:
@OneToMany@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private List<Course> courses;
Použitím této anotace sdělíme JPA, že tabulka COURSE
musí mít sloupec cizího klíče TEACHER_ID
, který odkazuje na sloupec ID
tabulky TEACHER
.
Přidáme do těchto tabulek nějaká data:
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');
A nyní zkontrolujeme, zda vztah funguje podle očekávání:
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");
Vidíme, že kurzy učitele se shromažďují automaticky, když načteme instanci Teacher
.
Pokud neznáte testování v jazyce Java, možná vás bude zajímat článek Unit Testing in Java with JUnit 5!“
Vlastní strana a obousměrnost
V předchozím příkladu se třída Teacher
nazývá vlastní strana vztahu One-To-Many. Je to proto, že definuje sloupec spojení mezi oběma tabulkami.
Třída Course
se v tomto vztahu nazývá odkazující strana.
Mohli jsme z třídy Course
udělat vlastní stranu vztahu tak, že bychom místo toho namapovali pole Teacher
pomocí @ManyToOne
ve třídě Course
:
@ManyToOne@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private Teacher teacher;
Není třeba, aby byl nyní ve třídě Teacher
seznam kurzů. Vztah by fungoval opačně:
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");
Tentokrát jsme použili anotaci @ManyToOne
, stejně jako jsme použili @OneToMany
.
Poznámka: Je dobrým zvykem umístit vlastní stranu vztahu do třídy/tabulky, kde bude cizí klíč.
V našem případě je tedy lepší tato druhá verze kódu. Co když ale přesto chceme, aby naše třída Teacher
nabízela přístup ke svému seznamu Course
?
Toho můžeme dosáhnout definováním obousměrného vztahu:
@Entitypublic class Teacher { // ... @OneToMany(mappedBy = "teacher") private List<Course> courses;}@Entitypublic class Course { // ... @ManyToOne @JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID") private Teacher teacher;}
Naší @ManyToOne
mapování ponecháme na entitě Course
. Mapujeme však také seznam Course
na entitu Teacher
.
Je důležité si všimnout použití příznaku mappedBy
v anotaci @OneToMany
na straně odkazování.
Bez něj bychom neměli obousměrný vztah. Měli bychom dva jednosměrné vztahy. Obě entity by mapovaly cizí klíče pro druhou entitu.
S ním říkáme JPA, že pole je již mapováno jinou entitou. Je mapováno polem teacher
entity Course
.
Eager vs Lazy Loading
Další věc, která stojí za zmínku, je eager a lazy loading. Když máme zmapované všechny vztahy, je rozumné vyhnout se tomu, abychom ovlivnili paměť softwaru tím, že do ní vložíme příliš mnoho entit, pokud je to zbytečné.
Představte si, že Course
je těžký objekt a my pro nějakou operaci načítáme všechny Teacher
objekty z databáze. Pro tuto operaci nepotřebujeme načítat ani používat kurzy, ale přesto se načítají spolu s objekty Teacher
.
To může být pro výkon aplikace zničující. Technicky to lze vyřešit použitím návrhového vzoru Data Transfer Object a načítáním informací Teacher
bez kurzů.
To však může být obrovský přežitek, pokud jediné, co díky vzoru získáme, je vyloučení kurzů.
JPA naštěstí myslel dopředu a zajistil, aby se vztahy One-to-Many ve výchozím nastavení načítaly líně.
To znamená, že se vztah nebude načítat hned, ale až tehdy a tehdy, když to bude skutečně potřeba.
V našem příkladu by to znamenalo, že dokud nezavoláme na metodu Teacher#courses
, kurzy se z databáze nenačtou.
Naproti tomu vztahy Many-to-One jsou ve výchozím nastavení eager, což znamená, že vztah se načte současně s entitou.
Tyto vlastnosti můžeme změnit nastavením argumentu fetch
obou anotací:
@OneToMany(mappedBy = "teacher", fetch = FetchType.EAGER)private List<Course> courses;@ManyToOne(fetch = FetchType.LAZY)private Teacher teacher;
To by obrátilo původní způsob fungování. Kurzy by se načítaly dychtivě, jakmile bychom načetli objekt Teacher
. Naproti tomu by se teacher
nenačetl, když načteme courses
, pokud je v danou chvíli nepotřebný.
Volitelnost
Teď si řekneme něco o volitelnosti.
Vztah může být volitelný nebo povinný.
Vezmeme-li v úvahu stranu One-to-Many – je vždy volitelný a nemůžeme s ním nic dělat. Naproti tomu strana Many-to-One nám nabízí možnost učinit ji povinnou.
Ve výchozím nastavení je vztah nepovinný, což znamená, že můžeme uložit Course
, aniž bychom mu přiřadili učitele:
Course course = new Course("C# 101");entityManager.persist(course);
Nyní učiníme tento vztah povinným. K tomu použijeme argument optional
anotace @ManyToOne
a nastavíme jej na false
(ve výchozím nastavení je to true
):
@ManyToOne(optional = false)@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private Teacher teacher;
Takto již nemůžeme uložit kurz, aniž bychom mu přiřadili učitele:
Course course = new Course("C# 101");assertThrows(Exception.class, () -> entityManager.persist(course));
Ale pokud mu přidělíme učitele, funguje opět dobře:
Teacher teacher = new Teacher();teacher.setLastName("Doe");teacher.setFirstName("Will");Course course = new Course("C# 101");course.setTeacher(teacher);entityManager.persist(course);
Nejméně by se tak mohlo zdát. Kdybychom kód spustili, vyhodila by se výjimka:
javax.persistence.PersistenceException: org.hibernate.PersistentObjectException: detached entity passed to persist: com.fdpro.clients.stackabuse.jpa.domain.Course
Proč tomu tak je? V objektu Course
, který se snažíme persistovat, jsme nastavili platný objekt Teacher
. Objekt Teacher
jsme však nepersistovali před pokusem o persistenci objektu Course
.
Objekt Teacher
tedy není spravovanou entitou. Napravme to a zkusme to znovu:
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();
Provedení tohoto kódu persistuje obě entity a zachová vztah mezi nimi.
Kaskádové operace
Mohli jsme však udělat jinou věc – mohli jsme kaskádovat, a tak propagovat persistenci objektu Teacher
při persistenci objektu Course
.
To dává větší smysl a funguje to tak, jak bychom očekávali, stejně jako v prvním příkladu, který vyhodil výjimku.
Pro to upravíme příznak cascade
anotace:
@ManyToOne(optional = false, cascade = CascadeType.PERSIST)@JoinColumn(name = "TEACHER_ID", referencedColumnName = "ID")private Teacher teacher;
Tímto způsobem bude Hibernate vědět, že má perzistovat potřebný objekt i v tomto vztahu.
Existuje více typů kaskádových operací: PERSIST
, MERGE
, REMOVE
, REFRESH
, DETACH
a ALL
(který kombinuje všechny předchozí).
Argument kaskádování můžeme také umístit na stranu One-to-Many vztahu, takže operace budou kaskádovány také z učitelů na jejich kurzy.
One-to-One
Když jsme nyní nastavili základy mapování vztahů v JPA prostřednictvím vztahů One-to-Many/Many-to-One a jejich nastavení, můžeme přejít ke vztahům One-to-One.
Tentokrát místo toho, abychom měli vztah mezi jednou entitou na jedné straně a hromadou entit na straně druhé, budeme mít na každé straně maximálně jednu entitu.
To je například vztah mezi Course
a její CourseMaterial
. Nejprve namapujme CourseMaterial
, což jsme zatím neudělali:
@Entitypublic class CourseMaterial { @Id private Long id; private String url;}
Anotace pro mapování jedné entity na jednu jinou entitu je překvapivě @OneToOne
.
Před nastavením v našem modelu si uvědomme, že vztah má vlastní stranu – nejlépe tu, která bude v databázi držet cizí klíč.
V našem příkladu to bude CourseMaterial
, protože dává smysl, že se odkazuje na Course
(i když bychom mohli postupovat i opačně):
@OneToOne(optional = false)@JoinColumn(name = "COURSE_ID", referencedColumnName = "ID")private Course course;
Nemá smysl mít materiál bez kurzu, který by ho zahrnoval. Proto vztah není optional
v tomto směru.
Když už jsme u toho směru, udělejme vztah obousměrný, abychom měli přístup k materiálu kurzu, pokud ho má. Do třídy Course
přidejme:
@OneToOne(mappedBy = "course")private CourseMaterial material;
Tady říkáme systému Hibernate, že materiál v rámci třídy Course
je již namapován polem course
entity CourseMaterial
.
Také zde není atribut optional
, protože ten je standardně true
, a mohli bychom si představit kurz bez materiálu (od velmi líného učitele).
Kromě toho, aby byl vztah obousměrný, bychom mohli přidat i kaskádové operace nebo zajistit, aby se entity načítaly dychtivě nebo líně.
Many-to-Many
Na závěr: Vztahy Many-to-Many. Ty jsme si nechali na konec, protože vyžadují trochu více práce než ty předchozí.
V databázi vztah Many-to-Many zahrnuje prostřední tabulku odkazující na obě ostatní tabulky.
Naštěstí pro nás většinu práce odvede JPA, my jen musíme přihodit pár anotací a zbytek zvládne za nás.
Pro náš příklad tedy bude vztah Many-to-Many vztahem mezi instancemi Student
a Course
, protože student může navštěvovat více kurzů a kurz může sledovat více studentů.
Pro mapování vztahu Many-to-Many použijeme anotaci @ManyToMany
. Tentokrát však použijeme také anotaci @JoinTable
pro nastavení tabulky, která představuje vztah:
@ManyToMany@JoinTable( name = "STUDENTS_COURSES", joinColumns = @JoinColumn(name = "COURSE_ID", referencedColumnName = "ID"), inverseJoinColumns = @JoinColumn(name = "STUDENT_ID", referencedColumnName = "ID"))private List<Student> students;
Nyní si projděte, o co zde jde. Anotace přebírá několik parametrů. Především musíme tabulce dát jméno. My jsme zvolili, že to bude STUDENTS_COURSES
.
Poté budeme muset systému Hibernate sdělit, které sloupce se mají připojit, aby se naplnila STUDENTS_COURSES
. První parametr, joinColumns
, určuje, jak nastavit sloupec pro připojení (cizí klíč) vlastní strany vztahu v tabulce. V tomto případě je vlastní stranou Course
.
Na druhé straně parametr inverseJoinColumns
dělá totéž, ale pro odkazující stranu (Student
).
Nastavíme datovou sadu se studenty a předměty:
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);
Pochopitelně to nebude fungovat z krabice. Budeme muset přidat metodu, která nám umožní přidávat studenty do kurzu. Trochu upravíme třídu Course
:
public class Course { private List<Student> students = new ArrayList<>(); public void addStudent(Student student) { this.students.add(student); }}
Nyní můžeme doplnit naši datovou sadu:
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);
Po spuštění tohoto kódu přetrvají naše instance Course
, Teacher
a Student
i jejich vztahy. Získejme například studenta z persistovaného kurzu a zkontrolujme, zda je vše v pořádku:
Course courseWithMultipleStudents = entityManager.find(Course.class, 1L);assertThat(courseWithMultipleStudents).isNotNull();assertThat(courseWithMultipleStudents.students()) .hasSize(2) .extracting(Student::firstName) .containsExactly("John", "Will");
Samozřejmě můžeme vztah stále mapovat jako obousměrný stejně, jako jsme to udělali u předchozích vztahů.
Můžeme také kaskádovat operace a také definovat, zda se mají entity načítat líně nebo nedočkavě (vztahy Many-to-Many jsou ve výchozím nastavení líné).
Závěr
Tím končí tento článek o vztazích mapovaných entit pomocí JPA. Probrali jsme vztahy Many-to-One, One-to-Many, Many-to-Many a One-to-One. Kromě toho jsme prozkoumali kaskádové operace, obousměrnost, volitelnost a eager/lazy loading fetch-types.
Kód k tomuto seriálu najdete na GitHubu.
.