Spring Data JPA 2 : One-to-Many
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.persistencea été transformée enjakarta.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 :
@Entity
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
public Category() {
}
// getters and setters omitted for brevity
}
@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 :
@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
Potionva ainsi contenir une référence à la classeCategory: 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 :
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 :
// 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@JoinColumnet le nom de la clef étrangère. -
quelle entité va être de l'autre côté de la relation, avec l'annotation
@OneToManyen lui précisant, le nom de l'attribut de l'autre classe à associer, grâce au paramètremappedBy.
@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
Categoryva ainsi contenir une référence vers une liste dePotion: 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 :
// 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.
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 :
@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 :
java.sql.SQLIntegrityConstraintViolationException: Column 'category_id' cannot be null
2. Fetch
Tu récupères une catégorie, associée à plusieurs potions :
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
Categorydès l'appel defindById. -
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 deCategoryne 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 :
@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: combineREMOVE,REFRESHet bien d'autres opérations. C'est la plus utilisée !
@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 :
@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.REFRESH)
@JoinColumn(name = "category_id")
private Category category;
La section "Advantages and Disadvantages" est intéressante
Pour aller plus loin sur l'utilisation des cascades
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 :
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.
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.
- Modifier l'entité
Wizardafin d'y ajouter le code nécessaire à la création de sa relation avec une école :School school - Modifier l'entité
Schoolafin d'y ajouter le code nécessaire à la création de sa relation avec une liste de sorciers :List<Wizard> wizards - Relancer le serveur : les requêtes de création de la clef étrangère de sa contrainte devraient apparaître dans les logs
- 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
Wizardcontient bien tout le code nécessaire pour lier une école. - La classe
Schoolcontient 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.