Guide till JPA med Hibernate – relationsmappning

Inledning

I den här artikeln går vi in på relationsmappning med JPA och Hibernate i Java.

Java Persistence API (JPA) är en standard för persistens i Java-ekosystemet. Det gör det möjligt för oss att mappa vår domänmodell direkt till databasstrukturen och ger oss sedan flexibiliteten att manipulera objekt i vår kod – istället för att krångla med besvärliga JDBC-komponenter som Connection, ResultSet etc.

Vi kommer att göra en omfattande guide för att använda JPA med Hibernate som leverantör. I den här artikeln kommer vi att behandla relationsmappningar.

  • Guide till JPA med Hibernate – grundläggande mappning
  • Guide till JPA med Hibernate – relationsmappning (du är här)
  • Guide till JPA med Hibernate: Inheritance Mapping (kommer snart!)
  • Guide to JPA with Hibernate – Querying (kommer snart!)

Vårt exempel

För att komma igång ska vi påminna om exemplet som vi använde i den förra delen av den här serien. Tanken var att kartlägga modellen för en skola med elever som läser kurser som ges av lärare.

Här är hur denna modell ser ut:

Som vi kan se finns det några klasser med vissa egenskaper. Dessa klasser har relationer mellan sig. I slutet av den här artikeln kommer vi att ha mappat alla dessa klasser till databastabeller och bevarat deras relationer.

För övrigt kommer vi att kunna hämta dem och manipulera dem som objekt, utan att behöva använda JDBC.

Relationer

Först av allt, låt oss definiera en relation. Om vi tittar på vårt klassdiagram kan vi se några relationer:

Lärare och kurser – studenter och kurser – kurser och kursmaterial.

Det finns också kopplingar mellan studenter och adresser, men de betraktas inte som relationer. Detta beror på att en Address inte är en enhet (dvs. den mappas inte till en egen tabell). Så när det gäller JPA är det inte en relation.

Det finns några typer av relationer:

  • En till flera
  • Många till en
  • En till en
  • Många till flera

Låt oss ta itu med dessa relationer en efter en.

En-till-Många/Många-till-en

Vi börjar med relationerna En-till-Många och Många-till-en, som är nära besläktade. Man kan säga att de är motsatta sidor av samma mynt.

Vad är en One-to-Many-relation?

Som namnet antyder är det en relation som kopplar en enhet till många andra enheter.

I vårt exempel skulle detta vara en Teacher och deras Courses. En lärare kan ge flera kurser, men en kurs ges bara av en lärare (det är Many-to-One-perspektivet – många kurser till en lärare).

Ett annat exempel skulle kunna vara på sociala medier – ett foto kan ha många kommentarer, men var och en av dessa kommentarer tillhör det enda fotot.

För att dyka ner i detaljerna om hur detta förhållande ska kartläggas ska vi skapa våra enheter:

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

Nu ska fälten i klassen Teacher innehålla en lista över kurser. Eftersom vi vill kartlägga den här relationen i en databas, som inte kan inkludera en lista över entiteter inom en annan entitet – kommer vi att annotera den med en @OneToMany-annotation:

@OneToManyprivate List<Course> courses;

Vi har använt en List som fälttyp här, men vi hade kunnat välja en Set eller en Map (även om den här typen kräver lite mer konfiguration).

Hur återspeglar JPA den här relationen i databasen? Generellt sett måste vi för den här typen av relation använda en främmande nyckel i en tabell.

JPA gör detta åt oss, med tanke på vår input om hur den ska hantera relationen. Detta görs via @JoinColumn-annotationen:

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

Användning av den här annotationen talar om för JPA att COURSE-tabellen måste ha en främmande nyckelkolumn TEACHER_ID som refererar till TEACHER-tabellens ID-kolumn.

Låt oss lägga till lite data till dessa 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');

Och låt oss nu kontrollera om relationen fungerar som förväntat:

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 att lärarens kurser samlas in automatiskt, när vi hämtar Teacher instansen.

Om du inte är bekant med testning i Java kan du vara intresserad av att läsa Unit Testing in Java with JUnit 5!

Owning Side and Bidirectionality

I det föregående exemplet kallas Teacher-klassen för den ägande sidan av One-To-Many-relationen. Detta beror på att den definierar join-kolumnen mellan de två tabellerna.

Course kallas den refererande sidan i den relationen.

Vi kunde ha gjort Course till den ägande sidan av relationen genom att mappa Teacher-fältet med @ManyToOne i Course-klassen i stället:

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

