Gids voor JPA met Hibernate – Relationship Mapping

Inleiding

In dit artikel duiken we in Relationship Mapping met JPA en Hibernate in Java.

De Java Persistence API (JPA) is de persistentiestandaard van het Java-ecosysteem. Het stelt ons in staat om ons domein model direct naar de database structuur te mappen en geeft ons vervolgens de flexibiliteit om objecten in onze code te manipuleren – in plaats van te knoeien met omslachtige JDBC componenten zoals Connection, ResultSet, etc.

We zullen een uitgebreide gids maken voor het gebruik van JPA met Hibernate als zijn leverancier. In dit artikel zullen we betrekking hebben op relatie mappings.

  • Guide naar JPA met Hibernate – Basic Mapping
  • Guide naar JPA met Hibernate – Relaties Mapping (je bent er)
  • Guide naar JPA met Hibernate: Inheritance Mapping (verschijnt binnenkort!)
  • Guide naar JPA met Hibernate – Querying (verschijnt binnenkort!)

Ons voorbeeld

Voordat we beginnen, laten we ons het voorbeeld herinneren dat we in het vorige deel van deze serie hebben gebruikt. Het idee was om het model van een school in kaart te brengen met leerlingen die cursussen volgen die door leraren worden gegeven.

Hier ziet dit model eruit:

Zoals we kunnen zien, zijn er een paar klassen met bepaalde eigenschappen. Deze klassen hebben onderlinge relaties. Aan het eind van dit artikel zullen we al deze klassen in databasetabellen hebben ondergebracht en hun relaties hebben bewaard.

Daarnaast zullen we in staat zijn om ze op te halen en te manipuleren als objecten, zonder het gedoe van JDBC.

Relaties

Laten we eerst eens een relatie definiëren. Als we naar ons klassendiagram kijken, zien we een aantal relaties:

Docenten en cursussen – studenten en cursussen – cursussen en cursusmateriaal.

Er zijn ook relaties tussen studenten en adressen, maar die worden niet als relaties beschouwd. Dit komt omdat een Address geen entiteit is (d.w.z. het is niet gekoppeld aan een eigen tabel). Wat JPA betreft is het dus geen relatie.

Er zijn een paar soorten relaties:

  • One-to-Many
  • Many-to-One
  • One-to-One
  • Many-to-Many

Laten we deze relaties een voor een aanpakken.

One-to-Many/Many-to-One

We beginnen met de One-to-Many en de Many-to-One relaties, die nauw verwant zijn. Je zou kunnen zeggen dat ze de tegengestelde kanten van dezelfde medaille zijn.

Wat is een One-to-Many-relatie?

Zoals de naam al aangeeft, is het een relatie die één entiteit met veel andere entiteiten verbindt.

In ons voorbeeld zou dit een Teacher en hun Courses zijn. Een docent kan meerdere cursussen geven, maar een cursus wordt slechts door één docent gegeven (dat is het Many-to-One-perspectief – veel cursussen aan één docent).

Een ander voorbeeld zou op sociale media kunnen zijn – een foto kan veel commentaren hebben, maar elk van die commentaren hoort bij die ene foto.

Voordat we in detail gaan bekijken hoe we deze relatie in kaart kunnen brengen, maken we onze entiteiten:

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

Nu moeten de velden van de klasse Teacher een lijst met cursussen bevatten. Omdat we deze relatie in een database willen vastleggen, die geen lijst van entiteiten binnen een andere entiteit kan bevatten, annoteren we deze met een @OneToMany annotatie:

@OneToManyprivate List<Course> courses;

We hebben hier een List als veldtype gebruikt, maar we hadden ook voor een Set of een Map kunnen gaan (hoewel deze iets meer configuratie vereist).

Hoe geeft JPA deze relatie weer in de database? In het algemeen moeten we voor dit type relatie een foreign key in een tabel gebruiken.

JPA doet dit voor ons, gegeven onze input over hoe het de relatie moet behandelen. Dit wordt gedaan via de @JoinColumn annotatie:

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

Het gebruik van deze annotatie zal JPA vertellen dat de COURSE tabel een foreign key kolom TEACHER_ID moet hebben die verwijst naar de ID kolom van de TEACHER tabel.

Laten we wat gegevens aan die tabellen toevoegen:

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

En laten we nu eens kijken of de relatie werkt zoals verwacht:

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

We kunnen zien dat de cursussen van de leraar automatisch worden verzameld, wanneer we de Teacher instantie opvragen.

Als je niet bekend bent met testen in Java, zou je geïnteresseerd kunnen zijn in het lezen van Unit Testing in Java met JUnit 5!

Owning Side and Bidirectionality

In het vorige voorbeeld wordt de Teacher klasse de owning side van de One-To-Many relatie genoemd. Dit komt omdat het de join kolom tussen de twee tabellen definieert.

De Course wordt de verwijzende kant in die relatie genoemd.

We hadden van Course de bezittende kant van de relatie kunnen maken door het Teacher veld te mappen met @ManyToOne in de Course klasse:

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

