Archives Wild Code School
Spring

Spring Data JPA 2 : One-to-Many

Quête 12 sur 16

Précédemment, tu as vu comment générer une base de données à partir d'entités en utilisant la puissance de la spécification JPA et de son implémentation Hibernate.

Cependant, tu as dû te rendre compte qu'en l'état, il ne t'est pas possible de gérer les relations entre les entités :

  • comment faire si une potion a une catégorie ?
  • comment faire si une catégorie appartient à plusieurs potions ?

Tu vas découvrir comment créer ces relations grâce à des annotations JPA !

:warning: En Spring Boot 3: le package des annotations JPA javax.persistence a été transformée en jakarta.persistence

Note : l'IDE peut t'aider à trouver les packages de chaque élément

Objectifs

  • Savoir créer une association One-to-Many
  • Savoir créer une association Many-to-One

Associations

Lors de la modélisation d'une base de données, tu as pu rencontrer trois types d'associations :

  • les associations [1, n] : appelées One-to-Many, par exemple : une potion possède une seule catégorie (1), une catégorie peut être associée à plusieurs potions (n).

  • les associations [n, n] : appelées Many-to-Many, par exemple : une potion peut avoir plusieurs effets (n), un effet pouvant être attribué à plusieurs potions (n).

  • les association [1, 1] : appelées One-to-One, par exemple: un sorcier ne possède qu'un seul diplôme (1), un diplôme n'appartient qu'à un seul sorcier (1).

Selon le sens dans lequel tu liras la relation [1, n], on parlera de One-to-Many (1 catégorie pour n potions) ou Many-to-One (n potions pour 1 catégorie).

Many-to-One

La spécification JPA contient plusieurs annotations Java qui vont te permettre de gérer les associations vues précédemment : elle sont comprises dans le package javax.persistence.*.

Tu vas découvrir comment réaliser une relation Many-to-One avec JPA. Pourquoi pas One-to-Many en premier ? Parce que c'est plus simple comme ça !

Tu veux modéliser le cas suivant : une catégorie peut être associée à plusieurs potions (n), une potion possède une seule catégorie (1).

Tu as déjà créé les entités Category et Potion :

java
@Entity
public class Category {

   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   private Long id;
   private String title;

   public Category() {
   }

   // getters and setters omitted for brevity
}

java
@Entity
public class Potion {

   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   private Long id;
   private String name;
   private Integer power;

   public Potion() {
   }

   // getters and setters omitted for brevity
}

La relation Many-to-One que tu voudrais obtenir est modélisée de la façon suivante :

La jointure entre les deux tables se fait sur la clef étrangère category_id dans la table potion.

Pour réaliser cette jointure avec JPA, tu as accès à l'annotation @ManyToOne. Pour préciser le nom du champ créé lors de la jointure, l'annotation utilisée est @JoinColumn, avec la propriété name :

java
@Entity
public class Potion {

   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   private Long id;
   private String name;
   private Integer power;

   @ManyToOne
   @JoinColumn(name = "category_id")
   private Category category;

   public Potion() {
   }

   // getters and setters omitted for brevity
}

Tu remarques que la classe Potion va ainsi contenir une référence à la classe Category : il faudra donc lui créer des accesseurs et mutateurs.

Lors de l'exécution du serveur Spring Boot, les tables vont ainsi être créées en tenant compte de la jointure sur la clef étrangère :

shell
Hibernate: create table category (id bigint not null auto_increment, title varchar(255), primary key (id)) engine=InnoDB

Hibernate: create table potion (id bigint not null auto_increment, name varchar(255), power integer, category_id bigint, primary key (id)) engine=InnoDB

Hibernate: alter table potion add constraint FKdvrn6ii7ahoh68l2bphfkib9l foreign key (category_id) references category (id)

Hibernate crée automatiquement un champ category_id dans potion, puisqu'il y ajoute une contrainte de clé étrangère faisant référence au champ id de la table category.

Pour créer une potion, il faudra préalablement avoir une référence de sa catégorie, par exemple :

java
// find the category with id 1 (Long)
Category restoration = categoryRepository.findById(1L).get();

// create a new potion for this category
Potion demonicLeech = new Potion("A Philter of Demonic Leech", 5, restoration);

// save the potion in database : potion's id is now assigned
demonicLeech = potionRepository.save(demonicLeech);

