Java Persistence API (JPA)

Introduction

La technologie JPA (Java Persistence API) a pour objectif d'offrir un modèle d'ORM (Object Relational Mapping) indépendant d'un produit particulier (comme Hibernate, TopLink, etc.). Cette technologie est basée sur

Cette technologie est utilisable dans les applications Web (conteneur Web), ou dans les EJB (serveur d'applications) ou bien dans les applications standards (Java Standard Edition). C'est ce dernier cas que nous allons étudier.

Quelques liens :

Mise en place

Projet Eclipse

Pour la mise en pratique de JPA, nous allons utiliser le framework Hibernate qui va nous servir de fournisseur de persistance. Plus précisement, suivez les étapes ci-dessous :

Une première entité

Pour construire notre exemple, nous allons créer une entité personne (classe Person) :

package myapp.jpa.model;

import java.util.Date;

import jakarta.persistence.Basic;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PostUpdate;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Temporal;
import jakarta.persistence.TemporalType;
import jakarta.persistence.Transient;
import jakarta.persistence.Version;

import lombok.Data;
import lombok.NoArgsConstructor;

@Entity(name = "Person")
@Data
@NoArgsConstructor
public class Person {

    @Id()
    @GeneratedValue(strategy = GenerationType.AUTO)
    private long id;

    @Basic(optional = false)
    private String firstName;

    @Basic()
    @Temporal(TemporalType.DATE)
    private Date birthDay;

    @Version()
    private long version = 0;

    @Transient
    public static long updateCounter = 0;

    public Person(String firstName, Date birthDay) {
        super();
        this.firstName = firstName;
        this.birthDay = birthDay;
    }

    @PreUpdate
    public void beforeUpdate() {
        System.err.println("PreUpdate of " + this);
    }

    @PostUpdate
    public void afterUpdate() {
        System.err.println("PostUpdate of " + this);
        updateCounter++;
    }

}

Explications : Il y a beaucoup de choses à expliquer sur cet exemple. Commençons par étudier les annotations :

@Entity
Cette annotation indique que la classe est un EJB Entity qui va représenter les données de la base de données relationnelle. Vous pouvez fixer un nom (attribut name) qui, par défaut, est celui de la classe. [JavaDoc]
@Id
Cette annotation précise la propriété utilisée comme clef primaire. Cette annotation est obligatoire pour un EJB Entity. [JavaDoc]
@GeneratedValue
Cette annotation spécifie la politique de construction automatique de la clef primaire. [JavaDoc]
@Basic
C'est l'annotation la plus simple pour indiquer qu'une propriété est persistante (c'est-à-dire gérée par JPA). A noter que le chargement retardé d'une propriété est possible avec l'attribut fetch=FetchType.LAZY. [JavaDoc]
@Temporal
A utiliser pour le mapping des types liés au temps (java.util.Date, java.sql.Date, java.sql.Time, java.sql.Timestamp et java.util.Calendar). [JavaDoc]
@Transient
Indique que la propriété n'est pas persistante. [JavaDoc]
@Version
Indique la propriété à utiliser pour activer et gérer la version des données. Cette capacité est notamment utile pour implémenter une stratégie de concurrence optimiste. [JavaDoc]
@PreUpdate/@PostUpdate
Deux exemples de callback. Voilà la liste des évènements récupérables : PostLoad, PostPersist, PostRemove, PostUpdate, PrePersist, PreRemove.

Le service DAO

Continuons en créant la classe de service qui va se charger des opération d'E/S.

package myapp.jpa.dao;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.Persistence;
import jakarta.persistence.PersistenceException;

import org.springframework.stereotype.Service;

import myapp.jpa.model.Person;

@Service
public class JpaDao {

    private EntityManagerFactory factory = null;

    @PostConstruct
    public void init() {
        factory = Persistence.createEntityManagerFactory("myBase");
    }

    @PreDestroy
    public void close() {
        if (factory != null) {
            factory.close();
        }
    }

    /*
     * Ajouter une personne
     */
    public Person addPerson(Person p) {
        EntityManager em = null;
        try {
            em = factory.createEntityManager();
            em.getTransaction().begin();
            em.persist(p);
            em.getTransaction().commit();
            return p;
        } finally {
            closeEntityManager(em);
        }
    }

    /*
     * Charger une personne
     */
    public Person findPerson(long id) {
        EntityManager em = null;
        try {
            em = factory.createEntityManager();
            em.getTransaction().begin();
            Person p = em.find(Person.class, id);
            em.getTransaction().commit();
            return p;
        } finally {
            closeEntityManager(em);
        }
    }