Het is nu niet nodig om een lijst van cursussen in de Teacher klasse te hebben. De relatie zou andersom hebben gewerkt:

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

Dit keer hebben we de @ManyToOne annotatie gebruikt, op dezelfde manier als we @OneToMany hebben gebruikt.

Note: Het is een goed gebruik om de eigen kant van een relatie te plaatsen in de klasse/tabel waar de foreign key zal worden gehouden.

Dus, in ons geval is deze tweede versie van de code beter. Maar wat als we nog steeds willen dat onze Teacher klasse toegang biedt tot de Course lijst?

Dat kunnen we doen door een bidirectionele relatie te definiëren:

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

We houden onze @ManyToOne mapping op de Course entiteit. We mappen echter ook een lijst van Courses aan de Teacher entiteit.

Wat hier belangrijk is om op te merken, is het gebruik van de mappedBy vlag in de @OneToMany annotatie aan de verwijzende kant.

Zonder deze vlag zouden we geen tweerichtings relatie hebben. We zouden twee eenrichtings relaties hebben. Beide entiteiten zouden foreign keys voor de andere entiteit in kaart brengen.

Met deze code vertellen we JPA dat het veld al door een andere entiteit in kaart is gebracht. Het wordt in kaart gebracht door het teacher veld van de Course entiteit.

Eager vs Lazy Loading

Er is ook nog iets op te merken over eager en lazy loading. Met al onze relaties in kaart gebracht, is het verstandig om het geheugen van de software niet te belasten door er te veel entiteiten in te stoppen als dat niet nodig is.

Stel je voor dat Course een zwaar object is, en we laden alle Teacher objecten uit de database voor een of andere bewerking. We hoeven de cursussen niet op te halen of te gebruiken voor deze operatie, maar ze worden nog steeds geladen naast de Teacher objecten.

Dit kan desastreus zijn voor de prestaties van de applicatie. Technisch kan dit worden opgelost door gebruik te maken van het Data Transfer Object Design Pattern en Teacher informatie op te halen zonder de cursussen.

Dit kan echter een enorme overkill zijn als het enige wat we winnen met het patroon het uitsluiten van de cursussen is.

Gelukkig heeft JPA vooruitgedacht en One-to-Many-relaties standaard lui laten laden.

Dit betekent dat de relatie niet meteen wordt geladen, maar alleen als en wanneer dat echt nodig is.

In ons voorbeeld zou dat betekenen dat de cursussen niet uit de database worden opgehaald totdat we de methode Teacher#courses aanroepen.

Veel-op-één-relaties daarentegen worden standaard gretig geladen, wat betekent dat de relatie wordt geladen op hetzelfde moment als de entiteit wordt geladen.

We kunnen deze kenmerken veranderen door het fetch-argument van beide annotaties in te stellen:

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

Dat zou de oorspronkelijke werkwijze omkeren. Cursussen zouden “eagerly” worden geladen, zodra we een Teacher object laden. Daarentegen zou de teacher niet worden geladen wanneer we courses ophalen, als deze op dat moment niet nodig is.

Optionaliteit

Nou, laten we het eens hebben over optionaliteit.

Een relatie kan optioneel of verplicht zijn.

Aan de één-op-veel-kant – deze is altijd optioneel, en we kunnen er niets aan doen. De Many-to-One-kant daarentegen biedt ons de mogelijkheid om deze verplicht te maken.

De relatie is standaard optioneel, wat betekent dat we een Course kunnen opslaan zonder er een leraar aan toe te wijzen:

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

Nu, laten we deze relatie verplicht maken. Om dat te doen, gebruiken we het optional argument van de @ManyToOne annotatie en zetten het op false (standaard is het true):

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

Dus kunnen we niet langer een cursus opslaan zonder er een leraar aan toe te wijzen:

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

Maar als we het een leraar geven, werkt het weer prima:

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

Althans, daar lijkt het op. Als we de code hadden uitgevoerd, zou er een exception zijn gegooid:

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

Waarom? We hebben een geldig Teacher object in het Course object gezet dat we proberen te persisteren. We hebben echter het Teacher object niet geperst voordat we het Course object probeerden te persisteren.

Dus, het Teacher object is geen beheerde entiteit. Laten we dat herstellen en het opnieuw proberen:

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

Het uitvoeren van deze code zal beide entiteiten persisteren en de relatie tussen hen behouden.

Cascading Operations

We hadden echter ook iets anders kunnen doen – we hadden kunnen cascaderen, en dus de persistentie van het Teacher object kunnen propageren wanneer we het Course object persisteren.

Dit is zinvoller en werkt zoals we verwachten in het eerste voorbeeld, dat een exception opleverde.

Om dit te doen, passen we de cascade vlag van de annotatie aan:

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

Op deze manier weet Hibernate dat het benodigde object in deze relatie ook moet worden bewaard.

