What You'll Learn

So far, we've seen how JPA can configure object relationships that map to a relational database. We've limited this to one-way, one-to-many relationships.

There are lots of other types of relationships and we'll explore those in this tutorial.

We'll also introduce a LocalDate object (or two) and look at how to map those into the database.

Problem domain extension

In this extension, we are going to introduce the idea of fundraising events. These are events that are typically organised by an individual charity, but in our design, we're going to allow events to support multiple charities. If an event supports more than one charity, the proceeds are split equally amongst the charities. Each event is organised by an organiser (who can organise more than one event).

Therefore, we have a new one to many relationship between organiser and events and a new many to many relationship between events and charities.

In a relational database, a many to many relationship is modelled using a link table. In objects, we DON'T need a link entity. Rather, we model as the charity having a list of events, and an event having a list of charities.

In domain/FundraisingOrganiser.java

@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity
public class FundraisingOrganiser {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    @ToString.Exclude
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "theOrganiser")
    private List<FundraisingEvent> fundraisingEvents;
}

This is a minimal class. The organiser just has an id and a name. There are some things to notice on the annotations.

We introduce @ToString.Exclude which is a Lombok annotation that tells Lombok to leave this field out of the toString() method. Why do we need to do this (HINT: we are going to implement a bidirectional relationship). If you can't work it out, try removing the annotation and running the tests that we'll create later.

The @OneToMany annotation looks similar to before, but now it has the mappedBy attribute. This tells JPA that the relationship is mapped on the other side (from event to organiser) so JPA can pick up the foreigh key configuration from there.

Create a fundraising event class

In domain/FundraisingEvent.java

@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity
public class FundraisingEvent {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    private String description;
    private Long targetAmount;
    private LocalDate startDate;
    private LocalDate endDate;
    private String furl;

    @ManyToOne
    @JoinColumn(name="fundraising_organiser_id")
    private FundraisingOrganiser theOrganiser;

    @ToString.Exclude
    @ManyToMany
    @JoinTable(
            name = "fundraising_event_benefits",
            joinColumns = @JoinColumn(name = "fundraising_event_id"),
            inverseJoinColumns = @JoinColumn(name = "charity_id")
    )
    private List<Charity> benefittingCharities;
}

This has a longer set of fields, including some LocalDate fields (more on those later). Let's focus on the relationships.

The many-to-one relationship

In the organiser entity, we had a one-to-many relationship defined to the events. In the event entity, we have the reverse - a many-to-one relationship to the organiser. We use @JoinColumn to define the foreign key. The field is called theOrganiser and this is the value we use in the mappedBy attribute.

The many-to-many relationship

To map the many-to-many relationship, we need to tell the entities how to use a link table.

Before we examine the entity annotation, let's have a look at the database schema.

CREATE TABLE IF NOT EXISTS `fundraising_event`
(
    `id` INT UNSIGNED  NOT NULL AUTO_INCREMENT,
    `title` VARCHAR(100) NOT NULL,
    `description` VARCHAR(1500) NOT NULL,
    `target_amount` INT UNSIGNED NOT NULL,
    `start_date` DATE NOT NULL,
    `end_date` DATE NOT NULL,
    `furl` VARCHAR(100) NOT NULL,
    `fundraising_organiser_id` INT UNSIGNED NOT NULL,
    PRIMARY KEY(`id`)
    )
    ENGINE = InnoDB;

CREATE TABLE IF NOT EXISTS `fundraising_organiser`
(
    `id` INT UNSIGNED  NOT NULL AUTO_INCREMENT,
    `name` VARCHAR(100) NOT NULL,
    PRIMARY KEY(`id`)
    )
    ENGINE = InnoDB;

CREATE TABLE IF NOT EXISTS `fundraising_event_benefits`
(
    `id` INT UNSIGNED  NOT NULL AUTO_INCREMENT,
    `charity_id` INT NOT NULL,
    `fundraising_event_id` INT NOT NULL,
    PRIMARY KEY(`id`)
    )
    ENGINE = InnoDB;

The DDL creates a link table called fundraising_event_benefits with three fields; an auto-incremented id, a foreign key to the charity table and a foreign key to the event table.

In the event entity, we will annotate a list of charities with @ManyToMany.

We also need to define the joins, and we do this with the @JoinTable annotation. Firstly, we give it the name of the join table. Then, we give it the names of the columns to join the id fields on both sides with. Since we are in the event table, the JoinColumns attribute is set to link from the event id to the foreign key of the event in the link table.

Similarly, the charity is the inverse relationship, so the inverseJoinColumn attribute is used to link the charity id to the link table.

We have chosen to use the LocalDate class for our dates. This class was added with Java 8 and brings a number of improvements over the previous Date class. However, by default, JDBC (which is still being used under the covers) will map database dates to java.sql.Date and therefore, we need to map those to LocalDate.

Borrowing straight from Baeldung

In jpa/converters/LocalDateConverter.java

@Converter(autoApply = true)
public class LocalDateConverter implements AttributeConverter<LocalDate, Date> {

    @Override
    public Date convertToDatabaseColumn(LocalDate localDate) {
        return Optional.ofNullable(localDate)
                .map(Date::valueOf)
                .orElse(null);
    }

    @Override
    public LocalDate convertToEntityAttribute(Date date) {
        return Optional.ofNullable(date)
                .map(Date::toLocalDate)
                .orElse(null);
    }
}

As we've done before, we define methods to convert in each direction. What's different in this converter is that autoApply is set to true so it will be applied to any field pair matching the required types.

In previous tutorials, we used @EntityGraph to define the entities that would be loaded when we retrieve a root entity.

It makes sense that, when getting all charities, we won't want to get the events, but that when we get a specific charity, we will.

