JEE Back-end

Mappings complexes avec JPA

Dans cette séance

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.

Relations entre les classes (1/4)

Le diagramme UML est un moyen concis de décrire les relations entre les classes.

Relations entre les classes (2/4)

  • Un objet A contient plusieurs objets de B. Un objet de B est contenu dans au plus un objet de A.
  • Un objet A contient 0 ou 1 objet de C. Un objet de C peut être contenu dans plusieurs objet de A.

Relations entre les classes (3/4)

Du point de vue de la classe A,

  • Le mapping vers la classe B est OneToMany.
  • Le mapping vers la classe C est ManyToOne
  • Il existe deux autres types de mapping : OneToOne et ManyToMany.

Relations entre les classes (4/4)

Agrégation vs Composition

  • Le losange plein sur la flèche vers 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.
  • Le losange vide sur la flèche vers 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.

Exemple concret

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.

Uni-directionnel ou bi-directionnel

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.

Mapping en JPA

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.

Retour sur l'exemple

@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)

Exemple de persistence

@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);
    }
    ...
}     

Exemple de persistence

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.

Exemple de persistence

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.

Cascade

@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.

Récupération des objets associés (1/3)

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!

Récupération des objets associés (2/3)

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

Récupération des objets associés (3/3)

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.

Récupération des objets associés

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

Solutions (1/3)

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 Set phoneNumbers;

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.

Solutions (2/3)

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.

Solutions (3/3)

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!

Suppression (1)

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 ->{
            ...
        });
}    

Suppression (2)

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.

Suppression (3)

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.

Suppression (4)

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.

Suppression (5)

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);
    });
}    

Suppression (6)

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;
}

Suppression (7)

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);
});
}    

Suppression (8)

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);
});
}    

Redéfinir equals ou pas ?

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

  • Le @ManyToOne a un fonctionnement symétrique au mapping @OneToMany.
    Une différence majeure est que par défaut ce mapping est par défaut fetchType.EAGER mais on peut le changer.
  • Le mapping @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.
  • Le @ManyToMany fait toujours une table de jointure.

@Embeddable

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.