Si seule l'annotation @ManyToOne est présente, on parle de relation unidirectionnelle : il sera possible pour une potion de récupérer sa catégorie, mais une catégorie ne pourra pas récupérer l'ensemble de ses potions.

One-to-Many

Maintenant, imagines que tu veuilles, pour une catégorie, récupérer toutes ses potions. La relation à créer sera One-to-Many.

Dans ce cas on va parler de relation bidirectionnelle. Pour ce faire il va te falloir définir :

  • quelle entité va posséder la relation : c'est le côté @ManyToOne, qui aura l'annotation @JoinColumn et le nom de la clef étrangère.

  • quelle entité va être de l'autre côté de la relation, avec l'annotation @OneToMany en lui précisant, le nom de l'attribut de l'autre classe à associer, grâce au paramètre mappedBy.

java
@Entity
public class Category {

   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   private Long id;
   private String title;

   @OneToMany(mappedBy = "category")
   private List<Potion> potions;

   // getters and setters omitted for brevity
}

Tu remarques que la classe Category va ainsi contenir une référence vers une liste de Potion : il faudra donc lui créer des accesseurs et mutateurs.

Ici mappedBy contient la valeur "category" : elle fait référence à l'attribut category de la classe Potion.

À lire attentivement : il faut donner le nom d'attribut que l'on veut joindre à la valeur de mappedBy : si dans Potion, le nom de l'attribut était Category foo, il faudrait écrire mappedBy = "foo" dans Category.

Lors de la récupération d'une catégorie, il est maintenant possible d'avoir toutes les potions qui lui sont associées :

java
// find the category with id 1 (Long)
Category restoration = categoryRepository.findById(1L).get();

// for each potion of restoration, show its name
for (Potion potion : restoration.getPotions()) {
   System.out.println(potion.getName());
}

Il n'est pas obligatoire de créer une relation bidirectionnelle : ça sera à toi de voir selon tes besoins.

↗
JPA and Hibernate One To Many Relationship Mapping Example with Spring Boot and MySQL
hellokoding.com
↗
Difference Between @JoinColumn and mappedBy
baeldung.com

Propriétés des annotations

1. Nullable et Optional

Lors de l'utilisation de l'annotation @JoinColumn, une propriété nullable, avec la valeur true par défaut, est associée à la clé étrangère. Par exemple : une potion pourrait ne pas avoir de catégorie associée. La propriété nullable gouverne la contrainte du côté de la génération de la base de données.

Lors de l'utilisation de l'annotation @ManyToOne, la propriété optional, avec la valeur true par défaut, est associée à la clé étrangère. La propriété optional gouverne la contrainte du côté de Spring. Peu importe comment la colonne est configurée en base de données : si optional a la valeur false, il ne sera pas possible d'y insérer une valeur nulle.

Si tu veux modifier ton entité Potion, afin qu'une potion ne puisse pas avoir de catégorie nulle, tu devras écrire le code suivant :

java
@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.REFRESH, optional = false)
@JoinColumn(name = "category_id", nullable = false)
private Category category;

En cas d'insertion d'une valeur nulle, le serveur affichera l'erreur suivante :

shell
java.sql.SQLIntegrityConstraintViolationException: Column 'category_id' cannot be null

2. Fetch

Tu récupères une catégorie, associée à plusieurs potions :

java
Category category = categoryRepository.findById(1L).get();
List<Potion> potions = category.getPotions();

Spring pourra charger les informations de la catégorie de deux façon :

  • immédiate (eager) : lors du chargement d'une entité, toutes les relations seront chargées. Dans notre cas, toutes les potions seront contenues dans l'instance de Category dès l'appel de findById.

  • paresseuse (lazy) : les relations ne sont chargées qu'au besoin. Dans notre cas, la requête récupérant les potions d'une catégorie ne sera exécutée qu'à l'appel de la méthode getPotions. L'instance de Category ne contiendra pas tout de suite les potions.

Le chargement paresseux est à privilégier, car il est plus rapide. Cependant, il exécutera une requête supplémentaire quand les données issues de l'association sont utilisées.

Pour préciser à Spring de quelle façon charger les données, on utilise la propriété fetch avec les valeurs FetchType.EAGER (immédiat) et FetchType.LAZY (paresseux).