Det finns inget behov av att ha en lista över kurser i Teacher-klassen nu. Relationen skulle ha fungerat på motsatt sätt:

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

Den här gången använde vi @ManyToOne-annotationen, på samma sätt som vi använde @OneToMany.

Notera: Det är en bra metod att placera den ägande sidan av en relation i klassen/tabellen där den främmande nyckeln kommer att hållas.

Så, i vårt fall är denna andra version av koden bättre. Men vad händer om vi fortfarande vill att vår Teacher-klass ska erbjuda tillgång till sin Course-lista?

Vi kan göra det genom att definiera en dubbelriktad relation:

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

Vi behåller vår @ManyToOne-mappning på Course-enheten. Men vi mappar också en lista med Courses till Teacher-enheten.

Vad som är viktigt att notera här är användningen av mappedBy-flaggan i @OneToMany-annotationen på den refererande sidan.

Och utan den skulle vi inte ha en dubbelriktad relation. Vi skulle ha två envägsrelationer. Båda enheterna skulle mappa utländska nycklar för den andra enheten.

Med den talar vi om för JPA att fältet redan är mappat av en annan enhet. Det mappas av teacher-fältet i Course-enheten.

Eager vs Lazy Loading

En annan sak som är värd att notera är eager och lazy loading. Med alla våra relationer kartlagda är det klokt att undvika att påverka programvarans minne genom att lägga in för många entiteter i det om det är onödigt.

Föreställ dig att Course är ett tungt objekt och att vi laddar alla Teacher objekt från databasen för någon operation. Vi behöver inte hämta eller använda kurserna för den här operationen, men de laddas ändå tillsammans med Teacher-objekten.

Detta kan vara förödande för programmets prestanda. Tekniskt sett kan detta lösas genom att använda designmönstret Data Transfer Object Design Pattern och hämta Teacher-information utan kurserna.

Det här kan dock vara en enorm överdrift om allt vi får ut av mönstret är att utesluta kurserna.

Tacksamt nog har JPA tänkt på förhand och gjort så att One-to-Many-relationer laddas lazily som standard.

Detta innebär att relationen inte laddas direkt, utan först när och om den verkligen behövs.

I vårt exempel skulle det innebära att kurserna inte hämtas från databasen förrän vi anropar Teacher#courses-metoden.

Många-till-en-relationer är däremot ivriga som standard, vilket innebär att relationen laddas samtidigt som enheten laddas.

Vi kan ändra dessa egenskaper genom att ställa in fetch-argumentet för båda annotationerna:

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

Det skulle vända på hur det fungerade från början. Kurser skulle laddas ivrigt, så snart vi laddar ett Teacher objekt. Däremot skulle teacher inte laddas när vi hämtar courses om det inte behövs för tillfället.

Optionalitet

Nu ska vi prata om optionalitet.

En relation kan vara valfri eller obligatorisk.

Objektet är alltid valfritt och vi kan inte göra något åt det. På Many-to-One-sidan har vi däremot möjlighet att göra den obligatorisk.

Som standard är relationen valfri, vilket innebär att vi kan spara en Course utan att tilldela den en lärare:

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

Nu gör vi den här relationen obligatorisk. För att göra det använder vi optional-argumentet i @ManyToOne-annotationen och sätter det till false (det är true som standard):

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

Därmed kan vi inte längre spara en kurs utan att tilldela den en lärare:

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

Men om vi ger den en lärare fungerar det bra igen:

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

Tja, det verkar åtminstone så. Om vi hade kört koden skulle ett undantag ha kastats:

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

Varför detta? Vi har satt ett giltigt Teacher-objekt i Course-objektet som vi försöker bevara. Vi har dock inte upprätthållit Teacher-objektet innan vi försökte upprätthålla Course-objektet.

Det innebär att Teacher-objektet inte är en hanterad enhet. Låt oss rätta till det och försöka 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();

Att köra den här koden kommer att persistera båda entiteterna och persistera relationen mellan dem.

Kaskadoperationer

Vi hade dock kunnat göra en annan sak – vi hade kunnat kaskadoperera och på så sätt propagera persistensen av Teacher-objektet när vi persisterar Course-objektet.

Detta är vettigare och fungerar på det sätt vi förväntar oss som i det första exemplet som kastade ett undantag.

För att göra detta ändrar vi cascade-flaggan i annotationen:

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

På så sätt vet Hibernate att det nödvändiga objektet ska persisteras i den här relationen också.

