JPA one-to-many with Spring-Boot

JPA one-to-many with Spring-Boot

Today Let's Talk about JPA/Hibernate One to Many Mapping. We will

  • Create a Project Using Spring Boot CLI
  • Dig deep into One to Many Mapping
  • Create Rest api to use our One to Many Relation

Consider the following two tables - authors and books of a database schema, where the authors table has a one-to-many relationship with the books table -

ER-Diagram-One-to-Many

Project Creation

Spring Boot CLI

Let's crate a project with spring-boot-cli. If you are not familiar with spring-cli, it's pretty handy tool to have. You can install it from this link . creating-project-with-spring-boot-cli

You can type the following command in your terminal.

spring init --dependencies=lombok,data-jpa,mysql --package=com.techyowls one-to-many

Spring Initializr

Alternatively, You can generate the project from Spring Initializr web tool by following the instructions below -

  1. Go to http://start.spring.io
  2. click Switch to full version link to see all the options
  3. Enter Artifact as “jpa-one-to-many”
  4. Change Package Name to “com.techyowls” / or anything you like
  5. Select Web, JPA and Mysql dependencies.
  6. Click Generate Project to download the project.

Our generate Folder Structure will be like following screenshot. folder-structure To achieve that let's create package name entity, exception, repository, resource inside of your /src/main/java

One to Many Relationship can be Unidirectional and Bidirectional. And it may be used based on the business requirement.

Database Configuration:

We will use a docker-compose file which will make available mysql database to which our application will connect.

If you want to know more about the docker-compose I have explained in great detail in another post Mysql Docker Compose our mysql.yaml compose file looks like this.

version: "3.7"
services:
  freeway:
    image: mysql:5.7.25
    container_name: mysql
    hostname: mysql
    networks:
      - default
    volumes:
      - techyowls/:/var/lib/mysql
    environment:
      - MYSQL_USER=techyowls
      - MYSQL_PASSWORD=techyowls
      - MYSQL_ROOT_PASSWORD=techyowls
      - MYSQL_DATABASE=one_to_many
    ports:
      - "3306:3306"
    command: mysqld --lower_case_table_names=1 --skip-ssl --character_set_server=utf8mb4 --explicit_defaults_for_timestamp
    restart: always

volumes:
  techyowls:

and our application.properties file looks like bellow

# DATASOURCE (DataSourceAutoConfiguration & DataSourceProperties)
spring.datasource.url=jdbc:mysql://localhost:3306/one_to_many?useSSL=false&serverTimezone=UTC&useLegacyDatetimeCode=false
spring.datasource.username=root
spring.datasource.password=root

# Hibernate

# The SQL dialect makes Hibernate generate better SQL for the chosen database
spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.MySQL5InnoDBDialect

# spring.jpa.hibernate.naming.implicit-strategy=org.hibernate.boot.model.naming.ImplicitNamingStrategyJpaCompliantImpl
# Hibernate ddl auto (create, create-drop, validate, update)
spring.jpa.hibernate.ddl-auto = update

logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type=TRACE

#show the query in console
spring.jpa.show-sql = true

Ways to map one to many relationship in hibernate:

  1. Hibernate is an old friend of mine and I have found it from experience that the best way to map one to many relationship in hibernate is using @ManyToOne annotation on child entity.

  2. We can also achieve bi-directional one to many relationship as well and to that we will have @OneToMany annotation on parent side and @ManyToOne annotation on children side.

  3. Also, we can achieve Unidirectional @OneToMany with @JoinColumn

@ManyToOne on child entity

We have used lombok to make remove the verbosity of java. If you are not familiar with lombok. Then take a look at my previous post In previous post Our Development Good Friend Lombok

Let's create Our Author and Book entity class inside our entity package.

Our Author and Book Class looks like this.

Author Model

package com.techyowls.entity;

import lombok.*;

import javax.persistence.*;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Getter
@Setter
@Entity
@Table(name = "authors")
public class Author extends BaseEntity {

    @Column(name = "name", nullable = false)
    private String name;

    @Column(name = "age", nullable = false)
    private Integer age;

    @Column(name = "birth_date", nullable = false)
    private LocalDate birthDate;
   
}

Book Model

package com.techyowls.entity;

import lombok.*;