Ces propriétés s'insèrent dans les annotations @ManyToOne et @OneToMany, par exemple :

java
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;

3. Cascade

Tu as vu précédemment comment mettre les données en relation entre-elles. Il y a un aspect à prendre en compte lors de ces relations : que se passe-t-il quand un côté de la relation est modifié ou supprimé ?

Imaginons que tu supprimes une catégorie : que va-t-il advenir des potions orphelines (les potions qui appartenait à cette catégorie) ?

Si tu décides de les supprimer à leurs tours, il va te falloir utiliser la propriété cascade, avec la propriété Cascade.REMOVE.

Dans cette quête, nous n'allons pas entrer dans les détails des valeurs de cascade, si tu veux aller plus loin tu trouveras des informations en ressource.

Pour le moment, connaît ces trois-là :

  • Cascade.REMOVE : supprime l'entité quand une relation est supprimée.

  • Cascade.REFRESH : met à jour l'entité quand une relation est modifiée ou supprimée.

  • Cascade.ALL : combine REMOVE, REFRESH et bien d'autres opérations. C'est la plus utilisée !

java
@OneToMany(mappedBy = "category", cascade = CascadeType.ALL)
private List<Potion> potions;

Attention : prend garde au côté où tu vas ajouter un Cascade.ALL. Tu n'aurais pas envie que, lors de la suppression d'une potion, la catégorie soit elle aussi supprimée !

Par contre il serait intéressant de mettre à jour le contenu de la liste de potions : dans ce cas-là tu pourras ajouter la propriété CascadeType.REFRESH :

java
@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.REFRESH)
@JoinColumn(name = "category_id")
private Category category;

↗
Eager/Lazy Loading In Hibernate

La section "Advantages and Disadvantages" est intéressante

baeldung.com
↗
Guide to JPA and Hibernate Cascade Types

Pour aller plus loin sur l'utilisation des cascades

javaguides.net

Démonstration

Afin de voir et de tester les exemples de cette quête, récupère le dépôt en ressource.

Tu vas devoir créer une nouvelle base de données vide spring_jpa_sample :

sql
CREATE DATABASE  IF NOT EXISTS `spring_jpa_sample`;

L'utilisateur est le même que celui des quêtes précédentes : h4rryp0tt3r avec le mot de passe Horcrux4life!. Il faudra lui donner tous les droits sur la base de données spring_jpa_sample.

↗
Dépôt d'exemple de la quête
github.com

Challenge

La magie de Spring Data JPA

Fait un fork du dépôt suivant : https://github.com/WildCodeSchool/quest-spring-jpa-onetomany.

Tu reprends le développement d'un site de fan d'Harry Potter afin d'y ajouter les fonctionnalités suivantes :

  • un sorcier devrait pouvoir s'inscrire à une (et une seule) école.
  • il devrait être possible de récupérer la liste des élèves inscrit à une école.

La première étape va consister à créer la base de données spring_jpa_onetomany, à partir du fichier sql suivant.

L'utilisateur est le même que celui des quêtes précédente : h4rryp0tt3r avec le mot de passe Horcrux4life!. Il faudra lui donner tous les droits sur la base de données spring_jpa_onetomany.

  1. Modifier l'entité Wizard afin d'y ajouter le code nécessaire à la création de sa relation avec une école : School school
  2. Modifier l'entité School afin d'y ajouter le code nécessaire à la création de sa relation avec une liste de sorciers : List<Wizard> wizards
  3. Relancer le serveur : les requêtes de création de la clef étrangère de sa contrainte devraient apparaître dans les logs
  4. Sans modifier d'autres classes, il devrait être possible d'ajouter un élève à une école et de voir la liste des élèves inscrits, à partir du lien "registration"

Si ça ne marche pas, regarde les messages d'erreurs dans les logs :

  • as-tu bien nommé les attributs comme demandé ?
  • as-tu créé les getters et setters ?

Critères de validation

  • Le projet s'exécute bien sur la base de données et l'utilisateur requis.
  • La classe Wizard contient bien tout le code nécessaire pour lier une école.
  • La classe School contient bien tout le code nécessaire pour lier une liste d'élèves.
  • Il est possible d'inscrire un élève à une école à partir du lien "registration".
  • Il est possible de voir la liste des élèves inscrit à une école à partir du lien "registration".
  • Le code est disponible sur GitHub.