Many-To-Many with Extra Columns
Estimated Lesson Duration: 90mins
Learning Objectives
- Know how to create a model to handle a join table with extra columns
- Understand how to break down a many-to-many relationship into two one-to-manys
- Know what JPA annotations to use in this scenario
Introduction
Currently, we have a many-to-many relationship between Chocolate
and Estate
which is handled in the database via our JPA annotations and Hibernate.
We do not explicitly create a model ChocolateEstate
to manage the relationship, it is done for us. If we run our server and open Postico we can see this for ourselves (i.e. that we have a table called chocolates_estates
).
Our class diagram and ERD would look like this:
classDiagram
Chocolate -- Estate_Chocolate
Estate -- Estate_Chocolate
class Chocolate{
- id : Long
- name : String
- cocoaPercentage : int
@ManytoMany
- estates : List < Estate >
+ getName()
+ setName() // ...etc
}
class Estate_Chocolate{
- chocolate : Chocolate
- estate : Estate
}
class Estate{
- id: Long
- name : String
- country : String
@ManyToMany
- chocolates : List < Chocolate >
+ getName()
+ setName() // ...etc
}
erDiagram
chocolates ||--o{ estates_chocolates : ""
estates ||--o{ estates_chocolates : ""
chocolates{
INT id PK
VARCHAR name
INT cocoa_percentage
}
estates_chocolates{
INT chocolate_id FK
INT estate_id FK
}
estates{
INT id PK
VARCHAR name
VARCHAR country
}
This is fine ... but what if we want our join table to include more than estate_id
and chocolate_id
? Perhaps there are other properties relevant to that relationship that we would like to keep track of.
Adding Another Model
If we wish to include extra columns in the join table, we need to make another model in our Spring application.
Let's suppose we would like a CocoaOrder
class that:
- continues to match a chocolate to an estate
- also includes some extra information such as batchNumber
and pricePaid
.
Although this is still a many-to-many relationship, we should break this down into two one-to-many
s.
Over the course of this lesson, we will modify our code to follow these digrams:
classDiagram
Chocolate -- CocoaOrder
Estate -- CocoaOrder
class Chocolate {
- id : Long
- name : String
- cocoaPercentage : int
- cocoaOrders : List < CocoaOrder > // @OneToMany
}
class CocoaOrder {
- id : Long
- chocolate : Chocolate // @ManyToOne
- estate : Estate // @ManyToOne
- batchNumber : String
- pricePaid : double
}
class Estate {
- id : Long
- name : String
- country : String
- cocoaOrders : List < CocoaOrder > // @OneToMany
}
This should result in a database this structure:
erDiagram
chocolates ||--o{ cocoa_orders : ""
estates ||--o{ cocoa_orders : ""
chocolates{
INT id PK
VARCHAR name
INT cocoa_percentage
}
cocoa_orders{
INT id PK
INT chocolate_id FK
INT estate_id FK
VARCHAR batch_number
NUMERIC price_paid
}
estates{
INT id PK
VARCHAR name
VARCHAR country
}
CocoaOrder
open your start code and check your server runs (
ChocolateApplication
>main()
)
We can begin by creating a new POJO class in our models
package. Right click on models
and create a java class called CocoaOrder
, with properties of id
, chocolate
, estate
, batchNumber
and pricePaid
.
// models/CocoaOrder.java
public class CocoaOrder {
private Long id;
private Chocolate chocolate;
private Estate estate;
private String batchNumber;
private double pricePaid;
public CocoaOrder(Chocolate chocolate, Estate estate, String batchNumber, double pricePaid){
this.chocolate = chocolate;
this.estate = estate;
this.batchNumber = batchNumber;
this.pricePaid = pricePaid;
}
public CocoaOrder(){
}
// GETTERS & SETTERS
//...
}
Annotate your class correctly. As we now know, CocoaOrder
will need to be used to create a corresponding cocoa_orers
table in the database. Note that CocoaOrder
is the many-to-one side of the relationship.
Many CocoaOrders can have one chocolate.
A Chocolate
could appear on multiple orders. This makes sense as a single chocolate's cocoa could come from several different estates.
Many CocoaOrders can have one estate.
An Estate
could appear on multiple orders. This means that an Estate
could contribute cocoa to many different chocolates.
// models/CocoaOrder.java
@Entity (name = "cocoa_orders") // ADDED
public class CocoaOrder {
@Id // ADDED
@GeneratedValue(strategy = GenerationType.IDENTITY) // ADDED
private Long id;
@JsonIgnoreProperties({ "cocoaOrders"}) // ADDED
@ManyToOne // Many orders can have one chocolate // ADDED
@JoinColumn(name = "chocolate_id") // ADDED
private Chocolate chocolate;
@JsonIgnoreProperties({ "cocoaOrders"}) // ADDED
@ManyToOne // Many orders can have one estate // ADDED
@JoinColumn(name = "estate_id") // ADDED
private Estate estate;
@Column(name = "batch_number" // ADDED
private String batchNumber;
@Column(name = "price_paid") // ADDED
private double pricePaid;
// ...
}
Note the name
parameter is added to conform to SQL conventions for naming columns.
As we will be persiting CocoaOrder
objects to the database, we can also expect that the CocoaOrder
class will require a repository, a service and a controller.
// repositories/CocoaOrderRepository
@Repository
public interface CocoaOrderRepository extends JpaRepository<CocoaOrder, Long> {
}
// services/CocoaOrderService
@Service
public class CocoaOrderService(){
@Autowired
CocoaOrderRepository cocoaOrderRepository;
}
// controllers/CocoaOrderController
@RestController
@RequestMapping("cocoa-orders")
public class CocoaOrderController {
@Autowired
CocoaOrderService cocoaOrderService;
}
Modifying the Chocolate class
We can now consider how to modify Chocolate
so that it works with our new set up. Let's change our estates
property to cocoaOrders
, and replace the @ManyToMany
annotation with a @OneToMany
.
Modify your constructor and remove getters and setters for the estates
property.
// models/Chocolate.java
@Entity (name = "chocolates")
public class Chocolate {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column
private String name;
@Column(name = "cocoa_percentage")
private int cocoaPercentage;
@OneToMany (mappedBy = "chocolate") // MODIFIED
private List<CocoaOrder> cocoaOrders; // MODIFIED
public Chocolate (String name, int cocoaPercentage){
this.name = name;
this.cocoaPercentage = cocoaPercentage;
this.cocoaOrders = new ArrayList<>(); // MODIFIED
}
// ...
// REMOVED getters and setters for 'estates'
public List<CocoaOrder> getCocoaOrders() { // ADDED
return cocoaOrders;
}
public void setCocoaOrders(List<CocoaOrder> cocoaOrders) { // ADDED
this.cocoaOrders = cocoaOrders;
}
WHile we're here we should also remove the addEstate()
and removeEstate()
methods from this class and replace them with methods to add and remove a cocoaOrder
from a chocolate
.
// models/Chocolate.java
// ...
// REMOVED addEstate() and removeEstate()
public void addCocoaOrder(CocoaOrder cocoaOrder){ // ADDED
this.cocoaOrders.add(cocoaOrder);
}
public void removeCocoaOrder(CocoaOrder cocoaOrder){ //ADDED
this.cocoaOrders.remove(cocoaOrder);
}
// ...
Modifying the Estate class
We can now do the same with the Estate
class. Here we also need to replace chocolates
with cocoaOrders
, change the relationship to @OneToMany
and update our constructor and methods.
// models/Estate.java
@Entity
@Table(name = "estates")
public class Estate {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column
private Long id;
@Column
private String name;
@Column
private String country;
// @JsonIgnoreProperties({"estate"})
@OneToMany(mappedBy = "estate") // MODIFIED
private List<CocoaOrder> cocoaOrders; // MODIFIED
public Estate(String name, String country) {
this.name = name;
this.country = country;
this.cocoaOrders = new ArrayList<CocoaOrder>(); // MODIFIED
}
public Estate() {
}
public void addChocolate(Chocolate chocolate){
this.chocolates.add(chocolate);
}
// REMOVED addChocolate()
// REMOVED getters and setters for 'chocolates'
public void addCocoaOrder(CocoaOrder cocoaOrder){ // ADDED
this.cocoaOrders.add(cocoaOrder);
}
public void removeCocoaOrder(CocoaOrder cocoaOrder){
this.cocoaOrders.remove(cocoaOrder);
}
// ...
Dataloader
It's probably a good idea at this point to amend our dataloader and review our tables in Postico.
Our set up is now different. Instead of creating an Estate
object, a Chocolate
object and adding the estate
to the chocolate
, we now need three types of object.
We should now:
- create an Estate
object
- create a Chocolate
object
- create a CocoaOrder
object that takes in both
Remove the contents of the run()
method in your dataloader and paste in the following:
// components/Dataloader.java
// ...
// RABOT ESTATE
Estate rabotEstate = new Estate("Rabot Estate", "St Lucia");
estateRepository.save(rabotEstate);
Chocolate saltedDark = new Chocolate("Salted Dark", 70);
chocolateRepository.save(saltedDark);
CocoaOrder cocoaOrder1 = new CocoaOrder(saltedDark, rabotEstate, "RE254", 47.50);
cocoaOrderRepository.save(cocoaOrder1);
Chocolate supermilkHazelnut = new Chocolate("Supermilk Hazelnut", 40);
chocolateRepository.save(supermilkHazelnut);
CocoaOrder cocoaOrder2 = new CocoaOrder(supermilkHazelnut, rabotEstate, "RE255", 50.50);
cocoaOrderRepository.save(cocoaOrder2);
// HACIENDA ZOLITA
Estate haciendaZolita = new Estate("Hacienda Zolita", "Ecuador");
estateRepository.save(haciendaZolita);
Chocolate ecuadorDark = new Chocolate("Ecuador Dark", 72);
chocolateRepository.save(ecuadorDark);
CocoaOrder cocoaOrder3 = new CocoaOrder(ecuadorDark, haciendaZolita, "HZ641", 50.10);
cocoaOrderRepository.save(cocoaOrder3);
Chocolate mintyLove = new Chocolate("Minty Love", 72);
chocolateRepository.save(mintyLove);
CocoaOrder cocoaOrder4 = new CocoaOrder(mintyLove, haciendaZolita, "HZ954", 42.00);
cocoaOrderRepository.save(cocoaOrder4);
// KUAPA KOKOO
Estate kuapaKokoo = new Estate("Kuapa Kokoo", "Ghana");
estateRepository.save(kuapaKokoo);
Chocolate pinkSalt = new Chocolate("Dark Pink Himalayan Salt", 60);
chocolateRepository.save(pinkSalt);
CocoaOrder cocoaOrder5 = new CocoaOrder(pinkSalt, kuapaKokoo, "KA274", 55.20);
cocoaOrderRepository.save(cocoaOrder5);
// MIXED ORIGIN CHOCOLATE
Chocolate toffeeTruffle = new Chocolate("Toffee Truffle", 55);
chocolateRepository.save(toffeeTruffle);
CocoaOrder cocoaOrder6 = new CocoaOrder(toffeeTruffle, rabotEstate, "RE274", 47.50);
cocoaOrderRepository.save(cocoaOrder6);
CocoaOrder cocoaOrder7 = new CocoaOrder(toffeeTruffle, haciendaZolita, "HZ204", 39.70);
cocoaOrderRepository.save(cocoaOrder7);
// ...
Run the code and use Postman to review your database tables. We will not yet be able to use all the functions we should, but you will be able to see our new set up in the database.
Data Transfer Objects
While we're here, we can think about whether we might need a DTO or two (or three). DTOs are especially useful if our object contains other objects. This is the case for all our models, so we will likely need a DTO for each of them.
We can start by modifying the DTO we already have, ChocolateDTO
. We should change our ChocolateDTO
to expect a list of CocoaOrder
ids. We will need getters and setter for this new property.
// models/ChocolateDTO.java
public class ChocolateDTO {
private String name;
private int cocoaPercentage;
private List<Long> cocoaOrderIds; // MODIFIED
public ChocolateDTO(String name, int cocoaPercentage, List<Long> cocoaOrderIds){ // MODIFIED
this.name = name;
this.cocoaPercentage = cocoaPercentage;
this.cocoaOrderIds = cocoaOrderIds; // MODIFIED
}
// ...
// REMOVE Getter/Setter for estateIds
public List<Long> getCocoaOrderIds() { // ADDED
return cocoaOrderIds;
}
public void setCocoaOrderIds(List<Long> cocoaOrderIds) { // ADDED
this.cocoaOrderIds = cocoaOrderIds;
}
This means that when we POST or UPDATE a chocolate, we could expect the request body to look something like this:
// POST localhost:8080/chocolates/{id}
{
"name" : "Twix",
"cocoaPercentage" : 35,
}
// PUT localhost:8080/chocolates/{id}
{
"name" : "Twix",
"cocoaPercentage" : 35,
"cocoaOrderIds" : [1, 3]
}
Creating a DTO simplifies matters for us. If we just used the Chocolate
model to process the request, our request body could look like this:
{
"name" : "Twix",
"cocoaPercentage" : 35,
"cocoaOrderIds" : [
{
"id" : 1,
"chocolate" : {
"id" : 5,
"name" : "Twix",
"cocoaPercentage" : 35,
},
"estate" : {
"id" : 2,
"name" : "Hacienda Zolita",
"country" : "Ecuador"
},
"batchNumber" : "HZ344",
"pricePaid" : 45.89
},
{
"id" : 3,
"chocolate" : {
"id" : 5,
"name" : "Twix",
"cocoaPercentage" : 35,
},
"estate" : {
"id" : 2,
"name" : "Rabot Estate",
"country" : "St Lucia"
},
"batchNumber" : "RC671",
"pricePaid" : 45.10
}
]
}
The ChocolateDTO
allows us to simply supply the id(s) of relevant CocoaOrder
s. Working this way also helps prevent mistakes, especially when working with more complicated relationships.
EstateDTO, CocoaOrderDTO
In this spirit, we should create a DTO for Estate
and CocoaOrder
to allow us to post our objects more easily.
Create a new class in models
called EstateDTO
. This class should allow us to process a request body that looks like this:
{
"name" : "Rabot Estate",
"country" : "St Lucia",
"cocoaOrderIds" : [2]
}
// models/EstateDTO
public class EstateDTO {
private String name;
private String country;
private List<Long> cocoaOrderIds;
public EstateDTO(String name, String country, List<Long> cocoaOrderIds) {
this.name = name;
this.country = country;
this.cocoaOrderIds = cocoaOrderIds;
}
// ...include default constructor, getters and setters
Create a new class in models
called CocoaOrderDTO
. This class should allow us to process a request body that looks like this:
{
"chocolateId": 2,
"estateId" : 1,
"batchNumber" : "RC491",
"pricePaid" : 55.00
}
// models/CocoaOrderDTO
public class CocoaOrderDTO {
private Long chocolateId;
private Long estateId;
private String batchNumber;
private double pricePaid;
public CocoaOrderDTO(Long chocolateId, Long estateId, String batchNumber, double pricePaid) {
this.chocolateId = chocolateId;
this.estateId = estateId;
this.batchNumber = batchNumber;
this.pricePaid = pricePaid;
}
// ...include default constructor, getters and setters
Note: DTOs classes do not require annotations as these objects are not persisted to the database.
Returning to CocoaOrderService
Now that we have a CocoaOrderDTO
, we can return to CocoaOrderService
to flesh out its functionality.
As CocoaOrder
is a model, we will (eventually) need this service to perform all the necessary CRUD functionality. For the moment, saveCocoaOrder()
, findCocoaOrder()
, findAllCocoaOrders()
, getCocoaOrder()
and deleteCocoaOrder()
should suffice.
// services/CocoaOrderService
@Service
public class CocoaOrderService(){
@Autowired
CocoaOrderRepository cocoaOrderRepository;
@Autowired
ChocolateService chocolateService;
@Autowired
EstateService estateService;
public List<CocoaOrder> saveCocoaOrder(CocoaOrderDTO cocoaOrderDTO){
// find chocolate
Chocolate chocolate = chocolateService.findChocolate(cocoaOrderDTO.getChocolateId());
// find estate
Estate estate = estateService.findEstate(cocoaOrderDTO.getEstateId());
CocoaOrder cocoaOrder = new CocoaOrder(chocolate, estate, cocoaOrderDTO.getBatchNumber(), cocoaOrderDTO.getPricePaid());
cocoaOrderRepository.save(cocoaOrder);
return cocoaOrderRepository.findAll();
}
public CocoaOrder findCocoaOrder(Long id){
return cocoaOrderRepository.findById(id).get();
}
public List<CocoaOrder> findAllCocoaOrders(){
return cocoaOrderRepository.findAll();
}
public void deleteCocoaOrder(Long id){
cocoaOrderRepository.deleteById(id);
}
}
Knitting our application back together
ChocolateService
We can now amend our code to process our new structure. First on our TODO list is ChocolateService
. In this class we should note:
- Our
ChocolateService
no longer needs access to theestateRepository
as the connection to anEstate
is now handled by our newCocoaOrder
class - To avoid a circular dependency issue, we will inject a
CocoaOrderRepository
object instead of the service - We can tweak our
updateChocolate()
method by replacing the for-loop that searches for the correctestates
with one that looks forcocoaOrders
Note: our saveChocolate()
method can remain the same as 'brand-new' chocolates will not have associated cocoaOrders.
Trainer may wish to inject
CocoaOrderService
in first instance to demonstrate circular dependency
updateChocolate()
// services/ChocolateService
@Service
public class ChocolateService{
@Autowired
ChocolateRepository chocolateRepository;
@Autowired // ADDED
CocoaOrderRepository cocoaOrderRepository; // ADDED
// ...
public Chocolate updateChocolate(ChocolateDTO chocolateDTO, Long id){
Chocolate chocolateToUpdate = chocolateRepository.findById(id).get();
chocolateToUpdate.setName(chocolateDTO.getName());
chocolateToUpdate.setCocoaPercentage(chocolateDTO.getCocoaPercentage());
chocolateToUpdate.setEstates(new ArrayList<Estate>());
for (Long cocoaOrderId : chocolateDTO.getCocoaOrderIds()){ // ADDED
CocoaOrder cocoaOrder = cocoaOrderRepository.findById(cocoaOrderId); // ADDED
chocolateToUpdate.addCocoaOrder(cocoaOrder); // ADDED
}
chocolateRepository.save(chocolateToUpdate);
return chocolateToUpdate;
}
public void saveChocolate(ChocolateDTO chocolateDTO) { // AS BEFORE
Chocolate chocolate = new Chocolate(chocolateDTO.getName(), chocolateDTO.getCocoaPercentage());
chocolateRepository.save(chocolate);
}
// ...
}
deleteChocolate()
We should also consider our deleteChocolate()
method. Now that a chocolate
may have associated cocoaOrders
, our delete method should take care to delete these orders first, before deleting the chocolate.
// services/chocolateService
@Service
public class ChocolateService{
// ...
public void deleteChocolate(Long chocolateId){
// find chocolate
Chocolate chocolate = findChocolate(chocolateId); // ADDED
// delete associated cocoaOrders
for (CocoaOrder cocoaOrder : chocolate.getCocoaOrders()){ // ADDED
cocoaOrderRepository.deleteById(cocoaOrder.getId()); // ADDED
}
chocolateRepository.deleteById(chocolateId);
}
}
EstateService
We can do the same with the EstateService
class.
We should:
- inject a CocoaOrderRepository
object
- tweak our updateEstate()
method by replacing the for-loop that searches for the correct chocolates
with one that looks for cocoaOrders
. This method should also be amended to take an EstateDTO
instead of estate
.
- tweak our deleteEstate()
method to take care of any cocoaOrders
it may have
// services/EstateService
@Service
public class EstateService {
@Autowired
EstateRepository estateRepository;
@Autowired
CocoaOrderRepository cocoaOrderRepository;
public List<Estate> findAllEstates(){
return estateRepository.findAll();
}
public Estate findEstate(Long id){
return estateRepository.findById(id).get();
}
public void saveEstate(EstateDTO estateDTO){
Estate estate = new Estate(estateDTO.getName(), estateDTO.getCountry());
estateRepository.save(estate);
}
public void deleteEstate(Long estateId){
// find estate
Estate estate = findEstate(estateId); // ADDED
// delete associated cocoaOrders
for (CocoaOrder cocoaOrder : estate.getCocoaOrders()){ // ADDED
cocoaOrderRepository.deleteById(cocoaOrder.getId()); // ADDED
}
estateRepository.deleteById(estateId);
}
public Estate updateEstate(EstateDTO estateDTO, Long id){ // MODIFIED
Estate estateToUpdate = estateRepository.findById(id).get();
estateToUpdate.setName(estateDTO.getName());
estateToUpdate.setCountry(estateDTO.getCountry());
// REMOVE: estateToUpdate.setChocolates(estate.getChocolates());
estateToUpdate.setCocoaOrders(new ArrayList<CocoaOrder>()); // ADDED
for (Long cocoaOrderId : estateDTO.getCocoaOrderIds()){ // ADDED
CocoaOrder cocoaOrder = cocoaOrderRepository.findById(cocoaOrderId).get(); // ADDED
estateToUpdate.addCocoaOrder(cocoaOrder); // ADDED
}
estateRepository.save(estateToUpdate);
return estateToUpdate;
}
}
Controllers
update EstateController
to use an EstateDTO
in the CREATE and UPDATE routes
update ChocolateController
to use ChocolateDTO
in the CREATE and UPDATE routes
Test these routes
return to CocoaOrderController
and begin to create the following routes: INDEX, SHOW, CREATE, DELETE (and if you have time, UPDATE)
// controllers/CocoaOrderController
@RestController
@RequestMapping("cocoa-orders")
public class CocoaOrderController {
@Autowired
CocoaOrderService cocoaOrderService;
// INDEX
@GetMapping
public ResponseEntity<List<CocoaOrder>> getAllCocoaOrders(){
return new ResponseEntity<>(cocoaOrderService.findAllCocoaOrders(), HttpStatus.OK);
}
// CREATE
@PostMapping
public ResponseEntity<List<CocoaOrder>> createCocoaOrder(@RequestBody CocoaOrderDTO cocoaOrderDTO){
cocoaOrderService.saveCocoaOrder(cocoaOrderDTO);
return new ResponseEntity<>(cocoaOrderService.findAllCocoaOrders(), HttpStatus.CREATED);
}
// DELETE
@DeleteMapping(value = "/{id}")
public ResponseEntity<Long> deleteCocoaOrder(@PathVariable Long id){
cocoaOrderService.deleteCocoaOrder(id);
return new ResponseEntity<>(id, HttpStatus.OK);
}
// SHOW
@GetMapping(value = "/{id}")
public ResponseEntity<CocoaOrder> getChocolate(@PathVariable Long id){
CocoaOrder foundCocoaOrder = cocoaOrderService.findCocoaOrder(id);
return new ResponseEntity<>(foundCocoaOrder, HttpStatus.FOUND);
}
}