import javax.persistence.*;
import java.time.LocalDate;

@Entity
@Table(name = "books")
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Book extends BaseEntity {
    @Column(name = "title", nullable = false)
    private String title;

    @Column(name = "isbn", nullable = false)
    private String isbn;

    @Column(name = "page_count", nullable = false)
    private Integer pageCount;

    @Column(name = "published_date", nullable = false)
    private LocalDate publishedDate;

    @ManyToOne(
            fetch = FetchType.LAZY,
            optional = false
    )
    @JoinColumn(
            name = "author_id",
            nullable = false,
            foreignKey = @ForeignKey(
                    name = "fk_books_authors_id"
            )
    )
    private Book book;
}

We have only mapped the child class with @ManyToOne annotation for this scenario and we made optional=false meaning is that author field is required.

@JoinColumn name="author_id creates a column named author_id in our books table and we have also explicitly named our foreignKey relationship name as well.

Defining The Repositories

We will define repositories for accessing data from database. Let's create interfaces inside our repositories package.

Author Repository

package com.techyowls.repository;

import com.techyowls.entity.Author;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {
}

Book Repository

package com.techyowls.repository;

import com.techyowls.entity.Book;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
    Page<Book> findByAuthorId(Long authorId, Pageable pageable);

    Optional<Book> findByIdAndAuthorId(Long id, Long authorId);
}

Rest Resource To Perform CRUD operations

AuthorResource (API for GET/POST/PUT/DELETE)

package com.techyowls.resource;

import com.techyowls.entity.Author;
import com.techyowls.exception.ResourceNotFoundException;
import com.techyowls.repository.AuthorRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import java.util.Optional;

import static com.techyowls.resource.ResponseUtil.resourceUri;

@RestController
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class AuthorResource {

    private final AuthorRepository authorRepository;

    @GetMapping("/authors")
    public Page<Author> getAllAuthors(Pageable pageable) {
        return authorRepository.findAll(pageable);
    }

    @PostMapping("/authors")
    public ResponseEntity<Author> saveAuthor(
            @Valid @RequestBody Author request
    ) {
        return Optional.of(request)
                .map(authorRepository::save)
                .map(
                        author -> ResponseEntity
                                .created(resourceUri(author.getId()))
                                .body(author)
                )
                .orElseThrow(IllegalArgumentException::new);
    }

    @PutMapping("/authors/{authorId}")
    public ResponseEntity<Author> updateAuthor(
            @PathVariable final Long authorId,
            @Valid @RequestBody Author request
    ) {
        return authorRepository.findById(authorId)
                .map(
                        author -> {
                            author.setAge(request.getAge());
                            author.setBirthDate(request.getBirthDate());
                            author.setName(request.getName());
                            return author;
                        }
                )
                .map(authorRepository::save)
                .map(author -> ResponseEntity
                        .ok()
                        .location(resourceUri(authorId))
                        .body(author))
                .orElseThrow(
                        () -> new ResourceNotFoundException(
                                "AuthorID " + authorId + " not found"
                        )
                );
    }

    @DeleteMapping("/authors/{authorId}")
    public ResponseEntity<?> deleteAuthor(
            @PathVariable Long authorId
    ) {
        return authorRepository.findById(authorId)
                .map(author -> {
                    authorRepository.delete(author);
                    return ResponseEntity
                            .ok()
                            .build();
                })
                .orElseThrow(() -> new ResourceNotFoundException(
                                "AuthorID " + authorId + " not found"
                        )
                );
    }
}

Our helper friend ResponseUtil looks like

package com.techyowls.resource;

import java.net.URI;
import java.util.Objects;
import java.util.Optional;

import static org.springframework.web.servlet.support.ServletUriComponentsBuilder.fromCurrentRequest;

public class ResponseUtil {
    static <T> URI resourceUri(T resourceId) {
        Objects.requireNonNull(resourceId);
        return Optional.of(resourceId)
                .map(id -> fromCurrentRequest().path("/{id}")
                        .buildAndExpand(id).toUri())
                .orElseThrow(IllegalArgumentException::new);
    }
}

BookResource (API for GET/POST/PUT/DELETE)

package com.techyowls.resource;