Det finns flera typer av kaskaderingsoperationer: PERSIST, MERGE, REMOVE, REFRESH, DETACH och ALL (som kombinerar alla tidigare).

Vi kan också sätta kaskadargumentet på en-till-många-sidan av relationen, så att operationer också kaskaderas från lärare till deras kurser.

En-till-en

När vi nu har lagt grunden för relationsmappning i JPA genom One-to-Many/Many-to-One-relationer och deras inställningar kan vi gå vidare till One-to-One-relationer.

Istället för att ha en relation mellan en enhet på ena sidan och en massa enheter på den andra sidan, har vi den här gången högst en enhet på varje sida.

Detta är till exempel relationen mellan en Course och dess CourseMaterial. Låt oss först mappa CourseMaterial, vilket vi inte har gjort ännu:

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

Anteckningen för mappning av en enda entitet till en enda annan entitet är, föga chockerande, @OneToOne.

För att ställa in den i vår modell ska vi komma ihåg att en relation har en ägande sida – företrädesvis den sida som kommer att inneha den utländska nyckeln i databasen.

I vårt exempel skulle det vara CourseMaterial eftersom det är logiskt att den refererar till en Course (även om vi skulle kunna göra tvärtom):

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

Det finns ingen mening med att ha material utan att det finns en kurs som omfattar det. Därför är relationen inte optional i den riktningen.

På tal om riktning, låt oss göra relationen dubbelriktad, så att vi kan få tillgång till materialet i en kurs om den har en sådan. I klassen Course lägger vi till:

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

Här talar vi om för Hibernate att materialet i en Course redan är mappat av course-fältet i CourseMaterial-enheten.

Det finns inte heller något optional-attribut här eftersom det är true som standard, och vi skulle kunna föreställa oss en kurs utan material (från en mycket lat lärare).

Förutom att göra relationen dubbelriktad kan vi också lägga till kaskadoperationer eller göra så att enheter laddas ivrigt eller långsamt.

Many-to-Many

Nu, sist men inte minst: Många-till-Many-relationer. Vi behöll dessa till slutet eftersom de kräver lite mer arbete än de tidigare.

Effektivt, i en databas, innebär en Many-to-Many-relation att en mellantabell refererar till båda de andra tabellerna.

Troligt nog för oss gör JPA det mesta av jobbet, vi behöver bara slänga ut några annotationer, så sköter den resten åt oss.

För vårt exempel kommer alltså Many-to-Many-relationen att vara den mellan Student och Course instanser eftersom en student kan delta i flera kurser och en kurs kan följas av flera studenter.

För att mappa en Many-to-Many-relation använder vi @ManyToMany-annotationen. Den här gången använder vi dock även en @JoinTable-annotation för att ställa in tabellen som representerar 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 går vi igenom vad som händer här. Annotationen tar emot några parametrar. Först och främst måste vi ge tabellen ett namn. Vi har valt att det ska vara STUDENTS_COURSES.

Därefter måste vi tala om för Hibernate vilka kolumner som ska sammanfogas för att fylla STUDENTS_COURSES. Den första parametern, joinColumns, definierar hur vi ska konfigurera join-kolumnen (främmande nyckel) för den ägande sidan av relationen i tabellen. I det här fallet är den ägande sidan en Course.

Parametern inverseJoinColumns gör å andra sidan samma sak, men för den refererande sidan (Student).

Låt oss konfigurera en datauppsättning med studenter och 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);

Självklart kommer detta inte att fungera utan vidare. Vi måste lägga till en metod som gör att vi kan lägga till studenter till en kurs. Låt oss ändra Course-klassen lite:

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

Nu kan vi komplettera vårt dataset:

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 den här koden har körts kommer den att upprätthålla våra Course-, Teacher– och Student-instanser samt deras relationer. Låt oss till exempel hämta en student från en persisterad kurs och kontrollera om allt är bra:

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

Självklart kan vi fortfarande mappa relationen som dubbelriktad på samma sätt som vi gjorde för de tidigare relationerna.

Vi kan också kaskadera operationer samt definiera om entiteter ska laddas lazily eller eagerly (Many-to-Many-relationer är lazy som standard).

Slutsats

Det avslutar den här artikeln om relationer mellan mappade entiteter med JPA. Vi har behandlat relationerna Many-to-One, One-to-Many, Many-to-Many och One-to-One. Dessutom har vi utforskat kaskadoperationer, bidirektionalitet, optionalitet och eager/lazy loading fetch-types.

Koden för den här serien finns på GitHub.

Lämna ett svar

Din e-postadress kommer inte publiceras.