Guide til JPA med Hibernate – Relationship Mapping

Indledning

I denne artikel vil vi dykke ned i Relationship Mapping med JPA og Hibernate i Java.

Java Persistence API (JPA) er persistensstandarden i Java-økosystemet. Det giver os mulighed for at mappe vores domænemodel direkte til databasestrukturen og giver os derefter fleksibilitet til at manipulere objekter i vores kode – i stedet for at rode med besværlige JDBC-komponenter som Connection, ResultSet osv.

Vi vil lave en omfattende guide til brug af JPA med Hibernate som leverandør. I denne artikel vil vi dække relationstilknytninger.

  • Guide til JPA med Hibernate – grundlæggende tilknytning
  • Guide til JPA med Hibernate – relationstilknytning (du er her)
  • Guide til JPA med Hibernate: Inheritance Mapping (kommer snart!)
  • Guide til JPA med Hibernate – Søgefunktioner (kommer snart!)
  • Vores eksempel

    Hvor vi går i gang, skal vi minde os om det eksempel, vi brugte i den foregående del af denne serie. Ideen var at kortlægge modellen for en skole med elever, der tager kurser, der gives af lærere.

    Her er hvordan denne model ser ud:

    Som vi kan se, er der et par klasser med visse egenskaber. Disse klasser har relationer mellem dem. Ved slutningen af denne artikel vil vi have kortlagt alle disse klasser til databasetabeller og bevaret deres relationer.

    Dertil kommer, at vi vil kunne hente dem og manipulere dem som objekter uden besværet med JDBC.

    Relationer

    Først af alt skal vi definere en relation. Hvis vi kigger på vores klassediagram, kan vi se et par relationer:

    Lærere og kurser – studerende og kurser – kurser og kursusmaterialer.

    Der er også forbindelser mellem studerende og adresser, men de betragtes ikke som relationer. Det skyldes, at en Address ikke er en entitet (dvs. den er ikke afbildet til sin egen tabel). Så for JPA’s vedkommende er det ikke en relation.

    Der er et par typer relationer:

    • En-til-Many
    • Many-til-One
    • En-til-One
    • Many-til-Many
    • Lad os tage fat på disse relationer en for en.

      One-to-Many/Many-to-One

      Vi starter med relationerne One-to-Many og Many-to-One, som er nært beslægtede. Man kan sige, at de er de modsatte sider af samme mønt.

      Hvad er en One-to-Many-relation?

      Som navnet antyder, er det en relation, der forbinder en enhed med mange andre enheder.

      I vores eksempel ville det være en Teacher og deres Courses. En lærer kan give flere kurser, men et kursus gives kun af én lærer (det er Many-to-One-perspektivet – mange kurser til én lærer).

      Et andet eksempel kunne være på sociale medier – et foto kan have mange kommentarer, men hver af disse kommentarer hører til det ene foto.

      Hvor vi dykker ned i detaljerne om, hvordan dette forhold skal kortlægges, skal vi oprette vores entiteter:

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

Nu skal felterne i Teacher-klassen indeholde en liste over kurser. Da vi gerne vil kortlægge dette forhold i en database, som ikke kan indeholde en liste over enheder i en anden enhed – annoterer vi det med en @OneToMany-annotation:

@OneToManyprivate List<Course> courses;

Vi har brugt en List som felttype her, men vi kunne have valgt en Set eller en Map (denne kræver dog en smule mere konfiguration).

Hvordan afspejler JPA dette forhold i databasen? Generelt skal vi for denne type relation bruge en fremmednøgle i en tabel.

JPA gør dette for os på baggrund af vores input om, hvordan den skal håndtere relationen. Dette gøres via annotationen @JoinColumn:

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

Ved brug af denne annotation får JPA at vide, at tabellen COURSE skal have en foreign key-kolonne TEACHER_ID, der refererer til TEACHER-tabellens ID-kolonne.

Lad os tilføje nogle data til disse tabeller:

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

Og lad os nu kontrollere, om forholdet fungerer som forventet:

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

Vi kan se, at lærerens kurser indsamles automatisk, når vi henter Teacher-instansen.

Hvis du ikke er bekendt med testning i Java, er du måske interesseret i at læse Unit Testing in Java with JUnit 5!

Owning Side and Bidirectionality

I det foregående eksempel kaldes Teacher-klassen for den ejende side af One-To-Many-forholdet. Det skyldes, at den definerer join-kolonnen mellem de to tabeller.

Den Course kaldes den refererende side i denne relation.

Vi kunne have gjort Course til den ejende side af relationen ved at mappe Teacher-feltet med @ManyToOne i Course-klassen i stedet:

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

