Database Relationships in JPA - One to Many
Learning Objectives
- Understand the One-to-many relationship
- Be able to implement a One-to-many relationship using Hibernate/JPA
- Be able to annotate models to set up a database schema describing a one-to-many relationship
- Appreciate why
@JsonIgnoreProperties
is used to partially suppress output - Be able to correctly format a POST request when another table is being referenced
Introduction
So far, we've created some models, some controllers, added some endpoints using multiple HTTP methods such as GET and POST, and even learned a little bit about the importance of separation of concerns, including how to apply this best practice with the use of a service layer in our application. We've even learned the essentials of persisting data to a Postgres DB via JPA Repositories. So far, however, our models are unrelated to one another. In practice we often need to track relationships between records in our DB. We will be learning about the types of relationships we can create, and how to best track them, in our next couple of lessons.
One-to-many Relationships
The first type of relationship we will learn about is the one-to-many. You actually worked with one-to-many examples in the SQL lessons, but let's do a little refresher; the best way to understand the different types of relationships is often by considering several theoretical, as well as more concrete, real-world examples. Firstly, think of a bowl of fruit:
---
title: Fruit Example
---
classDiagram
note "one to many"
FruitBowl "1" -- "!..*" Fruit
class FruitBowl{
- ArrayList< Fruit > fruit
}
class Fruit{
- FruitBowl fruitBowl
}
If we were to represent this relationship in code, it would be one-to-many - a bowl can have many fruit (in a collection, such as an ArrayList), whereas a fruit can be in only one bowl, not several. Another example of one-to-many would be the relationsip between a car dealership and the cars in it's inventory:
---
title: Car Dealership Example
---
classDiagram
note "one to many"
CarDealership "1" -- "!..*" Car
class CarDealership{
- ArrayList< Car > cars
}
class Car{
- CarDealership dealership
}
Hopefully those examples help to make the concept of a one-to-many relationship a bit clearer for you. But, you may wonder what would be a common example you'd come accross in real-world programming scenarios? In eCommerce, a great example would be the relationship between a customer and their purchases:
---
title: eCommerce Example
---
classDiagram
note "one to many"
Customer "1" -- "!..*" Purchase
class Customer{
- ArrayList< Purchase > purchases
// other properties
}
class Purchase{
- Customer customer
// other properties
}
Creating One-to-Many Relationships in Hibernate/JPA
Thanksfully, a lot of the grunt work of creating relationships between database tables/records is handled for us by Hibernate/JPA. We simply need to mark certain elements of our models and their properties with the appropriate annotations, so Hibernate knows what to do. In our guessing game project, we are going to create a one-to-many relationship between games and players - A player can play many different games over time, but since our example is a single-player game, a game can have only one player. The basic steps we will need to follow are as follows:
- Create a
Player
model, and give it a collection of all the games the player has played - Annotate this collection as
@OneToMany
- Add a
player
property to theGame
model - Annotate this property as
@ManyToOne
Some other tasks we will complete, which don't relate directly to the one-to-many concept, but are still essential, are to create a PlayerService
, a PlayerController
, and a PlayerRepository
.
The Player Model
Let's start by adding a Player
model to our models
package, alongside the other models, such as Game, LetterList, Reply etc. The first thing you'll want to do is open this new class up and tell Hibernate that it is an entity, using @Entity:
// models/Player.java
@Entity
@Table(name = "players")
public class Player {
}
By setting the name
attribute to players
, we're telling Hibernate that we want the table in the DB to be called players
(plural). Otherwise, it would default to the name of the class (Player), which might not cause any errors, but would break with the accepted convention for naming DB tables.
Next, we'll add our private properties:
// models/Player.java
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Column(name = "name")
private String name;
private List<Game> games;
Which of the above properties do you think relates to the one-to-many relationship? That's right, games
! So we need to add an annotation to mark it as such. First we'll add the @OneToMany
annotation (one player to many games).
// models/Player.java
// ...
@OneToMany(mappedBy = "player")
private List<Game> games;
Now that our property is annotated like this Hibernate will attempt to set up a relationship between players
and another table. In SQL terms we need to define a foreign key somewhere, but Hibernate needs us to tell it where to make the link. This information is derived from the datatype of the annotated property. In this case we have a list of Game
objects, so Hibernate will set up the relationship to the table defined by the Game
model.
Wealso need to add the mappedBy
argument to tell Hibernate which property of Game
will represent the foreign key. We haven't refactored Game
yet so this property currently doesn't exist, but ultimately it will have a player
property which
Next, let's write our constructor. It only needs to accept a name
parameter to set the property, plus initialise an empty list of games. We'll also add a default constructor and getters and setters for each property.
// models/PLayer.java
public Player(String name) {
this.name = name;
this.games = new ArrayList<>();
}
// default constructor
// getters & setters
And that's everything for the Player
model - next, we'll create our repository, service and controller.
The Player Service & Repository
We need a service class to, essentially, act as the manager for our player entity, and handle all dealings with the player repository, which we will also create in this step. Let's start by adding an interface called PlayerRepository
to the repositories package - functionally, this is much the same as the GameRepository
interface we created previously:
// repositories/PlayerRepository.java
public interface PlayerRepository extends JpaRepository<Player, Long> {
}
Since this is almost identical to GameRepository
, there's nothing particularly new here, so let's move on. Please create a class called PlayerService
in the services package. First, at the top of our new PlayerService class, we need to add the PlayerRepository as a dependency, so we can use it's methods.
// services/PlayerService.java
@Service
public class PlayerService{
@Autowired
PlayerRepository playerRepository;
}
The @Autowired
annotation makes sure that a PlayerRepository
object gets injected into our PlayerService
object upon creation.
Next, we will add 3 methods which are going to be very useful to us in the PlayerController
- getAllPlayers
, getPlayerById
, and savePlayer
, all of which are pretty self-explanatory:
// services/PlayerService.java
public List<Player> getAllPlayers(){
return playerRepository.findAll();
}
public Optional<Player> getPlayerById(Long id){
return playerRepository.findById(id);
}
public Player savePlayer(Player player){
playerRepository.save(player);
return player;
}
The Player Controller
Now that we have our model and service classes, we'll need a controller to handle the HTTP requests from the end user. Please create a new class called PlayerController
in the controllers
package. Firstly, add the @RestController
and @RequestMapping
annotations to it, as we did with GameController:
// controllers/PlayerController.java
@RestController
@RequestMapping(value = "/players")
public class PlayerController {
}
As a brief reminder, @RestController
is basically @Controller
and @ResponseBody
smushed together in one tidy little annotation. It marks the class as a controller for Spring, and indicates that all methods within should return serialized JSON, wihtout having to explicitly state this by adding the @ResponseBody
annotation to all of them individually. @RequestMapping
is used to make /players
the base of all endpoints on this controller.
Next, we'll add our PlayerService
as a dependency with @Autowired
:
// controllers/PlayerController.java
@RestController
@RequestMapping(value = "/players")
public class PlayerController {
@Autowired
PLayerService playerService;
}
Doing so gives us access to the methods of PlayerService
. To finish off our PlayerController
, we'll need to add 3 mappings: getAllPlayers
, getPlayerById
and addNewPlayer
, the function of which is self-explanatory:
// controllers/PlayerController.java
@GetMapping
public ResponseEntity<List<Player>> getAllPlayers(){
List<Player> players = playerService.getAllPlayers();
return new ResponseEntity<>(players, HttpStatus.OK);
}
@GetMapping(value = "/{id}")
public ResponseEntity<Player> getPlayerById(@PathVariable Long id){
Optional<Player> player = playerService.getPlayerById(id);
if (player.isPresent()){
return new ResponseEntity<>(player.get(), HttpStatus.OK);
} else {
return new ResponseEntity<>(null, HttpStatus.NOT_FOUND);
}
}
@PostMapping
public ResponseEntity<Player> addNewPlayer(@RequestBody Player player){
Player savedPlayer = playerService.savePlayer(player);
return new ResponseEntity<>(savedPlayer, HttpStatus.CREATED);
}
And that's all of our player classes implemented. Well done!
Updating the Game Model
Next, we need to update the Game
model so that it has a property called player
, which references the the Player object which is associated with the game - this is the many side of the one-to-many relationship. Please add the following private property after your existing private properties in the Game class:
// models/Game.java
@ManyToOne
@JoinColumn(name = "player_id")
private Player player;
Notice that, on the many side of the one-to-many relationship, we use the @ManyToOne
annotation, instead of @OneToMany
, which should only be used on the one side. @JoinColumn
specifies the name of the column to use for the foreign key of the player in the SQL table - in this case, player_id
. @JoinColumn
must be used on the many side of the relationship in a one-to-many relationship. It wouldn't work if we put it in the Player
class instead of Game
.
Please take a look at the existing constructor in Game
- you'll notice that, presently, it only accepts arguments for the word
and curentState
but not for the player. Let's update it to accept a player
argument, and assign it to the player
property we just created:
// models/Game.java
public Game(String word, String currentState Player player) { // MODIFIED
this.word = word;
this.currentState = currentState;
this.guesses = 0;
this.complete = false;
this.player = player; // ADDED
}
Excellent. We're almost finished updating the Game model - but, remember that Game
, being a model, should be a POJO, and that POJOs should have getters and setters for all private properties. This means that we also need to add in a getter and a setter for the player property, like so:
// models/Game.java
public Player getPlayer() {
return player;
}
public void setPlayer(Player player) {
this.player = player;
}
Updating the Game Service
And that's everything for the model. The GameService class will also need to be updated however - specifically, the startNewGame
method, since it calls the Game constructor, which now requires a player object as a third argument. We need to update the existing startNewGame
method, but also use @Autowired
to connect the PlayerService
and give us the functionality to get the player details.
// services/GameService.java
@Service
public class GameService{
@Autowired
PlayerService playerService; // ADDED
public Reply startNewGame(long playerId){
String targetWord = wordService.getRandomWord();
String currentWordStatus = Strings.repeat("*", targetWord.length());
Player player = playerService.getPlayerById(playerId).get(); // ADDED
Game game = new Game(targetWord, currentWordStatus, player); // MODIFIED
gameRepository.save(game);
return new Reply(
game.getCurrentState(),
String.format("Started new game with id %d", game.getId()), // MODIFIED
false
);
}
}
Updating the Game Controller
Now let's take a look at the GameController
class, specifically the newGame
mapping. In order to work correctly with GameService
following our changes we need to accept a @RequestParam
called playerId
, and pass it to the GameService
object, like so:
// controllers/GameService.java
@PostMapping
public ResponseEntity<Reply> newGame(@RequestParam long playerId){ // MODIFIED
Reply reply = gameService.startNewGame(playerId); // MODIFIED
return new ResponseEntity<>(reply, HttpStatus.CREATED);
}
Updating the Data Loader
Now that we have changed the requirements for saving a game we need to relfect those changes in our seed data. It's not as simple as just passing id
arguments to our startNewGame()
calls, we need to save some players in order to generate those ids. We need to inject a PlayerService
instance before saving some players, then using the id values returned to create some games.
// components/DataLoader.java
@Component
public class DataLoader implements ApplicationRunner {
@Autowired
GameService gameService;
@Autowired
PlayerService playerService;
@Override
public void run(ApplicationArguments args) throws Exception {
Player player1 = new Player("Colin");
Player player2 = new Player("Thibyaa");
Player player3 = new Player("Anna");
playerService.savePlayer(player1);
playerService.savePlayer(player2);
playerService.savePlayer(player3);
gameService.startNewGame(player1.getId());
gameService.startNewGame(player2.getId());
gameService.startNewGame(player3.getId());
gameService.startNewGame(player2.getId());
gameService.startNewGame(player1.getId());
}
}
Handling Infinite Recursion
If we test our GET
requests for the /players
routes we'll run into errors: Postman will abort the request while making the request from a browser shows a never-ending loop of raw JSON. What's gone wrong?
The way our data is structure is contributing to the problem. A Player
has a list of Game
s. Each of those Game
s includes details of its Player
, which will be the same Player
as we started with! If we try to serialise all of this we end up with a never-ending chain of nested objects which Postman can't handle and a browser can only show as raw JSON.
The solution is to add another annotation which will partially suppress JSON serialisation, depending on how we request the data. We will start with the Player
model and suppress the details which will be included when we serialise its list of Game
s.
// models/Player.java
// ...
@OneToMany(mappedBy = "player")
@JsonIgnoreProperties({"player"}) // ADDED
private List<Game> games;
// ...
@JsonIgnoreProperties
does exactly what the name suggests - when a Game
object is serialised as part of a Player
object Spring will not include the player
property. This breaks the recursive nesting and fixes our issue. We can ignore more than one property by comma-separating them in teh argument passed to the annotation.
It's important to annotate both ends of the relationship, meaning we need to update Game
too.
// models/Game.java
// ...
@ManyToOne
@JoinColumn(name = "player_id")
@JsonIgnoreProperties({"games"}) // ADDED
private Player player;
// ...
Test the Routes
Those are all the code changes we need to make - if the steps were followed successfully, then the routes should all work, but we should test them to confirm. Any GET
routes can be tested in the browser, but other route will need to be tested in Postman. If you run into issues, follow the usual debugging and troubleshooting procedures to work through them.