Dans la séance précédente, nous avons vu les concepts de base de JPA et comment mapper des classes simples.
Dans cette séance, nous allons voir comment mapper des classes qui contiennent d'autres classes.
Le diagramme UML est un moyen concis de décrire les relations entre les classes.
B
. Un objet de B
est contenu dans au plus un objet de A
.C
. Un objet de C
peut être contenu dans plusieurs objet de A
.Du point de vue de la classe A
,
B
est OneToMany
.C
est ManyToOneOneToOne
et ManyToMany
.Agrégation vs Composition
B
indique une relation de composition. La destruction (resp. la création) en BD d'un objet de A
implique la destruction (resp. la création) en BD des objets de B
qu'il contient.C
indique une relation d'agrégation.
La destruction (resp. la création) en BD d'un objet de A
n'implique pas la destruction (resp. la création) en BD des objets de C
qu'il contient.Si l'on retire un étudiant de la BD, on retire ses numéros de téléphones mais pas l'enregistrement qui représente son université.
Deux étudiants peuvent avoir le même numéro de téléphone, on dit juste que dans ce cas il y aura deux enregistrements différents en BD pour représenter le même numéro de téléphones.
Si un objet de A
contient un ou des objets de B
mais que les objets de B
ne contiennent pas de références sur les objets de A
.
On parle de mapping uni-directionnel.
Dans le cas contraire, on parle de mapping bi-directionnel.
public class PhoneNumber { private int id; private String phoneNumber; private Student student; ... }
Dans ce séance, on ne traitera que des mappings uni-directionnels.
Pour définir le mapping JPA d'une classe, il faut d'abord trouver le type des mappings (OneToMany, ...) vers les classes qu'il contient.
Il faut aussi se poser la question de savoir si on a affaire à une relation de composition ou d'agrégation.
Il faut savoir si l'on veut un mapping uni-directionnel ou bi-directionnel.
@Entity public class Student { @Id @GeneratedValue private long id; private String firstName; private String lastName; @OneToMany private Set<PhoneNumber> phoneNumbers; ... }
@OneToMany (1/4)
Par défaut, JPA crée une table de jointure.
@OneToMany (2/4)
On peut spécifier le nom de la table de jointure et de ses colonnes avec l'annotation @JoinTable
.
@Entity public class Student { @Id @GeneratedValue private long id; private String firstName; private String lastName; @OneToMany @JoinTable(name="Students_PhoneNumbers", joinColumns=@JoinColumn(name="Student_Id"), inverseJoinColumns=@JoinColumn(name="PhoneNumber_Id")) private Set<PhoneNumber> phoneNumbers; ... }
@OneToMany (3/4)
Comme un numéro de téléphone n'est utilisé que par un seul étudiant, on peut se passer de la table de jointure et mettre une colonne dans la table de PhoneNumber
avec la clé primaire du Student
correspondant.
Pour cela, on utilise l'annotation @JoinColumn
.
@Entity public class Student { @Id @GeneratedValue private long id; private String firstName; private String lastName; @OneToMany @JoinColumn(name = "Student_Id") private Set<PhoneNumber> phoneNumbers; ... }
@OneToMany (4/4)
@Entity public class Student { @Id @GeneratedValue private long id; private String firstName; private String lastName; @OneToMany @JoinColumn(name = "Student_Id") private Set<PhoneNumber> phoneNumbers; public Student(){} public Student(String firstName, String lastName,Set<PhoneNumber> phoneNumbers) { this.firstName = firstName; this.lastName = lastName; this.phoneNumbers= Set.copyOf(phoneNumbers); } ... }
PersitenceUtils.inTransaction( em -> { var harry = new Student("Harry","Potter",Set.of(new PhoneNumber("0700000000"))); em.persists(harry); });
On obtient l'erreur suivante:
object references an unsaved transient instance - save the transient instance before flushing: fr.uge.jee.hibernate.students.PhoneNumber
En effet, l'objet PhoneNumber
que référence l'objet Student
est transient.
On peut persister à la main le PhoneNumber
.
PersitenceUtils.inTransaction( em -> { var phoneHarry = new PhoneNumber("0700000000"); em.persists(phoneHarry); var harry = new Student("Harry","Potter",Set.of(phoneHarry)); em.persists(harry); });
Il existe une meilleure solution. On peut exprimer en JPA la relation de composition de Student
vers PhoneNumber
. En JPA, on parle de cascade.
Intuitivement on veut qu'à chaque fois qu'un Student
est inséré en BD, les PhoneNumber
qu'il contient qui ne sont pas en BD soit insérer s'ils ne sont pas en BD.
@Entity public class Student { @Id @GeneratedValue private long id; private String firstName; private String lastName; @OneToMany(cascade = {CascadeType.PERSIST}) @JoinColumn(name = "Student_Id") private Set<PhoneNumber> phoneNumbers; ... }
Il existe plusieurs types de cascades selon le type de l'opération concernée CascadeType.REMOVE
, CascadeType.MERGE
, ...
CascadeType.ALL
concerne toutes opérations.
Dans notre cas, on veut aussi que la destruction d'un Student
en BD supprime aussi ses PhoneNumber
.
PersitenceUtils.inTransaction( em -> { var q = "SELECT s FROM Student s WHERE s.firstName='Harry'"; var query = em.createQuery(q,Student.class); var student = query.getSingleResult(); System.out.println(student.getPhoneNumbers()); });
Tout se passe bien!
Student student = PersitenceUtils.inTransaction( em -> { var q = "SELECT s FROM Student s WHERE s.firstName='Harry'"; var query = em.createQuery(q,Student.class); return res = query.getSingleResult(); }); System.out.println(student.getPhoneNumbers());
On obtient une exception org.hibernate.LazyInitializationException
à la ligne surlignée!
failed to lazily initialize a collection of role: fr.uge.jee.hibernate.students.Student.phoneNumbers, could not initialize proxy - no Session
Par défaut, Hibernate ne va pas récuperer en BD les PhoneNumber
quand il va récupérer un Student
en BD.
Moralement, Hibernate renvoie un proxy pour l'objet Student dans lequel la méthode getPhoneNumbers
qui pourrait ressembler à:
Set<PhoneNumber> getPhoneNumbers(){ if (phoneNumber==null){ var q = "SELECT p FROM Student s LEFT JOIN s.phoneNumbers p WHERE s.id = :id"; var query = em.createQuery(q,PhoneNumber.class).setParameter("id",id); phoneNumbers=query.getListResult(); } return phoneNumber; }
La vérité est un peu plus compliquée: la méthode getPhoneNumbers()
renvoie un proxy de Set<PhoneNumber>
. Cet ensemble est initialement vide et c'est seulement quand on appel une de ses méthodes qui accède aux données qu'il est rempli en faisant une requête BD.
PersitenceUtils.inTransaction( em -> { var q = "SELECT s FROM Student s WHERE s.firstName='Harry'"; var query = em.createQuery(q,Student.class); var student = query.getSingleResult(); System.out.println(student.getPhoneNumbers()); // pas de pb });
OK
Student student = PersitenceUtils.inTransaction( em -> { var q = "SELECT s FROM Student s WHERE s.firstName='Harry'"; var query = em.createQuery(q,Student.class); return res = query.getSingleResult(); }); System.out.println(student.getPhoneNumbers()); // pas d'EntityManager
KO
Set<PhoneNumber> numbers = PersitenceUtils.inTransaction( em -> { var q = "SELECT s FROM Student s WHERE s.firstName='Harry'"; var query = em.createQuery(q,Student.class); return res = query.getSingleResult().getPhoneNumbers(); }); System.out.println(numbers); // le proxy n'est pas rempli et pas d'EntityManager
KO
Par défaut, OneToMany
,ManyToMany
sont paresseux (lazy), les éléments contenus ne sont pas récupérés.
On peut changer ce comportement dans la définition du mapping:
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER) @JoinColumn(name = "Student_Id") private SetphoneNumbers;
Sauf dans de rare cas, c'est une mauvaise solution car cela implique qu'on va toujours récupérer les objets contenus même si on en a pas besoin.
On peut forcer l'initialisation du proxy Set<PhoneNumber>
dans la transaction.
Set<PhoneNumber> numbers = PersitenceUtils.inTransaction( em -> { var q = "SELECT s FROM Student s WHERE s.firstName='Harry'"; var query = em.createQuery(q,Student.class); var res = query.getSingleResult().getPhoneNumbers(); res.size(); // pour initialiser le proxy, arg !!! return res; }); System.out.println(numbers); // le proxy est initialisé
Beurk !
Pire ce code fait deux SELECT
distincts.
La bonne solution consiste à utiliser JPQL qui permet d'indiquer dans la requête les objets contenu que l'on veut rappatrier.
var st = util.inTransaction( em -> { var q = "SELECT s FROM Student s LEFT JOIN FETCH s.phoneNumbers" + "WHERE s.firstName='Harry'"; var query = em.createQuery(q,Student.class); var res = query.getSingleResult(); return res.getPhoneNumbers(); } );
Cette requête ne fait qu'un seul SELECT
!
Avec la cascade cascadeType.REMOVE
, la suppression d'un Student
implique la suppression de ses PhoneNumber
.
La situation est plus compliquée si l'on veut supprimer un PhoneNumber
pour un Student
.
void removePhoneNumber(long studentId,long phoneNumberId){ PersistenceUtils.inTransaction(em ->{ ... }); }
Si le lien entre les tables Student
et PhoneNumber
est fait par une JoinColumn
, on peut simplement supprimer le PhoneNumber
void removePhoneNumber(long studentId,long phoneNumberId){ util.inTransaction(em ->{ var phoneNumber = em.find(PhoneNumber.class,phoneNumberId); if (phoneNumber==null){ throw new IllegalArgumentException(); } em.remove(phoneNumber); }); }
Bilan en BD: un SELECT
et un DELETE
.
Attention, ce code marche dans ce cadre précis où l'on crée un EntityManager
avant de faire la transaction. On verra plus tard qu'il ne fonctionne plus si
l'EntityManager
contient le Student
avec phoneNumber
dans son ensemble
phoneNumbers
. Dans ce cas, il faudrait enlever phoneNumber
de l'ensemble phoneNumbers
pour qu'il fonctionne.
Si le lien entre les tables Student
et PhoneNumber
est fait par une JoinTable
, le code précédent ne marche pas car il laisserait la BD dans un état incohérent.
Il faut supprimer l'entrée du PhoneNumber
dans la table de jointure et dans la table PhoneNumber
.
public static void removePhoneNumber2(long studentId,long phoneNumberId){ PersistenceUtils.inTransaction(em ->{ var phoneNumber = em.find(PhoneNumber.class,phoneNumberId); var student = em.find(Student.class,studentId); if (phoneNumber==null){ throw new IllegalArgumentException(); } // suppression dans la table jointure student.getPhoneNumbers().remove(phoneNumber); // suppression dans la table PhoneNumber em.remove(phoneNumber); }); }
Bilan en BD: trois SELECT
et deux DELETE
.
Il faut supprimer l'entrée du PhoneNumber
dans la table de jointure et dans la table PhoneNumber
.
public static void removePhoneNumber2(long studentId,long phoneNumberId){ PersistenceUtils.inTransaction(em ->{ var phoneNumber = em.find(PhoneNumber.class,phoneNumberId); var student = em.find(Student.class,studentId); if (phoneNumber==null){ throw new IllegalArgumentException(); } // suppression dans la table jointure student.getPhoneNumbers().remove(phoneNumber); // suppression dans la table PhoneNumber em.remove(phoneNumber); }); }
Si l'on supprime l'entrée du PhoneNumber
dans la table de jointure, le numéro de téléphone reste en base.
public static void removePhoneNumber2(long studentId,long phoneNumberId){ util.inTransaction(em ->{ var phoneNumber = em.find(PhoneNumber.class,phoneNumberId); var student = em.find(Student.class,studentId); if (phoneNumber==null){ throw new IllegalArgumentException(); } student.getPhoneNumbers().remove(phoneNumber); }); }
On peut faire en sorte qu'un numéro qui n'est plus référencé dans la table de jointure soit supprimer.
@Entity public class Student { ... @OneToMany(cascade = CascadeType.ALL,orphanRemoval=true) private Set<PhoneNumber> phoneNumbers; }
On peut faire mieux : avec un SELECT
et deux DELETE
.
public static void removePhoneNumber2(long studentId,long phoneNumberId){ PersistenceUtils.inTransaction(em ->{ var q = "SELECT s FROM Student s LEFT JOIN FETCH s.phoneNumbers where s.id=:id"; var query = em.createQuery(q,Student.class); query.setParameter("id",studentId); var student = query.getSingleResult(); if (student==null){ throw new IllegalArgumentException(); } var phoneNumber=student.getPhoneNumbers() .stream() .filter(p ->p.getId()==phoneNumberId) .findAny() .orElseThrow(() -> new IllegalArgumentException()); student.getPhoneNumbers().remove(phoneNumber); em.remove(phoneNumber); }); }
On peut faire plus efficace, en utilisant le fait que l'EntityManager
joue le rôle de cache.
public static void removePhoneNumber2(long studentId,long phoneNumberId){ PersistenceUtils.inTransaction(em ->{ var q = "SELECT s FROM Student s LEFT JOIN FETCH s.phoneNumbers where s.id=:id"; var query = em.createQuery(q,Student.class); query.setParameter("id",studentId); var student = query.getSingleResult(); if (student==null){ throw new IllegalArgumentException(); } var phoneNumber = em.find(PhoneNumber.class,phoneNumberId); // pas de select :) student.getPhoneNumbers().remove(phoneNumber); em.remove(phoneNumber); }); }
On manipule des collections (i.e. Set
) de PhoneNumber
donc il semble évident qu'il faut redéfinir les méthodes equals
et hashCode
de PhoneNumber
.
Pb: le champ id
est fixé par la BD donc si on s'en sert pour equals
, equals
est faux pour les objets transients. Si on ne s'en sert pas on a un equals faux si deux objets avec des id différentes, on les mêmes champs sinon.
Tant qu'on utilise le Set<PhoneNumber>
avec l'EntityManager
, il n'y a aucun problème à ne pas redéfinir equals
. L'EntityManager
garantie qu'il y a au plus un objet avec une id
donnée. Donc le ==
a effectivement le comportement que l'on attend de equals
.
Il n'y a pas de solution parfaite et il faut réfléchir: ne pas surcharger, utiliser l'id, utiliser une clé métier, ...
@OneToOne
, @ManyToOne
et @ManyToMany
@ManyToOne
a un fonctionnement symétrique au mapping @OneToMany
. fetchType.EAGER
mais on peut le changer. @OneToOne
réalise une colonne de jointure dans la table de la classe.
Elle semble être fetchType.EAGER
par défaut mais on peut le changer.
@ManyToMany
fait toujours une table de jointure.On n'est pas nécessairement obligé de créer un table pour chaque objet contenu dans la classe.
class Student{ ... @Embedded private Address; ... } @Embeddable class Address{ ... private int streetNumber; private String street; }
Les champs de Address
sont des colonnes de la table Student
.