Przewodnik po JPA z Hibernate – Mapowanie relacji

Wprowadzenie

W tym artykule zagłębimy się w Mapowanie relacji z JPA i Hibernate w Javie.

Java Persistence API (JPA) jest standardem persystencji ekosystemu Java. Pozwala nam mapować nasz model domeny bezpośrednio do struktury bazy danych, a następnie daje nam elastyczność manipulowania obiektami w naszym kodzie – zamiast kłopotliwych komponentów JDBC, takich jak Connection, ResultSet, itp.

Będziemy robić kompleksowy przewodnik do korzystania z JPA z Hibernate jako jego dostawcy. W tym artykule zajmiemy się mapowaniem relacji.

  • Przewodnik po JPA z Hibernate – Podstawowe mapowanie
  • Przewodnik po JPA z Hibernate – Mapowanie relacji (jesteś tutaj)
  • Przewodnik po JPA z Hibernate: Inheritance Mapping (coming soon!)
  • Guide to JPA with Hibernate – Querying (Coming soon!)

Our Example

Zanim zaczniemy, przypomnijmy przykład, którego użyliśmy w poprzedniej części tej serii. Chodziło o odwzorowanie modelu szkoły, w której uczniowie uczęszczają na kursy prowadzone przez nauczycieli.

Oto jak wygląda ten model:

Jak widzimy, istnieje kilka klas posiadających pewne właściwości. Klasy te posiadają relacje między sobą. Do końca tego artykułu, zmapujemy wszystkie te klasy do tabel bazy danych, zachowując ich relacje.

Co więcej, będziemy w stanie pobrać je i manipulować nimi jako obiektami, bez kłopotów z JDBC.

Relacje

Po pierwsze, zdefiniujmy relację. Jeśli spojrzymy na nasz diagram klas, zobaczymy kilka relacji:

Nauczyciele i kursy – studenci i kursy – kursy i materiały kursowe.

Istnieją również połączenia między studentami i adresami, ale nie są one uważane za relacje. Dzieje się tak dlatego, że Address nie jest encją (tzn. nie jest odwzorowany na własną tabelę). Tak więc, jeśli chodzi o JPA, nie jest to relacja.

Istnieje kilka typów relacji:

  • Jeden-do-wielu
  • Many-to-One
  • Jeden-do-wielu
  • Many-to-Many

Zajmijmy się tymi relacjami po kolei.

One-to-Many/Many-to-One

Zaczniemy od relacji One-to-Many i Many-to-One, które są blisko spokrewnione. Można powiedzieć, że są to przeciwne strony tej samej monety.

Co to jest relacja One-to-Many?

Jak sama nazwa wskazuje, jest to relacja, która łączy jedną jednostkę z wieloma innymi jednostkami.

W naszym przykładzie, byłaby to relacja Teacher i ich Courses. Nauczyciel może prowadzić wiele kursów, ale kurs jest prowadzony tylko przez jednego nauczyciela (to jest perspektywa Many-to-One – wiele kursów do jednego nauczyciela).

Inny przykład może być w mediach społecznościowych – zdjęcie może mieć wiele komentarzy, ale każdy z tych komentarzy należy do tego jednego zdjęcia.

Zanim zagłębimy się w szczegóły mapowania tej relacji, stwórzmy nasze encje:

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

Teraz pola klasy Teacher powinny zawierać listę kursów. Ponieważ chcielibyśmy odwzorować tę relację w bazie danych, która nie może zawierać listy encji wewnątrz innej encji – dodamy do niej adnotację @OneToMany:

@OneToManyprivate List<Course> courses;

Jako typ pola użyliśmy tutaj List, ale moglibyśmy użyć Set lub Map (choć to wymaga nieco więcej konfiguracji).

Jak JPA odzwierciedla tę relację w bazie danych? Generalnie, dla tego typu relacji, musimy użyć klucza obcego w tabeli.

JPA robi to za nas, biorąc pod uwagę nasze dane wejściowe na temat tego, jak powinna obsługiwać relację. Odbywa się to za pomocą adnotacji @JoinColumn:

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

Użycie tej adnotacji powie JPA, że tabela COURSE musi mieć kolumnę klucza obcego TEACHER_ID, która odwołuje się do kolumny ID tabeli TEACHER.

Dodajmy trochę danych do tych tabel:

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 teraz sprawdźmy, czy relacja działa zgodnie z oczekiwaniami:

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

Widzimy, że kursy nauczyciela są zbierane automatycznie, gdy pobieramy instancję Teacher.

Jeśli nie jesteś zaznajomiony z testowaniem w Javie, możesz być zainteresowany lekturą Testowanie jednostkowe w Javie z JUnit 5!

Własna strona i dwukierunkowość

W poprzednim przykładzie, klasa Teacher jest nazywana własną stroną relacji One-To-Many. Dzieje się tak, ponieważ definiuje ona kolumnę złączenia między dwiema tabelami.

Klasa Course jest nazywana stroną odwołującą się w tej relacji.