import com.techyowls.entity.Author;
import com.techyowls.entity.Book;
import com.techyowls.exception.ResourceNotFoundException;
import com.techyowls.repository.AuthorRepository;
import com.techyowls.repository.BookRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

import static com.techyowls.resource.ResponseUtil.resourceUri;

@RestController
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class BookResource {
    private final AuthorRepository authorRepository;
    private final BookRepository bookRepository;


    @GetMapping("/authors/{authorId}/books")
    public Page<Book> getAllBooksByAuthorId(
            @PathVariable(value = "authorId") Long authorId,
            Pageable pageable
    ) {
        return bookRepository.findByAuthorId(authorId, pageable);
    }

    @PostMapping("/authors/{authorId}/books")
    public ResponseEntity<Book> createBook(
            @PathVariable(value = "authorId") Long authorId,
            @Valid @RequestBody Book bookRequest
    ) {
        return authorRepository.findById(authorId)
                .map(author -> {
                    bookRequest.setAuthor(author);
                    return bookRepository.save(bookRequest);
                })
                .map(
                        book -> ResponseEntity.created(resourceUri(book.getId()))
                                .body(book)
                ).orElseThrow(
                        () -> new ResourceNotFoundException(
                                "AuthorId " + authorId + " not found"
                        )
                );
    }

    @PutMapping("/authors/{authorId}/books/{bookId}")
    public ResponseEntity<Book> updateBook(
            @PathVariable(value = "authorId") Long authorId,
            @PathVariable(value = "bookId") Long bookId,
            @Valid @RequestBody Book bookRequest
    ) {

        Author author = authorRepository
                .findById(authorId)
                .orElseThrow(() -> new ResourceNotFoundException(
                        "authorId " + authorId + " not found")
                );

        return bookRepository.findById(bookId)
                .map(book -> {
                    book.setIsbn(bookRequest.getIsbn());
                    book.setPageCount(bookRequest.getPageCount());
                    book.setPublishedDate(bookRequest.getPublishedDate());
                    book.setTitle(bookRequest.getTitle());
                    book.setAuthor(author);
                    return bookRepository.save(book);
                })
                .map(book -> ResponseEntity
                        .ok().location(
                                resourceUri(book.getId()
                                )
                        )
                        .body(book)
                )
                .orElseThrow(() -> new ResourceNotFoundException("BookId " + bookId + "not found"));
    }

    @DeleteMapping("/authors/{authorId}/books/{bookId}")
    public ResponseEntity<?> deleteBook(
            @PathVariable(value = "authorId") Long authorId,
            @PathVariable(value = "bookId") Long bookId
    ) {
        return bookRepository.findByIdAndAuthorId(bookId, authorId)
                .map(book -> {
                            bookRepository.delete(book);
                            return ResponseEntity.ok().build();
                        }
                ).orElseThrow(
                        () -> new ResourceNotFoundException(
                                "Book not found with id "
                                        + bookId +
                                        " and authorId "
                                        + authorId
                        )
                );
    }
}

ResourceNotFoundException class

Both the Author and Book Rest Resource throws ResourceNotFoundException when an author or book could not be found. Following is the definition of the ResourceNotFoundException.

package com.techyowls.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException{
    private static final long serialVersionUID = -5189726974741362117L;
    public ResourceNotFoundException() {
        super();
    }

    public ResourceNotFoundException(String message) {
        super(message);
    }

    public ResourceNotFoundException(String message, Throwable cause) {
        super(message, cause);
    }

}

The @ResponseStatus(HttpStatus.NOT_FOUND) annotation above exception class is there to tell Spring Boot to respond with a 404 status when this exception is thrown.

Running the Application

To run our project:
mvn spring-boot:run

Testing Our API's Via Postman

Let’s now test the APIs via Postman.

Create Author POST /authors

Spring Boot Hibernate Jpa One to Many Mapping with Pagination and Rest API

Get paginated Authors GET /authors?page=0&size=2&sort=createdAt,desc

Spring Boot Hibernate Jpa One to Many Mapping with Pagination and Sorting Rest API

Create Book POST /authors/{authorId}/book

Spring Boot Hibernate Jpa One to Many Mapping with Pagination and Sorting Rest API create Book

{
  "title": "Harry Potter and the Sorcerer's Stone",
  "isbn":"0545790352",
  "pageCount": 256,
  "publishedDate": "2015-10-06"

}