In CharityRepositoryJPA.java

   @EntityGraph(attributePaths = {"trustees"})
    List<Charity> findAll();

    @EntityGraph(attributePaths = {"trustees","fundraisingEvents"})
    Optional<Charity> findByAcronym(String acronym);

Let's test the new relationship.

Let's add some test data.

In data-test-h2.sql

insert into `fundraising_event`
(`id`, `title`, `description`, `target_amount`,
 `start_date`, `end_date`, `furl`,
 `fundraising_organiser_id`)
values
    (1, 'Race for Life', 'Running to beat cancer', 100,
     parsedatetime('2022-04-01','yyyy-MM-dd' ),
     parsedatetime('2022-08-01','yyyy-MM-dd' ),
     'race-for-life-2022',
     1 );

insert into `fundraising_organiser`
(`id`,`name`)
values(1, 'Bob Geldof');

insert into `fundraising_event_benefits`
(`id`, `charity_id`, `fundraising_event_id`)
values
    (1, 2, 1);

insert into `fundraising_event_benefits`
(`id`, `charity_id`, `fundraising_event_id`)
values
    (2, 3, 1);

Remember to update other files and check the truncate table commands! (Check the commit if you're not sure)

Now, we can make assertions about the events that should be linked to our charities.

In CharityDataJPATests.java

   @Test
    public void shouldGetOneEvent() throws Exception {

        Charity cruk = charityRepository.findByAcronym("cruk").get();

        assertEquals(1, cruk.getFundraisingEvents().size());
        assertEquals("Race for Life", cruk.getFundraisingEvents().get(0).getTitle());
        assertEquals(LocalDate.of(2022, 04, 01), cruk.getFundraisingEvents().get(0).getStartDate());

        Charity crw = charityRepository.findByAcronym("crw").get();

        assertEquals(1, crw.getFundraisingEvents().size());
        assertEquals("Race for Life", crw.getFundraisingEvents().get(0).getTitle());
        assertEquals(LocalDate.of(2022, 04, 01), crw.getFundraisingEvents().get(0).getStartDate());

        FundraisingEvent rfl = crw.getFundraisingEvents().get(0);
        assertEquals("Bob Geldof", rfl.getTheOrganiser().getName());
        assertEquals(2, rfl.getBenefittingCharities().size());

    }

In this test, we get a charity by acronym. We can then assert that it should be linked to one event and can check some details on the event (such as name and date). By checking the date, we are implicitly checking the converter as well.

We then repeat the test with another acronym and get the same event (we could change the test so that we get the events into separate variable and then check for equality).

Let's create a repository for the events to check that the relationship works the other way.

In data/jpa/repositories/FundraiserEventRepositoryJPA.java

public interface FundraisingEventRepositoryJPA extends JpaRepository<FundraisingEvent, Long> {

    List<FundraisingEvent> findAll();
    Optional<FundraisingEvent> findByFurl(String furl);

    @Query("select e from FundraisingEvent e where :date between e.startDate and e.endDate")
    List<FundraisingEvent> findAllRunningAt(@Param("date") LocalDate date);
}

Note: that there are no entity graphs on these methods.

Note: the @Query query has a named parameter :date. To provide a name, we use the @Param annotation on the parameter.

In data/jpa/FundraiserEventJPATests.java

@DataJpaTest
@Sql(scripts={"/schema-test-h2.sql","/data-test-h2.sql"})
@DirtiesContext
public class FundraiserEventJPATests {

    @Autowired
    FundraisingEventRepositoryJPA eventRepository;

    @Test
    void shouldGetOneEvent() throws Exception {
        List<FundraisingEvent> events = eventRepository.findAll();
        assertEquals(1,events.size());
    }

    @Test
    void shouldGetOneEventMatchingFurl() throws Exception {
        Optional<FundraisingEvent> event = eventRepository.findByFurl("race-for-life-2022");
        assertTrue(event.isPresent());
        assertEquals(2, event.get().getBenefittingCharities().size());
    }

    @Test
    void shouldGetOneEventInDateRange() throws Exception {
        List<FundraisingEvent> events = eventRepository.findAllRunningAt(LocalDate.of(2022, 6, 1));
        assertEquals(1,events.size());
    }
}

We search for all, then for one, then by date. In the 2nd case, we are able to get the number of benefiting charities - even though we didn't define the graph.

How does this work? Well according to this article ‘toOne' relationships are loaded eagerly, whilst ‘toMany' relationships are loaded lazily.

But, it's a ‘toMany' relationship, so that doesn't explain how we get the charities from the event, when the entity graph isn't stated.

Again, it is due to where the session starts and ends. The @DataJpaTest annotation applies the @Transactional annotation as well, which means that the JPA (Hibernate) session stays open and the proxies will fire their SQL to get the other data.

If this was a @SpringBootTest and there were methods to get the data on a service, then subsequent calls would fail unless the service was made transactional or the entity graph was explicitly set.

Why does Spring do this?

Let's remember what frameworks do; they provide common solutions to common problems. Spring is a framework that has proven popular in enterprise (e.g. large system) situations and it has been influenced by its use in enterprise situations. Big systems process lots of data. Queries can return 100s, 1000s or even more rows. Those can become objects at the root of graphs of connected objects which are linked to other objects. Enterprise systems also serve 100s, 1000s or more concurrent users each running these queries.

Spring can make you very productive by defaulting a lot of options, but it doesn't take away the need to think and design these systems carefully. Naive use of Spring (indeed, any framework) can lead to unfortunate consequences (The author has the mental scars).

In this tutorial, we've introduced the way JPA supports many to many relationships. It has reminded us of it is still the developers responsible to think and design the interfaces of their components very carefully.