Mogliśmy uczynić Course stroną posiadającą relacji, mapując pole Teacher z @ManyToOne w klasie Course zamiast:

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

Nie ma potrzeby posiadania listy kursów w klasie Teacher. Relacja działałaby w odwrotny sposób:

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

Tym razem użyliśmy adnotacji @ManyToOne, w ten sam sposób, w jaki użyliśmy @OneToMany.

Uwaga: Dobrą praktyką jest umieszczanie strony własności relacji w klasie/tabeli, w której będzie przechowywany klucz obcy.

Więc, w naszym przypadku ta druga wersja kodu jest lepsza. Ale co jeśli nadal chcemy, aby nasza klasa Teacher oferowała dostęp do swojej listy Course?

Możemy to zrobić definiując dwukierunkową relację:

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

Zachowujemy nasze mapowanie @ManyToOne na encji Course. Jednak mapujemy również listę Course do encji Teacher.

To, na co warto zwrócić uwagę, to użycie flagi mappedBy w adnotacji @OneToMany po stronie referencji.

Bez niej nie mielibyśmy dwukierunkowej relacji. Mielibyśmy dwie jednokierunkowe relacje. Obie encje mapowałyby klucze obce dla drugiej encji.

Bez niej mówimy JPA, że pole jest już zmapowane przez inną encję. Jest ono mapowane przez teacher pole Course encji.

Eager vs Lazy Loading

Inną rzeczą wartą uwagi jest eager i lazy loading. Mając wszystkie nasze relacje odwzorowane, mądrze jest unikać wpływania na pamięć oprogramowania poprzez umieszczanie w niej zbyt wielu encji, jeśli jest to niepotrzebne.

Wyobraźmy sobie, że Course jest ciężkim obiektem, a my ładujemy wszystkie Teacher obiekty z bazy danych dla jakiejś operacji. Nie musimy pobierać ani używać kursów do tej operacji, ale nadal są one ładowane obok obiektów Teacher.

To może być niszczące dla wydajności aplikacji. Technicznie można to rozwiązać, używając wzorca Data Transfer Object Design Pattern i pobierając informacje Teacher bez kursów.

Jednakże może to być ogromna przesada, jeśli wszystko, co zyskujemy dzięki wzorcowi, to wyłączenie kursów.

Na szczęście JPA pomyślało i sprawiło, że relacje One-to-Many domyślnie ładują się leniwie.

To oznacza, że relacja nie zostanie załadowana od razu, ale tylko wtedy, gdy i jeśli faktycznie jest potrzebna.

W naszym przykładzie, oznaczałoby to, że dopóki nie wywołamy metody Teacher#courses, kursy nie będą pobierane z bazy danych.

Dla kontrastu, relacje Many-to-One są domyślnie chętne, co oznacza, że relacja jest ładowana w tym samym czasie, co encja.

Możemy zmienić te właściwości poprzez ustawienie argumentu fetch obu adnotacji:

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

To odwróciłoby sposób, w jaki to działało początkowo. Kursy byłyby ładowane ochoczo, gdy tylko załadujemy obiekt Teacher. Dla kontrastu, teacher nie zostałby załadowany, gdy pobieramy courses, jeśli jest niepotrzebny w danym momencie.

Optionalność

Teraz porozmawiajmy o opcjonalności.

Relacja może być opcjonalna lub obowiązkowa.

Rozważając stronę One-to-Many – jest ona zawsze opcjonalna i nie możemy nic z nią zrobić. Z kolei strona Many-to-One oferuje nam możliwość uczynienia jej obowiązkową.

Domyślnie relacja jest opcjonalna, co oznacza, że możemy zapisać Course bez przypisywania mu nauczyciela:

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

Teraz uczyńmy tę relację obowiązkową. W tym celu użyjemy argumentu optional adnotacji @ManyToOne i ustawimy go na false (domyślnie jest to true):

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

W ten sposób nie możemy już zapisać kursu bez przypisania do niego nauczyciela:

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

Ale jeśli damy mu nauczyciela, znów będzie działał poprawnie:

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

A przynajmniej tak by się wydawało. Gdybyśmy uruchomili ten kod, zostałby rzucony wyjątek:

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

Dlaczego tak jest? Ustawiliśmy poprawny obiekt Teacher w obiekcie Course, który próbujemy persistować. Jednak nie zapisaliśmy obiektu Teacher przed próbą zapisania obiektu Course.

Tak więc obiekt Teacher nie jest zarządzaną jednostką. Naprawmy to i spróbujmy ponownie:

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

Wykonanie tego kodu spowoduje utrwalenie obu encji i zachowanie relacji między nimi.

Operacje kaskadowe

Mogliśmy jednak zrobić inną rzecz – mogliśmy wykonać operację kaskadową, a więc propagować trwałość obiektu Teacher podczas utrwalania obiektu Course.

To ma więcej sensu i działa tak, jak oczekiwalibyśmy tego w pierwszym przykładzie, który rzucił wyjątek.

Aby to zrobić, zmodyfikujemy flagę cascade adnotacji:

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

W ten sposób Hibernate wie, aby persystować również potrzebny obiekt w tej relacji.