Get paginated books GET /authors/{authorId}/books?page=0&size=3&sort=id,desc

Spring Boot Hibernate Jpa One to Many Mapping with Pagination and Sorting Rest API get books

You get the idea!!

Bi-directional one to many mapping

Let's see how our entities will look like if we want to map our Book and Author as bi-directional

Author Entity

package com.techyowls.entity;

import lombok.*;

import javax.persistence.*;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Getter
@Setter
@Entity
@Table(name = "authors")
public class Author extends BaseEntity {

    @Column(name = "name", nullable = false)
    private String name;

    @Column(name = "age", nullable = false)
    private Integer age;

    @Column(name = "birth_date", nullable = false)
    private LocalDate birthDate;

    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true,
            mappedBy = "author"
    )
    @Builder.Default
    private List<Book> books = new ArrayList<>();
}

Book Entity

package com.techyowls.entity;

import lombok.*;

import javax.persistence.*;
import java.time.LocalDate;

@Entity
@Table(name = "books")
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Book extends BaseEntity {
    @Column(name = "title", nullable = false)
    private String title;

    @Column(name = "isbn", nullable = false)
    private String isbn;

    @Column(name = "page_count", nullable = false)
    private Integer pageCount;

    @Column(name = "published_date", nullable = false)
    private LocalDate publishedDate;

    @ManyToOne(
            fetch = FetchType.LAZY,
            optional = false
    )
    @JoinColumn(
            name = "author_id",
            nullable = false,
            foreignKey = @ForeignKey(
                    name = "fk_books_authors_id"
            )
    )
    private Author author;
}

In a bi-directional one-to-many association

  • A collection of child entities are kept and tracked by parent entity.
  • Enables us to persist and retrieve the child entities via the parent
  • cascade = CascadeType.ALLtells hibernate to handle the persist operation of child entities..

For example, here is how we can persist books via authors entity in the bidirectional mapping -

   
        Author author = Author.builder()
            .age(56)
            .birthDate(LocalDate.now().minusYears(56))
            .name("J. K. Rowling")
            .build();
        
        Book book1 = Book.builder()
            .author(author)
            .title("Harry Potter and the Sorcerer's Stone")
            .isbn("0545790352")
            .pageCount(256)
            .publishedDate(LocalDate.now().minusYears(10))
            .build();
        
        Book book2 = Book.builder()
            .author(author)
            .title("Harry Potter and the Sorcerer's Stone Part 2")
            .isbn("0566695585")
            .pageCount(300)
            .publishedDate(LocalDate.now().minusYears(12))
            .build();
        author.getBooks().add(book1);
        author.getBooks().add(book2);
        
        Author save = authorRepository.save(author);

	

Hibernate automatically issues insert statements and saves the comments added to the post.

Problems with bidirectional one-to-many mapping

  • A bidirectional mapping tightly couples the many-side of the relationship to the one-side.
  • If we load books via the author entity, we won't be able to limit the number of books loaded and which leads to not being able to paginate.

When to use bi-directional one to many relationship?

  • When we know the number of child entities are limited then then bi-directional one to many mapping is best fit.

  • For example, A mcq Question which can have max 4/5 options and it's predictable and for this use case we want this kind of tight coupling.

Unidirectional @One to many with @JoinColumn

    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(
            name = "author_id",
            nullable = false,
            foreignKey = @ForeignKey(
                    name = "fk_books_authors_id"
            )
    )
    private List<Book> books = new ArrayList<>();

In this kind of mapping parent entity will hold list of child entities and in child entity there will be no mapping related information.

@JoinColumn annotation will create a column author_id column in books table and as CascadeType.ALL is used so parent entity will take the responsibility to persist the child book entities.

Conclusion

That’s all for now! In this post, we have learned how to map a one-to-many database relationship using JPA and Hibernate.

You can find the code for the sample project that we built in this article in my jpa-one-to-many-demo repository on github.

More Resources:

You may wanna check out the following articles by Vlad Mihalcea to learn more about Hibernate and it’s performance -

Previous
Next
Avatar
Moshiour Rahman
Software Architect

My interests include enterprise software development, robotics, mobile computing and programmable matter.

comments powered by Disqus
Previous
Next

Related