    /*
     * Fermeture d'un EM (avec rollback éventuellement)
     */
    private void closeEntityManager(EntityManager em) {
        if (em == null || !em.isOpen())
            return;

        var t = em.getTransaction();
        if (t.isActive()) {
            try {
                t.rollback();
            } catch (PersistenceException e) {
                e.printStackTrace(System.err);
            }
        }
        em.close();
    }

}

Explication 1 : Lors de l'initialisation, la classe Persistence est utilisée pour analyser les paramètres de connection (fichier persistence.xml) et trouver l'unité de persistance passée en paramètre (myBase dans cet exemple). A l'issue de cette étape nous récupérons une instance de l'interface EntityManagerFactory. Cette usine, qui est généralement un singleton, nous permettra, dans un deuxième temps, d'ouvrir des connections vers la base de données.

Explication 2 : Pour agir sur les entités (addPerson) nous devons récupérer à partir de l'usine une instance de l'interface EntityManager. Celle-ci va permettre les opérations CRUD de persistance sur les entités (Create, Read, Update et Delete). Un EntityManager ne supporte pas le multi-threading. Il doit donc être créé et détruit à chaque utilisation par un thread. Cette création est une opération peu coûteuse qui peut se reproduire un grand nombre de fois (contrairement à l'usine).

Test du service DAO

Créez une classe de test unitaire en vous basant sur Junit 5 :

package myapp.jpa;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import myapp.jpa.dao.JpaDao;
import myapp.jpa.model.Person;

@SpringBootTest
public class TestJpaDao {

    @Autowired
    JpaDao dao;

    @Test
    public void addAndFindPerson() {
        // Création
        var p1 = new Person("Jean", null);
        p1 = dao.addPerson(p1);
        assertTrue(p1.getId() > 0);
        // relecture
        var p2 = dao.findPerson(p1.getId());
        assertEquals("Jean", p2.getFirstName());
        assertEquals(p1.getId(), p2.getId());
    }

}

@Autowired : La classe Dao est instanciée une seule fois car la création d'une EntityManagerFactory est une opération très coûteuse. C'est typiquement une opération réalisée à l'initialisation de l'application.

Travail à faire :

Compléter le service DAO

Vous pouvez maintenant implémenter les méthodes ci-dessous en utilisant les méthodes de l'interface EntityManager (notamment merge pour la mise à jour et remove pour la suppression). Attention : pour la suppression, vous devez au prélable charger l'entité à supprimer avec em.find(...).

public void updatePerson(Person p) {
   ...
}

public void removePerson(long id) {
   ...
}

Travail à faire : Enrichir la classe de test unitaire pour créer une personne, la recharger, la modifier et finalement la détruire.

Configurer les noms SQL

Motivation : Pour l'instant, nous laissons JPA/Hibernate générer automatiquement les noms des tables et des colonnes à partir des noms de classes et de propriétés. Cette approche n'est pas possible si la base de données existe déjà.

L'annotation @Table permet de préciser le nom de la table associée à une classe ainsi que d'ajouter des conditions d'unicité. Dans l'exemple ci-dessous, nous ajoutons une contrainte d'unicité sur le couple (prénom, date de naissance).

L'annotation @Column permet préciser la correspondance entre colonne d'une table et propriété d'une classe et d'imposer des contraintes supplémentaires.

...

@Entity(name = "Person")

@Table(name = "TPerson",
   uniqueConstraints = {
      @UniqueConstraint(columnNames = {
         "first_name", "birth_day"
      })
   })
      
public class Person {
        
    ...

    // propriété à modifier (ajouter @Column)
    @Basic(optional = false, fetch = FetchType.EAGER)
    @Column(name = "first_name", length = 200)
    private String firstName;

    // propriété à ajouter
    @Basic(optional = true, fetch = FetchType.EAGER)
    @Column(name = "second_name", length = 100, nullable = true, unique = true)
    private String secondName;

    // propriété à modifier (ajouter @Column)
    @Basic()
    @Temporal(TemporalType.DATE)
    @Column(name = "birth_day")
    private Date birthDay;
        
    ...

}

Travail à faire :

Utiliser Spring avec JPA

Vous avez remarqué que les méthodes de la classe Dao contiennent beaucoup de code technique (création/fermeture d'un EM, ouverture/fermeture d'une transaction). Nous allons maintenant utiliser Spring pour nous aider à simplifier l'utilisation de JPA. Suivez les étapes ci-dessous :

Explication 1 : Les annotations @Repository et @Transactional permettent d'indiquer que la classe va manipuler des données et que chaque méthode doit être exécutée dans une transaction.

Explication 2 : L'annotation @PersistenceContext réclame l'injection d'un EM. Cette instance est capable de gérer en même temps plusieurs transactions associées à plusieurs threads s'exécutant en parallèle. Les transactions sont démarrées avant l'exécution des méthodes et stoppées après. L'entity manager injecté par Spring va agir comme un Proxy d'aiguillage qui va associer un EM réel au thread courant, c'est-à-dire à la requête courante, c'est-à-dire au client courant. Cette solution respecte le principe un thread est associé à chaque requête et un EM à chaque thread.

Clients  Requêtes/Threads  Singleton Dao      Un em par thread/requête/client
-------  ----------------  -------------      -------------------
  C1     --> R1/T1 --> +                      +--> em1.find(...)
                       |                      |
  C2     --> R2/T2 --> +---> JpaDao --> em -->+--> em2.merge(...)
                       |                      |
  C3     --> R3/T3 --> +                      +--> em3.remove(...)

Travail à faire : Terminez l'adaptation des méthodes de la classe JpaDao.

Travail à faire : Pour tester la gestion des transactions, écrivez un test pour appeler la méthode addPerson sur la même personne dans deux threads qui s'exécutent en parallèle. Une insertion doit fonctionner et l'autre pas.

Les requêtes en JPA

JPA fournit le langage JPQL afin de construire des requêtes. Il est important de noter que JPQL s'exprime sur les classes et les propriétés et non pas sur les tables et les colonnes. En d'autres termes, JPQL travaille sur le modèle objet et pas sur le modèle relationnel. JPQL assure ainsi une indépendance complète de l'application vis-à-vis de la base de données et vis-à-vis du SGBDR utilisé.

Requêtes simples

Ajoutez à votre service DAO une méthode de listage des personnes :

public List<Person> findAllPersons() {
    String query = "SELECT p FROM Person p";
    TypedQuery<Person> q = em.createQuery(query, Person.class);
    return q.getResultList();
}

Travail à faire : En utilisant la JavaDoc de l'interface Query et cette présentation du langage JPQL (très proche de SQL), proposez une version permettant de sélectionner les personnes à partir de leur prénom (clause like). Pour ce faire, vous utiliserez des requêtes paramétrées (format :nom-du-paramètre) et la méthode setParameter de la requête typée pour fixer la valeur de paramètre.

public List<Person> findPersonsByFirstName(String pattern) {
    ...
}

Requêtes nommées

Vous pouvez aussi utiliser l'annotation NamedQuery pour placer les requêtes dans les classes d'entité et y faire référence.

Travail à faire : Créez une requête particulière par l'annotation NamedQuery dans la classe Person et utilisez-la grace à la méthode createNamedQuery (version typée JPA 2) de l'interface EntityManager. C'est une solution simple pour délocaliser les requêtes dans les classes entité et simplifier les DAO.

Création d'objets

Nous avons souvent besoin de récupérer une partie des propriétés d'une entité. Par exemple le numéro et le prénom de chaque personne pour dresser une liste. Pour ce faire, nous devons créer un javaBean myapp.jpa.model.FirstName qui représente les informations voulues et utiliser la requête suivante :

SELECT new myapp.jpa.model.FirstName(p.id,p.firstName)
FROM Person p

Travail à faire : Créez le nouveau JavaBean FirstName ci-dessous et ajoutez une méthode qui renvoie la liste des prénoms et testez-la.

package myapp.jpa.model;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class FirstName {

    private long id;
    private String firstName;

}

Les classes embarquées

Les classes embarquées permettent de coder plus facilement des relations un-un comme par exemple, le fait que chaque personne doit avoir une et une seule adresse. Dans ce cas il est plus simple de placer l'adresse dans la table qui code les personnes sans être obligé de créer une nouvelle table.

Définissons la classe Address et indiquons qu'elle peut être embarquée avec l'annotation Embeddable :

package myapp.jpa.model;

import jakarta.persistence.Embeddable;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Embeddable
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Address {

        private String street;
        private String city;
        private String country;

}

Nous pouvons maintenant embarquer cette adresse dans la classe Person en utilisant l'annotation Embedded. Nous retrouverons dans la table TPerson les colonnes nécessaires pour coder la rue, la ville et le pays.

   ...

   @Embedded
   private Address address;

   ...

Complément : Si nous voulions associer deux adresses à une seule personne, alors nous devrions changer le nom des colonnes qui codent la rue, la ville et le pays de la deuxième adresse. Pour ce faire, nous pouvons utiliser l'annotation AttributeOverrides (voir Embedded).