Istnieje wiele typów operacji kaskadowych: PERSIST, MERGE, REMOVE, REFRESH, DETACH oraz ALL (która łączy wszystkie poprzednie).

Możemy również umieścić argument kaskadowania po stronie relacji One-to-Many, tak aby operacje były kaskadowane również od nauczycieli do ich kursów.

One-to-One

Teraz, gdy stworzyliśmy podstawy mapowania relacji w JPA poprzez relacje One-to-Many/Many-to-One i ich ustawienia, możemy przejść do relacji One-to-One.

Tym razem, zamiast mieć relację pomiędzy jedną encją po jednej stronie i grupą encji po drugiej, będziemy mieć maksymalnie jedną encję po każdej stronie.

To jest, na przykład, relacja pomiędzy Course i jego CourseMaterial. Najpierw zmapujmy CourseMaterial, czego jeszcze nie zrobiliśmy:

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

Adnotacją do mapowania pojedynczej encji na pojedynczą inną encję jest, co szokujące, @OneToOne.

Zanim ustawimy ją w naszym modelu, pamiętajmy, że relacja ma stronę posiadającą – najlepiej tę, która będzie trzymać klucz obcy w bazie danych.

W naszym przykładzie będzie to CourseMaterial, ponieważ ma sens, że odwołuje się do Course (choć moglibyśmy postąpić odwrotnie):

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

Nie ma sensu posiadać materiału bez kursu, który by go obejmował. Dlatego relacja nie jest optional w tym kierunku.

Mówiąc o kierunku, uczyńmy relację dwukierunkową, abyśmy mieli dostęp do materiału kursu, jeśli go posiada. W klasie Course dodajmy:

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

Powiadamiamy tutaj Hibernate’owi, że materiał w ramach klasy Course jest już odwzorowany przez pole course encji CourseMaterial.

Nie ma tutaj również atrybutu optional, ponieważ domyślnie jest on true, a moglibyśmy sobie wyobrazić kurs bez materiału (od bardzo leniwego nauczyciela).

Poza uczynieniem relacji dwukierunkową, moglibyśmy również dodać operacje kaskadowe lub sprawić, by encje ładowały się chętnie lub leniwie.

Many-to-Many

A teraz, last but not least: Relacje Many-to-Many. Zachowaliśmy je na koniec, ponieważ wymagają nieco więcej pracy niż poprzednie.

Efektywnie, w bazie danych, relacja Many-to-Many obejmuje tabelę środkową odwołującą się do obu innych tabel.

Na szczęście dla nas, JPA wykonuje większość pracy, musimy tylko rzucić tam kilka adnotacji, a ona zajmuje się resztą za nas.

Więc, dla naszego przykładu, relacja Many-to-Many będzie relacją pomiędzy instancjami Student i Course, ponieważ student może uczestniczyć w wielu kursach, a kurs może być śledzony przez wielu studentów.

Aby zmapować relację Many-to-Many użyjemy adnotacji @ManyToMany. Jednak tym razem użyjemy również adnotacji @JoinTable, aby skonfigurować tabelę, która będzie reprezentować tę relację:

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

Teraz sprawdź, co się tutaj dzieje. Adnotacja pobiera kilka parametrów. Przede wszystkim, musimy nadać tabeli nazwę. My wybraliśmy STUDENTS_COURSES.

Potem musimy powiedzieć Hibernate, do których kolumn ma dołączyć, aby wypełnić STUDENTS_COURSES. Pierwszy parametr, joinColumns określa, jak skonfigurować kolumnę złączenia (klucz obcy) dla strony relacji w tabeli. W tym przypadku jest to Course.

Z drugiej strony, parametr inverseJoinColumns robi to samo, ale dla strony odnoszącej się (Student).

Ustawmy zestaw danych ze studentami i kursami:

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

Oczywiście, to nie będzie działało po wyjęciu z pudełka. Będziemy musieli dodać metodę, która pozwoli nam dodawać studentów do kursów. Zmodyfikujmy nieco klasę Course:

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

Teraz możemy uzupełnić nasz zbiór danych:

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 uruchomieniu tego kodu, będzie on przechowywał nasze Course, Teacher i Student instancje, jak również ich relacje. Na przykład, pobierzmy studenta z persystowanego kursu i sprawdźmy, czy wszystko jest w porządku:

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

Oczywiście, nadal możemy zmapować relację jako dwukierunkową w taki sam sposób, w jaki zrobiliśmy to dla poprzednich relacji.

Możemy również kaskadować operacje jak również zdefiniować czy encje powinny ładować się leniwie czy chętnie (relacje Many-to-Many są domyślnie leniwe).

Podsumowanie

To kończy ten artykuł o relacjach mapowanych encji z JPA. Omówiliśmy relacje Many-to-One, One-to-Many, Many-to-Many oraz One-to-One. Dodatkowo, zbadaliśmy operacje kaskadowe, dwukierunkowość, opcjonalność i eager/lazy loading fetch-types.

Kod dla tej serii można znaleźć na GitHub.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.