Er zijn meerdere soorten cascade operaties: PERSIST, MERGE, REMOVE, REFRESH, DETACH, en ALL (die alle voorgaande combineert).

We kunnen het cascade-argument ook aan de One-to-Many-kant van de relatie zetten, zodat bewerkingen ook van docenten naar hun cursussen worden gecascadeerd.

One-to-One

Nu we de basis hebben gelegd voor relatie mapping in JPA door middel van One-to-Many/Many-to-One relaties en hun instellingen, kunnen we verder gaan met One-to-One relaties.

Deze keer, in plaats van een relatie tussen een entiteit aan de ene kant en een heleboel entiteiten aan de andere kant, zullen we maximaal een entiteit aan elke kant hebben.

Dit is, bijvoorbeeld, de relatie tussen een Course en zijn CourseMaterial. Laten we eerst CourseMaterial in kaart brengen, wat we nog niet hebben gedaan:

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

De annotatie voor het in kaart brengen van een enkele entiteit naar een enkele andere entiteit is, niet schokkend, @OneToOne.

Voordat we dit in ons model opzetten, moeten we onthouden dat een relatie een bezittende kant heeft – bij voorkeur de kant die de foreign key in de database zal bevatten.

In ons voorbeeld zou dat CourseMaterial zijn, omdat het logisch is dat het verwijst naar een Course (hoewel we het ook andersom zouden kunnen doen):

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

Het heeft geen zin materiaal te hebben zonder een cursus om het te omvatten. Daarom is de relatie niet optional in die richting.

Over richting gesproken, laten we de relatie tweerichtingsverkeer maken, zodat we toegang hebben tot het materiaal van een cursus als die er een heeft. Laten we in de Course klasse toevoegen:

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

Hiermee vertellen we Hibernate dat het materiaal binnen een Course al in kaart is gebracht door het course veld van de CourseMaterial entiteit.

Ook is er geen optional attribuut hier, omdat het standaard true is, en we kunnen ons een cursus zonder materiaal voorstellen (van een erg luie leraar).

Naast het bidirectioneel maken van de relatie, zouden we ook cascade operaties kunnen toevoegen of entiteiten eagerly of lazily kunnen laten laden.

Many-to-Many

Nu, last but not least: Many-to-Many relaties. Deze hebben we tot het laatst bewaard, omdat ze wat meer werk vergen dan de vorige.

In feite houdt een Many-to-Many relatie in een database in dat een middelste tabel naar beide andere tabellen verwijst.

Gelukkig voor ons doet JPA het meeste werk, we hoeven er alleen maar een paar annotaties tegenaan te gooien, en het regelt de rest voor ons.

In ons voorbeeld is de veel-op-veel-relatie dus die tussen Student en Course instanties, omdat een student meerdere cursussen kan volgen, en een cursus door meerdere studenten kan worden gevolgd.

Om een veel-op-veel-relatie in kaart te brengen, gebruiken we de @ManyToMany annotatie. Deze keer gebruiken we echter ook een @JoinTable annotatie om de tabel op te zetten die de relatie weergeeft:

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

Nu doornemen wat er hier gebeurt. De annotatie heeft een paar parameters nodig. Allereerst moeten we de tabel een naam geven. We hebben gekozen voor STUDENTS_COURSES.

Daarna moeten we Hibernate vertellen welke kolommen moeten worden samengevoegd om STUDENTS_COURSES te vullen. De eerste parameter, joinColumns definieert hoe de join kolom (foreign key) van de eigenaar van de relatie in de tabel moet worden geconfigureerd. In dit geval is de eigen zijde een Course.

Aan de andere kant doet de inverseJoinColumns parameter hetzelfde, maar dan voor de verwijzende zijde (Student).

Laten we een dataset opzetten met studenten en cursussen:

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

Natuurlijk werkt dit niet zo uit de doos. We zullen een methode moeten toevoegen die ons toelaat studenten toe te voegen aan een cursus. Laten we de Course klasse een beetje aanpassen:

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

Nu kunnen we onze dataset compleet maken:

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

Als deze code eenmaal loopt, zullen onze Course, Teacher en Student instanties en hun relaties bewaard blijven. Laten we bijvoorbeeld een student ophalen uit een cursus en controleren of alles in orde is:

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

Natuurlijk kunnen we nog steeds de relatie als bidirectioneel toewijzen op dezelfde manier als we deden voor de vorige relaties.

We kunnen ook cascade operaties uitvoeren en definiëren of entiteiten lazy of eagerly moeten worden geladen (Many-to-Many relaties zijn standaard lazy).

Conclusie

Dat is het einde van dit artikel over relaties van gemapte entiteiten met JPA. We hebben Many-to-One, One-to-Many, veel-op-veel en One-to-One relaties behandeld. Daarnaast hebben we gekeken naar cascade operaties, bidirectionaliteit, optionaliteit en eager/lazy loading fetch-types.

De code voor deze serie is te vinden op GitHub.

Geef een antwoord

Het e-mailadres wordt niet gepubliceerd.