Der er ingen grund til at have en liste over kurser i Teacher-klassen nu. Relationen ville have fungeret på den modsatte måde:

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

Denne gang brugte vi annotationen @ManyToOne, på samme måde som vi brugte @OneToMany.

Bemærk: Det er en god praksis at placere den ejende side af en relation i den klasse/tabel, hvor den fremmede nøgle skal holdes.

Så, i vores tilfælde er denne anden version af koden bedre. Men hvad nu, hvis vi stadig ønsker, at vores Teacher-klasse skal tilbyde adgang til dens Course-liste?

Det kan vi gøre ved at definere et tovejsforhold:

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

Vi beholder vores @ManyToOne-tilknytning på Course-enheden. Men vi mapper også en liste af Courses til Teacher-entiteten.

Det er vigtigt at bemærke her er brugen af mappedBy-flaget i @OneToMany-annotationen på den henvisende side.

Hvis det ikke var tilfældet, ville vi ikke have et tovejsforhold. Vi ville have to envejsrelationer. Begge entiteter ville afbilde fremmednøgler for den anden enhed.

Med den fortæller vi JPA, at feltet allerede er afbildet af en anden enhed. Det er afbildet af teacher-feltet i Course-entiteten.

Eager vs Lazy Loading

En anden ting, der er værd at bemærke, er eager og lazy loading. Med alle vores relationer kortlagt er det klogt at undgå at påvirke softwarens hukommelse ved at lægge for mange entiteter i den, hvis det er unødvendigt.

Forestil dig, at Course er et tungt objekt, og vi indlæser alle Teacher-objekter fra databasen til en eller anden operation. Vi har ikke brug for at hente eller bruge kurserne til denne operation, men de bliver stadig indlæst sammen med Teacher-objekterne.

Dette kan være ødelæggende for programmets ydeevne. Teknisk set kan dette løses ved at bruge Data Transfer Object Design Pattern og hente Teacher-oplysninger uden kurserne.

Det kan imidlertid være et massivt overkill, hvis det eneste, vi får ud af mønsteret, er at udelukke kurserne.

Godt nok har JPA tænkt forud og gjort One-to-Many-relationer lazily loaded som standard.

Det betyder, at relationen ikke indlæses med det samme, men kun når og hvis der faktisk er brug for den.

I vores eksempel ville det betyde, at indtil vi kalder på Teacher#courses-metoden, bliver kurserne ikke hentet fra databasen.

Mange-til-en-relationer er derimod som standard ivrige, hvilket betyder, at relationen indlæses samtidig med, at entiteten bliver indlæst.

Vi kan ændre disse egenskaber ved at indstille fetch-argumentet i begge annotationer:

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

Det ville vende den måde, det fungerede på i starten. Kurser ville blive indlæst ivrigt, så snart vi indlæser et Teacher objekt. Derimod ville teacher ikke blive indlæst, når vi henter courses, hvis det ikke er nødvendigt på det tidspunkt.

Optionalitet

Nu skal vi tale om optionalitet.

En relation kan være valgfri eller obligatorisk.

Med hensyn til One-to-Many-siden – den er altid valgfri, og det kan vi ikke gøre noget ved. Mange-til-en-siden giver os derimod mulighed for at gøre den obligatorisk.

Som standard er relationen valgfri, hvilket betyder, at vi kan gemme en Course uden at tildele den en lærer:

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

Lad os nu gøre denne relation obligatorisk. For at gøre det bruger vi optional-argumentet i @ManyToOne-annotationen og sætter det til false (det er true som standard):

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

Sådan kan vi ikke længere gemme et kursus uden at tildele det en lærer:

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

Men hvis vi giver det en lærer, fungerer det fint igen:

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

Det ser i det mindste sådan ud. Hvis vi havde kørt koden, ville der være blevet kastet en undtagelse:

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

Hvorfor er det? Vi har indstillet et gyldigt Teacher-objekt i det Course-objekt, som vi forsøger at persistere. Vi har imidlertid ikke persisteret Teacher-objektet, før vi forsøgte at persistere Course-objektet.

Det betyder, at Teacher-objektet ikke er en administreret enhed. Lad os rette op på det og prøve igen:

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

Den her kode vil persistere begge entiteter og persistere forholdet mellem dem.

Kaskadeoperationer

Vi kunne dog have gjort noget andet – vi kunne have kaskaderet og dermed propageret persistensen af Teacher-objektet, når vi persisterer Course-objektet.

Dette giver mere mening og fungerer som vi forventer det, ligesom i det første eksempel, som kastede en undtagelse.

For at gøre dette ændrer vi cascade-flaget i annotationen:

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

På denne måde ved Hibernate, at det nødvendige objekt i denne relation også skal persisteres.

Der er flere typer kaskadeoperationer: PERSIST, MERGE, REMOVE, REFRESH, DETACH og ALL (der kombinerer alle de foregående).

Vi kan også sætte kaskadeargumentet på en-til-mange-siden af relationen, så operationer også kaskaderes fra lærere til deres kurser.

One-to-One

Nu da vi har opstillet grundlaget for relationsafbildning i JPA gennem One-to-Many/Many-to-One-relationer og deres indstillinger, kan vi gå videre til One-to-One-relationer.

Denne gang har vi i stedet for at have en relation mellem en enhed på den ene side og en masse enheder på den anden side højst én enhed på hver side.

Det er f.eks. relationen mellem en Course og dens CourseMaterial. Lad os først mappe CourseMaterial, hvilket vi ikke har gjort endnu:

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

Annotationen til at mappe en enkelt enhed til en enkelt anden enhed er, ikke chokerende nok, @OneToOne.

Hvor vi sætter den op i vores model, skal vi huske, at en relation har en ejerside – helst den side, som skal have den fremmede nøgle i databasen.

I vores eksempel ville det være CourseMaterial, da det giver mening, at den refererer til en Course (selvom vi kunne gå den anden vej rundt):

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

Der er ingen mening med at have materiale uden et kursus, der omfatter det. Derfor er relationen ikke optional i den retning.

Apropos retning, lad os gøre relationen bidirektionel, så vi kan få adgang til materialet i et kursus, hvis det har et sådant. Lad os i Course-klassen tilføje:

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

Her fortæller vi Hibernate, at materialet i en Course allerede er afbildet af course-feltet i CourseMaterial-entiteten.

Der er heller ikke nogen optional-attribut her, da den er true som standard, og vi kunne forestille os et kursus uden materiale (fra en meget doven lærer).

Ud over at gøre relationen bidirektionel kunne vi også tilføje kaskadeoperationer eller få enheder til at indlæse ivrigt eller dovent.

Many-to-Many

Nu, sidst men ikke mindst: Mange-til-Many-relationer. Vi beholdt disse til sidst, fordi de kræver lidt mere arbejde end de foregående.

Effektivt set i en database involverer et Many-to-Many-forhold en mellemtabel, der refererer til begge andre tabeller.

Glækkeligt nok for os gør JPA det meste af arbejdet, vi skal bare smide et par annotationer ud, og så klarer den resten for os.

Så for vores eksempel vil Many-to-Many-relationen være den mellem Student og Course-instanserne, da en studerende kan deltage i flere kurser, og et kursus kan følges af flere studerende.

For at mappe en Many-to-Many-relation bruger vi annotationen @ManyToMany. Denne gang bruger vi dog også en @JoinTable-annotation til at oprette den tabel, der repræsenterer relationen:

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

Nu skal du gennemgå, hvad der foregår her. Annotationen tager et par parametre. Først og fremmest skal vi give tabellen et navn. Vi har valgt, at det skal være STUDENTS_COURSES.

Dernæst skal vi fortælle Hibernate, hvilke kolonner vi skal sammenføje for at udfylde STUDENTS_COURSES. Den første parameter, joinColumns, definerer, hvordan vi skal konfigurere join-kolonnen (fremmednøglen) på den ejende side af relationen i tabellen. I dette tilfælde er den ejende side en Course.

Parameteren inverseJoinColumns gør det samme, men for den henvisende side (Student).

Lad os opsætte et datasæt med studerende og kurser:

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

Det virker selvfølgelig ikke uden videre. Vi bliver nødt til at tilføje en metode, der giver os mulighed for at tilføje elever til et kursus. Lad os ændre Course-klassen en smule:

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

Nu kan vi færdiggøre vores datasæt:

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

Når denne kode er kørt, vil den persistere vores Course-, Teacher– og Student-instanser samt deres relationer. Lad os f.eks. hente en elev fra et persisteret kursus og kontrollere, om alt er i orden:

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

Vi kan selvfølgelig stadig kortlægge relationen som tovejsrelation på samme måde, som vi gjorde for de tidligere relationer.

Vi kan også kaskadeoperationer samt definere, om entiteter skal indlæses lazily eller eagerly (Many-to-Many-relationer er som standard lazy).

Konklusion

Det afslutter denne artikel om relationer af kortlagte entiteter med JPA. Vi har dækket Many-to-One-, One-to-Many-, Many-to-Many- og One-to-One-relationer. Derudover har vi udforsket kaskadeoperationer, bidirektionalitet, valgfrihed og eager/lazy loading fetch-typer.

Koden til denne serie kan findes ovre på GitHub.

Skriv et svar

Din e-mailadresse vil ikke blive publiceret.