Aller au contenu

Hibernate & JPA — Cours complet

Sans Spring Boot · Avec Spring Boot · Java 17 · Eclipse · MySQL


Sommaire


1. Introduction à la persistance Java

Même si nous avons déjà abordé la partie JPA, les entités et le Mapping, nous allons approfondir, voir même revoir cettains concepts afin que vous maitrisiez Hibernate et JPA.

1.1. Le problème de l’impedance objet-relationnel

Imaginez que vous modélisez un système de gestion de commandes. En Java, vous créez des classes : Client, Commande, Produit. Ces objets ont des méthodes, des liens entre eux, une hiérarchie d’héritage. Mais en base de données, tout est stocké sous forme de tables, de lignes et de colonnes — un monde fondamentalement différent.

Ce décalage entre le monde objet et le monde relationnel s’appelle le problème d’impédance (Object-Relational Impedance Mismatch). Il se manifeste par exemple quand :

Avant Hibernate, les développeurs écrivaient manuellement du JDBC — des dizaines de lignes de code pour chaque requête, avec gestion manuelle des connexions, des ResultSet, comme nous l’avons fait lors de la semaine 4.

// sans Hibernate, le code JDBC manuel s'avère long et fastidieux
Connection conn = DriverManager.getConnection(url, user, password);
PreparedStatement ps = conn.prepareStatement(
    "SELECT * FROM client WHERE id = ?");
ps.setInt(1, 42);
ResultSet rs = ps.executeQuery();
if (rs.next()) {
    Client client = new Client();
    client.setId(rs.getInt("id"));
    client.setNom(rs.getString("nom"));
    client.setEmail(rs.getString("email"));
    // ... 20 lignes supplémentaires pour la connexion, les erreurs, la fermeture...
}
//  avec Hibernate il suffit d'écrire une seule ligne !
Client client = entityManager.find(Client.class, 42);

1.2. Qu’est-ce que JPA et Hibernate ?

JPA (Java Persistence API) est une spécification Java EE/Jakarta EE qui définit comment mapper des objets Java vers une base de données relationnelle. C’est un ensemble d’interfaces et d’annotations : Ce n’est pas une implémentation.

Hibernate est l’implémentation la plus populaire de JPA. Il est développé par Red Hat et existe depuis 2001. D’autres implémentations existent (EclipseLink, OpenJPA) mais Hibernate domine le marché.

Votre code Java
       │
       ▼
   JPA (spec)          Vous utilisez les interfaces JPA
       │
       ▼
   Hibernate            Qui appelle en coulisse...
       │
       ▼
   JDBC Driver          le driver (pilote) JDBC
       │
       ▼
   Base de données      pour se communiquer avec PostgreSQL, H2 ou MySQL,...

Utilisez toujours les interfaces JPA (@Entity, EntityManager, @OneToMany…) plutôt que les APIs spécifiques Hibernate. Cela rend votre code portable vers d’autres implémentations et plus standard !

Pour cela, il faut vérifier les importations de vos packaages !

1.3. Versions et compatibilité

La version actuelle de Jakata est la 11 qui est compatible avec Java 21. La 12 est en développement.

Les versions utilisées sont celles listées ci-dessous :

Version Standard Nom package Java requis
JPA 2.2 Java EE 8 javax.persistence Java 8+
Jakarta Persistence 3.0 Jakarta EE 9 jakarta.persistence Java 11+
Jakarta Persistence 3.1 Jakarta EE 10 jakarta.persistence Java 11+

En 2019, Java EE a migré vers la fondation Eclipse sous le nom Jakarta EE. Les packages ont changé de javax.* à jakarta.*. Hibernate 6+ utilise jakarta.persistence. Spring Boot 3+ nécessite Hibernate 6+ et Jakarta EE. Dans ce cours, nous utilisons Hibernate 6 avec jakarta.persistence.

1.4. Architecture d’une application avec Hibernate

┌─────────────────────────────────────────────────────────┐
│                 VOTRE APPLICATION                       │
│                                                         │
│  ┌──────────────┐    ┌─────────────────────────────┐    │
│  │  Entités JPA │    │        Services métier      │    │
│  │  @Entity     │    │  CatalogueService           │    │
│  │  Client      │    │  CommandeService            │    │
│  │  Produit     │    │  (votre logique)            │    │
│  └──────────────┘    └──────────────┬──────────────┘    │  
│                                     │                   │
│  ┌──────────────────────────────────▼────────────────┐  │
│  │            EntityManager / Repository             │  │
│  │    (persist, find, merge, remove, createQuery)    │  │
│  └──────────────────────────────────┬────────────────┘  │
│                                     │                   │
│  ┌──────────────────────────────────▼────────────────┐  │
│  │                  Hibernate ORM                    │  │
│  │        (génère le SQL, gère le cache...)          │  │
│  └──────────────────────────────────┬────────────────┘  │
└─────────────────────────────────────│───────────────────┘
                                      │ JDBC
                              ┌───────▼────────┐
                              │  Base MySQL /  │
                              │  PostgreSQL /  │
                              │  H2            │
                              └────────────────┘

2. Hibernate sans Spring Boot — Configuration

2.1. Prérequis et dépendances Maven

Créez un projet Maven dans Eclipse (File puis New puis Maven Project) avec les dépendances suivantes :

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>fr.formation</groupId>
    <artifactId>hibernate-demo</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <hibernate.version>6.4.4.Final</hibernate.version>
        <mysql.version>8.3.0</mysql.version>
        <h2.version>2.2.224</h2.version>
        <junit.version>5.10.1</junit.version>
        <assertj.version>3.24.2</assertj.version>
        <logback.version>1.4.14</logback.version>
    </properties>

    <dependencies>

        <!-- Hibernate ORM — implémentation JPA -->
        <dependency>
            <groupId>org.hibernate.orm</groupId>
            <artifactId>hibernate-core</artifactId>
            <version>${hibernate.version}</version>
        </dependency>

        <!-- Driver MySQL ou PostgreSQL-->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <version>${mysql.version}</version>
        </dependency>

        <!-- H2 — base de données en mémoire pour les tests -->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <version>${h2.version}</version>
            <scope>test</scope>
        </dependency>

        <!-- Logging -->
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>${logback.version}</version>
        </dependency>

        <!-- Tests -->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>${junit.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>${assertj.version}</version>
            <scope>test</scope>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.11.0</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.2.2</version>
            </plugin>
        </plugins>
    </build>

</project>

2.2. Le fichier persistence.xml

Sans Spring Boot, Hibernate est configuré via le fichier persistence.xml placé dans src/main/resources/META-INF/ :

<!-- src/main/resources/META-INF/persistence.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="https://jakarta.ee/xml/ns/persistence"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence
             https://jakarta.ee/xml/ns/persistence/persistence_3_0.xsd"
             version="3.0">

    <!-- Une "unité de persistance" = une configuration de base de données -->
    <persistence-unit name="hibernate-demo" transaction-type="RESOURCE_LOCAL">

        <!-- Toutes les entités du projet -->
        <!-- Avec Hibernate 6, il les détecte automatiquement -->
        <properties>

            <!-- ════════ CONNEXION ════════ -->
            <property name="jakarta.persistence.jdbc.driver"
                      value="com.mysql.cj.jdbc.Driver"/>
            <property name="jakarta.persistence.jdbc.url"
                      value="jdbc:mysql://localhost:3306/hibernate_demo
                             ?useSSL=false&amp;serverTimezone=Europe/Paris
                             &amp;characterEncoding=UTF-8"/>
            <property name="jakarta.persistence.jdbc.user"     value="root"/>
            <property name="jakarta.persistence.jdbc.password" value="votre_mot_de_passe"/>

            <!-- ════════ HIBERNATE ════════ -->
            <!-- Dialecte — Hibernate adapte le SQL au SGBD -->
            <property name="hibernate.dialect"
                      value="org.hibernate.dialect.MySQLDialect"/>

            <!-- Afficher le SQL généré dans la console (développement) -->
            <property name="hibernate.show_sql"   value="true"/>
            <property name="hibernate.format_sql" value="true"/>

            <!-- Stratégie de création/mise à jour du schéma :
                 none        = ne rien faire
                 validate    = valider le schéma sans le modifier
                 update      = créer/modifier les tables manquantes
                 create      = recréer le schéma à chaque démarrage
                 create-drop = créer au démarrage, supprimer à l'arrêt -->
            <property name="hibernate.hbm2ddl.auto" value="update"/>

            <!-- Pool de connexions basique -->
            <property name="hibernate.connection.pool_size" value="5"/>

            <!-- Encodage -->
            <property name="hibernate.connection.charSet"   value="UTF-8"/>
            <property name="hibernate.connection.characterEncoding" value="UTF-8"/>

        </properties>

    </persistence-unit>

</persistence>

En développement, hibernate.hbm2ddl.auto=update est pratique. En production, utilisez validate ou none et gérez les migrations avec Liquibase ou Flyway.

2.3. Initialiser Hibernate — L’EntityManagerFactory

// src/main/java/fr/formation/util/HibernateUtil.java
package fr.formation.util;

import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.Persistence;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Utilitaire Singleton pour gérer l'EntityManagerFactory.
 * L'EMF est coûteux à créer — on le crée une seule fois.
 */
public class HibernateUtil {

    private static final Logger log =
        LoggerFactory.getLogger(HibernateUtil.class);

    // Singleton — une seule instance pour toute l'application
    private static EntityManagerFactory emf;

    // Nom de l'unité de persistance dans persistence.xml
    private static final String PERSISTENCE_UNIT = "hibernate-demo";

    private HibernateUtil() {}  // Pas d'instanciation directe

    /**
     * Retourne l'EntityManagerFactory (crée à la première demande).
     */
    public static synchronized EntityManagerFactory getEntityManagerFactory() {
        if (emf == null || !emf.isOpen()) {
            log.info("Création de l'EntityManagerFactory...");
            emf = Persistence.createEntityManagerFactory(PERSISTENCE_UNIT);
            log.info("EntityManagerFactory créée avec succès.");
        }
        return emf;
    }

    /**
     * Ferme l'EntityManagerFactory — à appeler à la fin de l'application.
     */
    public static void fermer() {
        if (emf != null && emf.isOpen()) {
            emf.close();
            log.info("EntityManagerFactory fermée.");
        }
    }
}

2.4. Utilisation de base de l’EntityManager

// Exemple de cycle de vie de l'EntityManager
EntityManagerFactory emf = HibernateUtil.getEntityManagerFactory();
EntityManager em = emf.createEntityManager();

try {
    // Démarrer une transaction
    em.getTransaction().begin();

    // Opérations de persistance ici...
    Client client = new Client("Alicia", "alicia@exemple.fr");
    em.persist(client);  // INSERT

    // Valider la transaction
    em.getTransaction().commit();

} catch (Exception e) {
    // En cas d'erreur — annuler
    if (em.getTransaction().isActive()) {
        em.getTransaction().rollback();
    }
    throw e;
} finally {
    // Toujours fermer l'EntityManager
    em.close();
}

2.5. Configuration H2 pour les tests

Comme je vous l’ai déjà dit, H2 est une base de données en mémoire qui est bien pratique pour réaliser des tests.

<!-- src/test/resources/META-INF/persistence.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="https://jakarta.ee/xml/ns/persistence"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence
             https://jakarta.ee/xml/ns/persistence/persistence_3_0.xsd"
             version="3.0">

    <persistence-unit name="hibernate-demo-test"
                      transaction-type="RESOURCE_LOCAL">
        <properties>
            <!-- H2 en mémoire — rapide, s'efface après les tests -->
            <property name="jakarta.persistence.jdbc.driver"
                      value="org.h2.Driver"/>
            <property name="jakarta.persistence.jdbc.url"
                      value="jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;
                             DB_CLOSE_ON_EXIT=FALSE;MODE=MySQL"/>
            <property name="jakarta.persistence.jdbc.user"     value="sa"/>
            <property name="jakarta.persistence.jdbc.password" value=""/>

            <property name="hibernate.dialect"
                      value="org.hibernate.dialect.H2Dialect"/>
            <property name="hibernate.hbm2ddl.auto" value="create-drop"/>
            <property name="hibernate.show_sql"     value="true"/>
            <property name="hibernate.format_sql"   value="false"/>
        </properties>
    </persistence-unit>

</persistence>

2.6. TP 1 — Mise en place de l’environnement

Objectif : Configurer un projet Hibernate fonctionnel.

  1. Créez un projet Maven hibernate-tp1 dans Eclipse.
  2. Copiez le pom.xml fourni ci-dessus.
  3. Créez la base de données MySQL hibernate_demo :
CREATE DATABASE hibernate_demo
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
  1. Créez le fichier persistence.xml avec vos paramètres de connexion.
  2. Créez HibernateUtil.java.
  3. Créez une classe TestConnexion.java avec un main() qui appelle HibernateUtil.getEntityManagerFactory() et affiche “Connexion réussie !” si aucune exception n’est levée.
  4. Exécutez avec mvn exec:java ou via Eclipse → Run As → Java Application.

3. Les entités JPA — Mapping objet-relationnel

3.1. Votre première entité

Une entité est une classe Java normale annotée @Entity qu’Hibernate sait persister en base de données.

// src/main/java/fr/formation/model/Client.java
package fr.formation.model;

import jakarta.persistence.*;
import java.time.LocalDate;
import java.util.Objects;

/**
 * Entité JPA — correspond à la table "client" en base.
 * Chaque instance de Client correspond à une ligne de la table.
 */
@Entity                           // Déclare que cette classe est une entité JPA
@Table(name = "client",           // Nom de la table (optionnel si même nom)
       schema = "hibernate_demo") // Schéma de la base
public class Client {

    // ── CLÉ PRIMAIRE ──────────────────────────────────────────────────────────
    @Id                           // Identifiant unique de l'entité
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    // IDENTITY    = AUTO_INCREMENT MySQL (recommandé pour MySQL)
    // SEQUENCE    = séquence Oracle/PostgreSQL
    // TABLE       = table de séquences (portable mais lent)
    // AUTO        = Hibernate choisit la stratégie
    private Long id;

    // ── COLONNES SIMPLES ──────────────────────────────────────────────────────
    @Column(name = "nom",
            nullable = false,      // NOT NULL en SQL
            length = 100)          // VARCHAR(100)
    private String nom;

    @Column(name = "prenom", length = 100)
    private String prenom;

    @Column(name = "email",
            nullable = false,
            unique = true,         // UNIQUE en SQL
            length = 200)
    private String email;

    @Column(name = "date_naissance")
    private LocalDate dateNaissance;

    @Column(name = "solde_compte",
            precision = 10,        // 10 chiffres au total
            scale = 2)             // dont 2 après la virgule → DECIMAL(10,2)
    private Double soldeCompte;

    @Column(name = "actif",
            nullable = false,
            columnDefinition = "TINYINT(1) DEFAULT 1")
    private boolean actif = true;

    // ── CONSTRUCTEURS ─────────────────────────────────────────────────────────
    //  OBLIGATOIRE : JPA exige un constructeur sans argument
    protected Client() {}

    public Client(String nom, String prenom, String email) {
        this.nom    = nom;
        this.prenom = prenom;
        this.email  = email;
    }

    // ── GETTERS / SETTERS sauf si vous utilisez Lombok ────────────────────────
    public Long   getId()            { return id; }
    public String getNom()           { return nom; }
    public void   setNom(String n)   { this.nom = n; }
    public String getPrenom()        { return prenom; }
    public void   setPrenom(String p){ this.prenom = p; }
    public String getEmail()         { return email; }
    public void   setEmail(String e) { this.email = e; }
    public LocalDate getDateNaissance()        { return dateNaissance; }
    public void setDateNaissance(LocalDate d)  { this.dateNaissance = d; }
    public Double getSoldeCompte()             { return soldeCompte; }
    public void   setSoldeCompte(Double s)     { this.soldeCompte = s; }
    public boolean isActif()                   { return actif; }
    public void    setActif(boolean actif)     { this.actif = actif; }

    // ── EQUALS / HASHCODE ─────────────────────────────────────────────────────
    //  Toujours basé sur l'ID (jamais sur les champs mutables)
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Client)) return false;
        Client other = (Client) o;
        return id != null && Objects.equals(id, other.id);
    }

    @Override
    public int hashCode() {
        return getClass().hashCode();  // Stable même avant persistence
    }

    @Override
    public String toString() {
        return String.format("Client{id=%d, nom='%s %s', email='%s'}",
            id, prenom, nom, email);
    }
}

3.2. Les annotations de mapping essentielles

// Exemples d'annotations JPA courantes

// ── TYPES SPÉCIAUX ────────────────────────────────────────────────────────────

@Enumerated(EnumType.STRING)  // stocker l'enum comme chaîne (pas comme nombre)
private StatutCommande statut;

@Temporal(TemporalType.TIMESTAMP)  // pour java.util.Date (préférer java.time)
private Date dateCreation;

// avec java.time (Java 8+) — pas d'annotation @Temporal nécessaire
private LocalDate       dateNaissance;    // DATE
private LocalDateTime   dateCreation;     // DATETIME/TIMESTAMP
private LocalTime       heureOuverture;   // TIME

@Lob  // Large Object — pour les textes longs ou données binaires
private String description;  // TEXT/CLOB

@Lob
private byte[] photo;  // BLOB

// ── VALEUR TRANSITOIRE (non persistée) ───────────────────────────────────────
@Transient  // Ce champ n'est PAS stocké en base, déjà abordé
private String champCalcule;

// ── COLONNES GÉNÉRÉES ─────────────────────────────────────────────────────────
@Column(insertable = false, updatable = false)
private LocalDateTime dateCreation;  // Géré par la base de données

// ── VERSION POUR L'OPTIMISTIC LOCKING ─────────────────────────────────────────
@Version
private Integer version;  // Hibernate incrémente automatiquement (optionnel)

3.3. Les stratégies d’héritage

Ceci est un rappel : Hibernate gère 3 stratégies pour mapper l’héritage Java en tables SQL :

// ── Stratégie 1 : SINGLE_TABLE (une seule table pour toute la hiérarchie) ────
//  Recommandé pour la performance (pas de jointure)
//  Colonnes NULL pour les attributs des sous-classes

@Entity
@Table(name = "vehicule")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "type_vehicule", discriminatorType = DiscriminatorType.STRING)
public abstract class Vehicule {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String marque;
    private int annee;
}

@Entity
@DiscriminatorValue("VOITURE")
public class Voiture extends Vehicule {
    private int nombrePortes;
}

@Entity
@DiscriminatorValue("MOTO")
public class Moto extends Vehicule {
    private boolean sidecar;
}

// ── Stratégie 2 : JOINED (une table par classe, jointure pour reconstruire) ──
//  Schéma normalisé
//  Jointures à chaque requête

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
public abstract class Animal {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String nom;
}

// ── Stratégie 3 : TABLE_PER_CLASS (une table complète par sous-classe) ────────
//  Redondance des colonnes communes
//  Requêtes polymorphiques coûteuses (UNION)

3.4. TP 2 — Créer les entités du projet restaurant

Créez les entités suivantes pour le restaurant d’entreprise :

Modèle de données :

PLAT                    MENU
──────────              ──────────────
id (PK)                 id (PK)
nom VARCHAR             nom VARCHAR
description TEXT        date_menu DATE
prix DECIMAL(8,2)       tarif DECIMAL(8,2)
categorie VARCHAR       actif BOOLEAN
calories INTEGER        type_repas VARCHAR
vegetarien BOOLEAN

Mission :

  1. Créez l’entité Plat avec tous les champs, les annotations correctes.
  2. Créez un enum CategoriePlat : ENTREE, PLAT_PRINCIPAL, DESSERT, BOISSON.
  3. Ajoutez @Enumerated(EnumType.STRING) sur le champ categorie.
  4. Ajoutez le persistence.xml et vérifiez avec hbm2ddl.auto=create que les tables sont créées.
  5. Insérez 3 plats via un main() et vérifiez en base POSTGRES.

4. Le cycle de vie des entités et l’EntityManager

4.1. Les quatre états d’une entité

Comprendre le cycle de vie est fondamental pour éviter les bugs classiques avec Hibernate :

                    new Entite()
                         │
                         ▼
               ┌─────────────────┐
               │   TRANSIENT     │  L'objet existe en mémoire
               │  (nouveau)      │  mais Hibernate ne le connaît pas
               └────────┬────────┘
                        │ em.persist(e)
                        ▼
               ┌─────────────────┐
               │    MANAGED      │  Hibernate surveille l'objet
    ◀──────────│   (géré)        │  Tout changement → UPDATE automatique
    │          │                 │  à la fin de la transaction
    │          └────────┬────────┘
    │                   │ em.detach(e)  /  em.close()
    │                   ▼              /  fin de session
    │          ┌─────────────────┐
    │          │   DETACHED      │  Existe en mémoire, pas géré par Hibernate
    │          │   (détaché)     │  Les modifications ne sont PAS suivies
    │          └────────┬────────┘
    │ em.merge(e)       │
    └───────────────────┘
                        │ em.remove(e)
                        ▼
               ┌─────────────────┐
               │    REMOVED      │  Sera supprimé au prochain commit
               │   (supprimé)    │
               └─────────────────┘

4.2. Les opérations CRUD de l’EntityManager

// src/main/java/fr/formation/dao/ClientDao.java
package fr.formation.dao;

import fr.formation.model.Client;
import fr.formation.util.HibernateUtil;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.TypedQuery;
import java.util.List;
import java.util.Optional;

public class ClientDao {

    private final EntityManagerFactory emf =
        HibernateUtil.getEntityManagerFactory();

    // ── CREATE — persist() ────────────────────────────────────────────────────
    public Client sauvegarder(Client client) {
        EntityManager em = emf.createEntityManager();
        try {
            em.getTransaction().begin();
            em.persist(client);       // INSERT INTO client ...
            em.getTransaction().commit();
            return client;            // client.getId() est maintenant renseigné
        } catch (Exception e) {
            if (em.getTransaction().isActive()) {
                em.getTransaction().rollback();
            }
            throw new RuntimeException("Erreur sauvegarde client", e);
        } finally {
            em.close();
        }
    }

    // ── READ — find() ─────────────────────────────────────────────────────────
    public Optional<Client> trouverParId(Long id) {
        EntityManager em = emf.createEntityManager();
        try {
            // find() retourne null si non trouvé (pas d'exception)
            Client client = em.find(Client.class, id);
            return Optional.ofNullable(client);
        } finally {
            em.close();
        }
    }

    // ── READ ALL — JPQL ───────────────────────────────────────────────────────
    public List<Client> trouverTous() {
        EntityManager em = emf.createEntityManager();
        try {
            // JPQL utilise les noms de CLASSES Java (pas les tables SQL)
            TypedQuery<Client> query = em.createQuery(
                "SELECT c FROM Client c ORDER BY c.nom", Client.class);
            return query.getResultList();
        } finally {
            em.close();
        }
    }

    // ── UPDATE — merge() ──────────────────────────────────────────────────────
    public Client mettreAJour(Client client) {
        EntityManager em = emf.createEntityManager();
        try {
            em.getTransaction().begin();
            // merge() retourne l'instance gérée (différente de l'argument !)
            Client clientGere = em.merge(client);
            em.getTransaction().commit();
            return clientGere;
        } catch (Exception e) {
            if (em.getTransaction().isActive()) {
                em.getTransaction().rollback();
            }
            throw new RuntimeException("Erreur mise à jour client", e);
        } finally {
            em.close();
        }
    }

    // ── DELETE — remove() ─────────────────────────────────────────────────────
    public boolean supprimer(Long id) {
        EntityManager em = emf.createEntityManager();
        try {
            em.getTransaction().begin();
            Client client = em.find(Client.class, id);
            if (client == null) return false;
            em.remove(client);        // DELETE FROM client WHERE id = ?
            em.getTransaction().commit();
            return true;
        } catch (Exception e) {
            if (em.getTransaction().isActive()) {
                em.getTransaction().rollback();
            }
            throw new RuntimeException("Erreur suppression client", e);
        } finally {
            em.close();
        }
    }

    // ── RECHERCHE PAR CHAMP ───────────────────────────────────────────────────
    public Optional<Client> trouverParEmail(String email) {
        EntityManager em = emf.createEntityManager();
        try {
            TypedQuery<Client> query = em.createQuery(
                "SELECT c FROM Client c WHERE c.email = :email",
                Client.class);
            query.setParameter("email", email);
            List<Client> resultats = query.getResultList();
            return resultats.isEmpty() ?
                Optional.empty() : Optional.of(resultats.get(0));
        } finally {
            em.close();
        }
    }
}

4.3. Le dirty checking — La magie de Hibernate

Le dirty checking est l’une des fonctionnalités les plus importantes d’Hibernate. Une entité en état MANAGED est automatiquement synchronisée avec la base au commit — sans appel explicite à persist() save ou update().

EntityManager em = emf.createEntityManager();
em.getTransaction().begin();

// Charger l'entité — elle est maintenant en état MANAGED
Client client = em.find(Client.class, 1L);
System.out.println(client.getNom()); // "Alicia"

// Modifier l'entité — PAS de em.update() ou em.persist() ou em.saved() nécessaire !
client.setNom("Alicia Martini");
client.setEmail("alicia.martini@ca.fr");

// au commit, Hibernate détecte les changements et génère un UPDATE
em.getTransaction().commit();
// SQL généré : UPDATE client SET nom='Alicia Martini', email='alicia.martini@...' WHERE id=1
em.close();

Le dirty checking fonctionne parce qu’Hibernate conserve un snapshot (une copie) de l’état initial de l’entité au moment du chargement. À la fin de la transaction, il compare l’état actuel avec le snapshot et génère les UPDATE nécessaires.

4.4. La gestion du contexte de persistance

//  PIÈGE CLASSIQUE : les entités DETACHED

// Session 1
EntityManager em1 = emf.createEntityManager();
em1.getTransaction().begin();
Client client = em1.find(Client.class, 1L);  // MANAGED
em1.getTransaction().commit();
em1.close();  // client devient DETACHED !

// Plus loin dans le code...
client.setNom("Boby");  // la modification n'est PAS suivie !

// Session 2
EntityManager em2 = emf.createEntityManager();
em2.getTransaction().begin();
// Pour rattacher le client détaché :
Client clientGere = em2.merge(client);  // il faut un merge() !
em2.getTransaction().commit();
em2.close();

4.5. Pattern DAO — Bonne pratique d’organisation

Le pattern DAO (Data Access Object) que vous connaissez, centralise tout l’accès aux données :

Couche Service      Couche DAO         Base de données
────────────        ──────────         ───────────────
ClientService   →   ClientDao      →   table client
CommandeService →   CommandeDao    →   table commande
ProduitService  →   ProduitDao     →   table produit
// src/main/java/fr/formation/dao/AbstractDao.java
package fr.formation.dao;

import fr.formation.util.HibernateUtil;
import jakarta.persistence.*;
import java.util.List;
import java.util.Optional;

/**
 * DAO générique — factoriser les opérations CRUD communes.
 * @param <T>  Type de l'entité
 * @param <ID> Type de l'identifiant
 */
public abstract class AbstractDao<T, ID> {

    private final Class<T>          entityClass;
    protected final EntityManagerFactory emf;

    protected AbstractDao(Class<T> entityClass) {
        this.entityClass = entityClass;
        this.emf         = HibernateUtil.getEntityManagerFactory();
    }

    public T sauvegarder(T entity) {
        EntityManager em = emf.createEntityManager();
        try {
            em.getTransaction().begin();
            em.persist(entity);
            em.getTransaction().commit();
            return entity;
        } catch (Exception e) {
            rollback(em);
            throw new RuntimeException("Erreur sauvegarde", e);
        } finally {
            em.close();
        }
    }

    public Optional<T> trouverParId(ID id) {
        EntityManager em = emf.createEntityManager();
        try {
            return Optional.ofNullable(em.find(entityClass, id));
        } finally {
            em.close();
        }
    }

    public List<T> trouverTous() {
        EntityManager em = emf.createEntityManager();
        try {
            return em.createQuery(
                "SELECT e FROM " + entityClass.getSimpleName() + " e",
                entityClass
            ).getResultList();
        } finally {
            em.close();
        }
    }

    public T mettreAJour(T entity) {
        EntityManager em = emf.createEntityManager();
        try {
            em.getTransaction().begin();
            T result = em.merge(entity);
            em.getTransaction().commit();
            return result;
        } catch (Exception e) {
            rollback(em);
            throw new RuntimeException("Erreur mise à jour", e);
        } finally {
            em.close();
        }
    }

    public boolean supprimer(ID id) {
        EntityManager em = emf.createEntityManager();
        try {
            em.getTransaction().begin();
            T entity = em.find(entityClass, id);
            if (entity == null) { em.getTransaction().rollback(); return false; }
            em.remove(entity);
            em.getTransaction().commit();
            return true;
        } catch (Exception e) {
            rollback(em);
            throw new RuntimeException("Erreur suppression", e);
        } finally {
            em.close();
        }
    }

    public long compter() {
        EntityManager em = emf.createEntityManager();
        try {
            return em.createQuery(
                "SELECT COUNT(e) FROM " + entityClass.getSimpleName() + " e",
                Long.class
            ).getSingleResult();
        } finally {
            em.close();
        }
    }

    protected void rollback(EntityManager em) {
        if (em.getTransaction().isActive()) {
            em.getTransaction().rollback();
        }
    }
}

5. Les associations entre entités

5.1. Vue d’ensemble des associations

Les associations JPA correspondent aux relations entre tables SQL :

JPA SQL Exemple
@ManyToOne Clé étrangère Commande → Client
@OneToMany Inverse de @ManyToOne Client → Commandes
@OneToOne Clé étrangère unique Client ↔ Adresse
@ManyToMany Table de jointure Commande ↔ Produit

5.2. @ManyToOne — Le côté propriétaire

// src/main/java/fr/formation/model/Commande.java
package fr.formation.model;

import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Entity
@Table(name = "commande")
public class Commande {

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

    // @ManyToOne — plusieurs commandes pour un même client
    // C'est le côté PROPRIÉTAIRE (celui qui a la clé étrangère en base)
    @ManyToOne(fetch = FetchType.LAZY)    // ← LAZY = chargé à la demande
    @JoinColumn(name = "client_id",       // ← Nom de la colonne FK en base
                nullable = false)
    private Client client;

    @Column(name = "date_commande", nullable = false)
    private LocalDateTime dateCommande = LocalDateTime.now();

    @Column(name = "montant_total", precision = 10, scale = 2)
    private BigDecimal montantTotal = BigDecimal.ZERO;

    @Enumerated(EnumType.STRING)
    @Column(name = "statut", length = 20)
    private StatutCommande statut = StatutCommande.EN_COURS;

    // Constructeurs
    protected Commande() {}

    public Commande(Client client) {
        this.client = client;
    }

    // Getters / Setters
    public Long getId()               { return id; }
    public Client getClient()         { return client; }
    public void   setClient(Client c) { this.client = c; }
    public LocalDateTime getDateCommande()    { return dateCommande; }
    public BigDecimal getMontantTotal()       { return montantTotal; }
    public void setMontantTotal(BigDecimal m) { this.montantTotal = m; }
    public StatutCommande getStatut()         { return statut; }
    public void setStatut(StatutCommande s)   { this.statut = s; }
}

5.3. @OneToMany — Le côté inverse

// Ajouter dans Client.java
@Entity
@Table(name = "client")
public class Client {

    // ... champs existants ...

    // @OneToMany — un client a plusieurs commandes
    // mappedBy = nom du champ @ManyToOne côté Commande
    // C'est le côté INVERSE (pas de colonne en base côté Client)
    @OneToMany(
        mappedBy  = "client",
        cascade   = CascadeType.ALL,  // Les opérations se propagent aux commandes
        orphanRemoval = true,          // Supprime les commandes orphelines
        fetch     = FetchType.LAZY    // TOUJOURS LAZY pour les collections !
    )
    private List<Commande> commandes = new ArrayList<>();

    // ── MÉTHODES HELPER — maintenir la cohérence bidirectionnelle ──────────────
    //  IMPORTANT : toujours utiliser des méthodes helper pour les associations
    public void ajouterCommande(Commande commande) {
        commandes.add(commande);
        commande.setClient(this);   // Côté propriétaire doit être mis à jour !
    }

    public void retirerCommande(Commande commande) {
        commandes.remove(commande);
        commande.setClient(null);
    }

    public List<Commande> getCommandes() {
        return Collections.unmodifiableList(commandes);
    }
}

Règle d’or des associations bidirectionnelles : c’est TOUJOURS le côté @ManyToOne (propriétaire) qui contrôle la relation en base. Si vous ne mettez pas à jour le côté propriétaire, la clé étrangère ne sera pas enregistrée.

5.4. FetchType — LAZY & EAGER

// FetchType.LAZY — chargé SEULEMENT quand on y accède (recommandé)
@ManyToOne(fetch = FetchType.LAZY)
private Client client;
// SQL : "SELECT * FROM commande WHERE id = ?"
// Le client n'est PAS chargé immédiatement

// FetchType.EAGER — chargé IMMÉDIATEMENT avec l'entité principale
@ManyToOne(fetch = FetchType.EAGER)
private Client client;
// SQL : "SELECT c.*, cl.* FROM commande c JOIN client cl ON c.client_id = cl.id"

// PIÈGE : accès à un attribut LAZY après fermeture de la session
EntityManager em = emf.createEntityManager();
Commande commande = em.find(Commande.class, 1L);
em.close();  // Session fermée !

// ERREUR : LazyInitializationException !
// commande.getClient().getNom();  ← Le proxy ne peut plus charger

// Solution : charger dans la même session ou utiliser JOIN FETCH

5.5. CascadeType — Propagation des opérations

// CascadeType.PERSIST — persist() se propage aux entités associées
// CascadeType.MERGE   — merge() se propage
// CascadeType.REMOVE  — remove() se propage (ATTENTION !)
// CascadeType.REFRESH — refresh() se propage
// CascadeType.DETACH  — detach() se propage
// CascadeType.ALL     — tout se propage

// Exemple typique : un Client "possède" ses commandes
@OneToMany(mappedBy = "client", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Commande> commandes;

// Utilisation :
Client client = new Client("Alice", "alice@exemple.fr");
Commande cmd1 = new Commande();
Commande cmd2 = new Commande();

client.ajouterCommande(cmd1);
client.ajouterCommande(cmd2);

em.persist(client);  // Persiste aussi cmd1 et cmd2 grâce à CascadeType.ALL !

5.6. @ManyToMany — Table de jointure

// Exemple : Commande ↔ Produit (une commande contient plusieurs produits,
//           un produit peut être dans plusieurs commandes)

@Entity
@Table(name = "produit")
public class Produit {

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

    private String nom;
    private BigDecimal prix;

    // Côté inverse de la relation ManyToMany
    @ManyToMany(mappedBy = "produits")
    private Set<Commande> commandes = new HashSet<>();
}

@Entity
@Table(name = "commande")
public class Commande {

    // ... autres champs ...

    // @ManyToMany — côté propriétaire
    @ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
    @JoinTable(
        name           = "commande_produit",     // Table de jointure
        joinColumns    = @JoinColumn(name = "commande_id"),
        inverseJoinColumns = @JoinColumn(name = "produit_id")
    )
    private Set<Produit> produits = new HashSet<>();

    // Méthodes helper
    public void ajouterProduit(Produit produit) {
        produits.add(produit);
        produit.getCommandes().add(this);
    }

    public Set<Produit> getProduits() { return produits; }
}

5.7. @OneToOne — Relation un à un

@Entity
@Table(name = "adresse")
public class Adresse {

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

    private String rue;
    private String ville;
    private String codePostal;

    // Côté inverse
    @OneToOne(mappedBy = "adresse")
    private Client client;
}

@Entity
@Table(name = "client")
public class Client {

    // ... autres champs ...

    // @OneToOne — côté propriétaire
    @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "adresse_id", unique = true)
    private Adresse adresse;
}

5.8. TP 3 — Associations : Menu et Plats

Complétez le modèle du restaurant :

PLAT         MENU          MENU_PLAT (jointure)
────         ────          ────────────────────
id           id            menu_id (FK)
nom          nom           plat_id (FK)
prix         date_menu     quantite
categorie    tarif
             actif

Missions :

  1. Créez l’entité Menu avec @ManyToMany vers Plat.
  2. Ajoutez la table de jointure menu_plat avec @JoinTable.
  3. Si vous avez besoin d’un attribut sur la relation (ex: quantite), créez une entité intermédiaire MenuPlat avec @ManyToOne vers Menu et @ManyToOne vers Plat.
  4. Créez MenuDao et PlatDao héritant de AbstractDao.
  5. Testez : créer un menu du lundi, lui ajouter 3 plats, le persister, le recharger et vérifier que les plats sont bien présents.

6. JPQL et Criteria API — Requêtes

6.1. Introduction à JPQL

JPQL (Jakarta Persistence Query Language) est similaire à SQL mais travaille sur les entités Java et non les tables SQL :

SQL   : SELECT * FROM client WHERE nom = 'Alicia'
JPQL  : SELECT c FROM Client c WHERE c.nom = 'Alicia'
           └──   Nom de CLASSE Java      └── Propriété Java
EntityManager em = emf.createEntityManager();

// ── REQUÊTE SIMPLE ────────────────────────────────────────────────────────────
List<Client> clients = em.createQuery(
    "SELECT c FROM Client c", Client.class)
    .getResultList();

// ── AVEC CONDITION ────────────────────────────────────────────────────────────
List<Client> actifs = em.createQuery(
    "SELECT c FROM Client c WHERE c.actif = true ORDER BY c.nom",
    Client.class)
    .getResultList();

// ── PARAMÈTRES NOMMÉS (recommandé) ───────────────────────────────────────────
TypedQuery<Client> query = em.createQuery(
    "SELECT c FROM Client c WHERE c.email = :email",
    Client.class);
query.setParameter("email", "alicia@exemple.fr");
Optional<Client> client = query.getResultList().stream().findFirst();

// ── PARAMÈTRES POSITIONNELS (déconseillé, utiliser les paramètres nommés) ──────
TypedQuery<Client> query2 = em.createQuery(
    "SELECT c FROM Client c WHERE c.nom = ?1 AND c.prenom = ?2",
    Client.class);
query2.setParameter(1, "Martini");
query2.setParameter(2, "Alicia");

// ── RÉSULTAT UNIQUE ───────────────────────────────────────────────────────────
Long count = em.createQuery(
    "SELECT COUNT(c) FROM Client c WHERE c.actif = true",
    Long.class)
    .getSingleResult();

// ── JOINTURES EN JPQL ────────────────────────────────────────────────────────
// Jointure avec chargement (JOIN FETCH évite le LazyInitializationException)
List<Client> clientsAvecCommandes = em.createQuery(
    "SELECT DISTINCT c FROM Client c " +
    "LEFT JOIN FETCH c.commandes " +
    "WHERE c.actif = true",
    Client.class)
    .getResultList();

// ── PROJECTION — sélectionner certains champs ─────────────────────────────────
List<Object[]> resultats = em.createQuery(
    "SELECT c.nom, c.email, COUNT(cmd) " +
    "FROM Client c LEFT JOIN c.commandes cmd " +
    "GROUP BY c.nom, c.email " +
    "ORDER BY COUNT(cmd) DESC",
    Object[].class)
    .getResultList();

for (Object[] row : resultats) {
    System.out.printf("%-20s %-30s %d commandes%n",
        row[0], row[1], row[2]);
}

6.2. Named Queries — Requêtes nommées

// Déclarer des requêtes nommées sur l'entité
@Entity
@Table(name = "client")
@NamedQueries({
    @NamedQuery(
        name  = "Client.trouverTousActifs",
        query = "SELECT c FROM Client c WHERE c.actif = true ORDER BY c.nom"
    ),
    @NamedQuery(
        name  = "Client.trouverParEmail",
        query = "SELECT c FROM Client c WHERE c.email = :email"
    ),
    @NamedQuery(
        name  = "Client.compterActifs",
        query = "SELECT COUNT(c) FROM Client c WHERE c.actif = true"
    )
})
public class Client { /* ... */ }

// Utilisation
List<Client> actifs = em.createNamedQuery("Client.trouverTousActifs", Client.class)
    .getResultList();

Client client = em.createNamedQuery("Client.trouverParEmail", Client.class)
    .setParameter("email", "alice@exemple.fr")
    .getSingleResult();

6.3. Pagination

TypedQuery<Client> query = em.createQuery(
    "SELECT c FROM Client c ORDER BY c.nom", Client.class);

// Pagination
query.setFirstResult(0);    // Numéro du premier résultat (0-indexé)
query.setMaxResults(10);    // Nombre maximum de résultats

List<Client> page1 = query.getResultList();

// Page 2
query.setFirstResult(10);
query.setMaxResults(10);
List<Client> page2 = query.getResultList();

6.4. Mise à jour et suppression en masse

//  UPDATE en masse — sans charger les entités
int nbMis = em.createQuery(
    "UPDATE Client c SET c.actif = false WHERE c.soldeCompte < 0")
    .executeUpdate();

//  DELETE en masse
int nbSupprimes = em.createQuery(
    "DELETE FROM Client c WHERE c.actif = false AND c.commandes IS EMPTY")
    .executeUpdate();

6.5. Criteria API — Requêtes type-safe

La Criteria API permet de construire des requêtes de façon programmatique, sans chaînes de caractères :

// Même requête en SQL, JPQL et Criteria API :
// "Clients actifs dont le nom commence par 'M', triés par nom"

// SQL   : SELECT * FROM client WHERE actif = 1 AND nom LIKE 'M%' ORDER BY nom
// JPQL  : SELECT c FROM Client c WHERE c.actif = true AND c.nom LIKE 'M%' ORDER BY c.nom

//  Criteria API — type-safe (détection d'erreurs à la compilation)
CriteriaBuilder    cb       = em.getCriteriaBuilder();
CriteriaQuery<Client> cq   = cb.createQuery(Client.class);
Root<Client>       root    = cq.from(Client.class);

// WHERE c.actif = true AND c.nom LIKE 'M%'
Predicate actif   = cb.isTrue(root.get("actif"));
Predicate nomLike = cb.like(root.get("nom"), "M%");

cq.select(root)
  .where(cb.and(actif, nomLike))
  .orderBy(cb.asc(root.get("nom")));

List<Client> resultats = em.createQuery(cq).getResultList();
// Critères dynamiques — l'avantage principal de la Criteria API
public List<Plat> rechercherPlats(String nom, CategoriePlat categorie,
                                   Double prixMax, Boolean vegetarien) {
    CriteriaBuilder cb = em.getCriteriaBuilder();
    CriteriaQuery<Plat> cq = cb.createQuery(Plat.class);
    Root<Plat> root = cq.from(Plat.class);

    List<Predicate> predicats = new ArrayList<>();

    // Ajouter les filtres seulement si non null
    if (nom != null && !nom.isBlank()) {
        predicats.add(cb.like(cb.lower(root.get("nom")),
                              "%" + nom.toLowerCase() + "%"));
    }
    if (categorie != null) {
        predicats.add(cb.equal(root.get("categorie"), categorie));
    }
    if (prixMax != null) {
        predicats.add(cb.lessThanOrEqualTo(root.get("prix"), prixMax));
    }
    if (vegetarien != null) {
        predicats.add(cb.equal(root.get("vegetarien"), vegetarien));
    }

    cq.where(predicats.toArray(new Predicate[0]));
    cq.orderBy(cb.asc(root.get("nom")));

    return em.createQuery(cq).getResultList();
}

6.6. TP 4 — Requêtes JPQL pour le restaurant

Implémentez dans PlatDao et MenuDao les requêtes suivantes :

  1. trouverPlatsDuJour(LocalDate date) — plats disponibles dans un menu à une date donnée.
  2. trouverPlatsVegetariens() — plats végétariens triés par prix.
  3. calculerPrixMoyenParCategorie() — retourner une Map<CategoriePlat, Double>.
  4. trouverMenusParIntervalle(LocalDate debut, LocalDate fin) — menus sur une période.
  5. rechercherPlats(String nom, CategoriePlat cat, Double prixMax) — recherche multicritère avec Criteria API.
  6. Testez chaque méthode avec des données de test en H2.

7. Transactions et gestion des erreurs

7.1. Le principe ACID

Les transactions garantissent la cohérence des données selon les propriétés ACID :

Propriété Description Exemple
Atomicité Tout ou rien Virement : débit ET crédit, ou ni l’un ni l’autre
Cohérence Les règles métier sont respectées Solde ne peut pas être négatif
Isolation Les transactions ne se voient pas Deux virements simultanés restent corrects
Durabilité Validé = persisté même après crash COMMIT → données en base

7.2. Gestion propre des transactions

// src/main/java/fr/formation/util/TransactionUtil.java
package fr.formation.util;

import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.util.function.Consumer;
import java.util.function.Function;

/**
 * Utilitaire pour simplifier la gestion des transactions.
 * Utilise des lambdas pour éviter la répétition du try/catch.
 */
public class TransactionUtil {

    private final EntityManagerFactory emf;

    public TransactionUtil(EntityManagerFactory emf) {
        this.emf = emf;
    }

    /**
     * Exécute une action dans une transaction (sans résultat).
     */
    public void executerDansTransaction(Consumer<EntityManager> action) {
        EntityManager em = emf.createEntityManager();
        try {
            em.getTransaction().begin();
            action.accept(em);
            em.getTransaction().commit();
        } catch (Exception e) {
            if (em.getTransaction().isActive()) {
                em.getTransaction().rollback();
            }
            throw new RuntimeException("Erreur transaction", e);
        } finally {
            em.close();
        }
    }

    /**
     * Exécute une action dans une transaction et retourne un résultat.
     */
    public <R> R executerAvecResultat(Function<EntityManager, R> action) {
        EntityManager em = emf.createEntityManager();
        try {
            em.getTransaction().begin();
            R result = action.apply(em);
            em.getTransaction().commit();
            return result;
        } catch (Exception e) {
            if (em.getTransaction().isActive()) {
                em.getTransaction().rollback();
            }
            throw new RuntimeException("Erreur transaction", e);
        } finally {
            em.close();
        }
    }
}

// Utilisation avec lambda — code beaucoup plus propre !
TransactionUtil txUtil = new TransactionUtil(emf);

// Sauvegarder sans résultat
txUtil.executerDansTransaction(em -> {
    Client client = new Client("Alice", "Martin", "alice@exemple.fr");
    em.persist(client);
});

// Sauvegarder avec résultat
Client client = txUtil.executerAvecResultat(em -> {
    Client c = new Client("Bob", "Dupont", "bob@exemple.fr");
    em.persist(c);
    return c;
});

7.3. Les exceptions Hibernate

// Exceptions courantes et comment les gérer

// ConstraintViolationException — violation de contrainte (UNIQUE, NOT NULL...)
try {
    em.persist(new Client("Alice", "alice@exemple.fr")); // email déjà utilisé
    em.getTransaction().commit();
} catch (jakarta.persistence.PersistenceException e) {
    // Cause possible : ConstraintViolationException
    if (e.getCause() instanceof org.hibernate.exception.ConstraintViolationException) {
        System.err.println("Email déjà utilisé !");
    }
}

// EntityNotFoundException — entité introuvable
// find() retourne null (pas d'exception) — préférable
Client client = em.find(Client.class, 999L);  // null si non trouvé

// getReference() lève EntityNotFoundException si non trouvé quand accédé
Client ref = em.getReference(Client.class, 999L);
ref.getNom(); // ← EntityNotFoundException ici si 999 n'existe pas

// LazyInitializationException — accès LAZY hors session
// → Toujours accéder aux attributs LAZY DANS la même session

// OptimisticLockException — conflit de concurrence (@Version)
// → Réessayer l'opération

7.4. L’Optimistic Locking

@Entity
public class ProduitStock {

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

    private String nom;
    private int quantiteStock;

    //  @Version — protège contre les modifications concurrentes
    // Hibernate ajoute "AND version = ?" à chaque UPDATE
    @Version
    private Integer version;
}

// Scénario de conflit :
// Utilisateur A et B chargent le même produit (version = 1)
// Utilisateur A modifie et sauvegarde (version devient 2)
// Utilisateur B essaie de sauvegarder (sa version = 1, mais en base = 2)
// → OptimisticLockException ! On doit gérer le conflit.

try {
    em.getTransaction().begin();
    ProduitStock produit = em.find(ProduitStock.class, 1L);
    produit.setQuantiteStock(produit.getQuantiteStock() - 1);
    em.getTransaction().commit();
} catch (OptimisticLockException e) {
    em.getTransaction().rollback();
    System.err.println("Conflit de concurrence ! Rechargez et réessayez.");
}

8. Hibernate avec Spring Boot

8.1. Pourquoi Spring Boot simplifie Hibernate

Avec Spring Boot, la configuration d’Hibernate se réduit drastiquement :

Spring Boot configure automatiquement :

8.2. Créer un projet Spring Boot avec JPA

Via Spring Initializr (https://start.spring.io) :

Project     : Maven
Language    : Java
Spring Boot : 3.2.x
Group       : fr.formation
Artifact    : springboot-hibernate
Java        : 17
Dependencies:
  - Spring Data JPA     ← Hibernate + Spring Data
  - MySQL Driver        ← ou H2 Database pour les tests
  - Spring Web          ← Si vous faites des endpoints REST
  - Lombok              ← Pour réduire le code boilerplate

pom.xml Spring Boot :

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!--  Parent Spring Boot — gère toutes les versions de dépendances -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.3</version>
    </parent>

    <groupId>fr.formation</groupId>
    <artifactId>springboot-hibernate</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>

        <!--  Spring Data JPA = Spring + Hibernate + JPA -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <!--  Driver MySQL -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!--  Lombok — génère getters/setters/constructeurs -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!--  Tests -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <!--  H2 pour les tests -->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>test</scope>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

8.3. Configuration dans application.properties

# src/main/resources/application.properties

# ════════════════════════════════════════
# BASE DE DONNÉES
# ════════════════════════════════════════
spring.datasource.url=jdbc:mysql://localhost:3306/restaurant_sb\
  ?useSSL=false&serverTimezone=Europe/Paris&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=votre_mot_de_passe
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# Pool de connexions HikariCP (intégré à Spring Boot)
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=2
spring.datasource.hikari.connection-timeout=30000

# ════════════════════════════════════════
# JPA / HIBERNATE
# ════════════════════════════════════════
# Dialecte — Spring Boot 3 le détecte souvent automatiquement
spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect

# Stratégie DDL
spring.jpa.hibernate.ddl-auto=update

# Afficher le SQL (développement)
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# Statistiques Hibernate
spring.jpa.properties.hibernate.generate_statistics=false

# ════════════════════════════════════════
# LOGGING
# ════════════════════════════════════════
# Afficher les paramètres des requêtes SQL
logging.level.org.hibernate.orm.jdbc.bind=TRACE
logging.level.org.hibernate.SQL=DEBUG
logging.level.fr.formation=DEBUG
# src/test/resources/application.properties — Configuration pour les tests
spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MySQL
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true

8.4. Les entités avec Spring Boot et Lombok

Avec Lombok, les entités sont beaucoup plus concises :

// src/main/java/fr/formation/model/Plat.java
package fr.formation.model;

import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.util.HashSet;
import java.util.Set;

@Entity
@Table(name = "plat")
@Getter                   // Génère tous les getters
@Setter                   // Génère tous les setters
@NoArgsConstructor        // Génère le constructeur sans argument (requis JPA)
@AllArgsConstructor       // Génère le constructeur avec tous les arguments
@Builder                  // Génère le pattern Builder
@ToString(exclude = "menus")  // éviter les boucles infinies dans toString
@EqualsAndHashCode(of = "id") // equals/hashCode basés sur l'id seulement
public class Plat {

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

    @Column(nullable = false, length = 150)
    private String nom;

    @Column(length = 500)
    private String description;

    @Column(nullable = false, precision = 8, scale = 2)
    private BigDecimal prix;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 30)
    private CategoriePlat categorie;

    @Column
    private Integer calories;

    @Column(nullable = false)
    private boolean vegetarien = false;

    @Column(nullable = false)
    private boolean actif = true;

    @ManyToMany(mappedBy = "plats", fetch = FetchType.LAZY)
    private Set<Menu> menus = new HashSet<>();
}
// src/main/java/fr/formation/model/Menu.java
package fr.formation.model;

import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.Set;

@Entity
@Table(name = "menu")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
@Builder
@ToString(exclude = "plats")
@EqualsAndHashCode(of = "id")
public class Menu {

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

    @Column(nullable = false, length = 200)
    private String nom;

    @Column(name = "date_menu", nullable = false)
    private LocalDate dateMenu;

    @Enumerated(EnumType.STRING)
    @Column(name = "type_repas", length = 20)
    private TypeRepas typeRepas;

    @Column(precision = 6, scale = 2)
    private BigDecimal tarif;

    @Column(nullable = false)
    private boolean actif = true;

    @ManyToMany
    @JoinTable(
        name               = "menu_plat",
        joinColumns        = @JoinColumn(name = "menu_id"),
        inverseJoinColumns = @JoinColumn(name = "plat_id")
    )
    @Builder.Default
    private Set<Plat> plats = new HashSet<>();

    // Méthodes helper (Lombok ne les génère pas)
    public void ajouterPlat(Plat plat) {
        plats.add(plat);
        plat.getMenus().add(this);
    }

    public void retirerPlat(Plat plat) {
        plats.remove(plat);
        plat.getMenus().remove(this);
    }
}

9. Spring Data JPA — Repositories

9.1. L’interface JpaRepository

Spring Data JPA génère automatiquement les requêtes CRUD à partir de l’interface :

// src/main/java/fr/formation/repository/PlatRepository.java
package fr.formation.repository;

import fr.formation.model.Plat;
import fr.formation.model.CategoriePlat;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;

// Spring génère l'implémentation automatiquement !
// JpaRepository<T, ID> → T = type de l'entité, ID = type de la PK
@Repository
public interface PlatRepository extends JpaRepository<Plat, Long> {

    // ══════════════════════════════════════════════════════════════════════════
    // MÉTHODES DÉRIVÉES — Spring génère le SQL depuis le nom de la méthode !
    // ══════════════════════════════════════════════════════════════════════════

    // findBy<Champ>
    List<Plat> findByCategorie(CategoriePlat categorie);

    // findBy<Champ>And<Champ>
    List<Plat> findByCategorieAndActifTrue(CategoriePlat categorie);

    // findBy<Champ>OrderBy<Champ>
    List<Plat> findByActifTrueOrderByNomAsc();

    // findBy<Champ>LessThan
    List<Plat> findByPrixLessThanEqual(BigDecimal prixMax);

    // findBy<Champ>Containing — LIKE %mot%
    List<Plat> findByNomContainingIgnoreCase(String nom);

    // findBy<Champ>Between
    List<Plat> findByPrixBetween(BigDecimal prixMin, BigDecimal prixMax);

    // findBy<ChampBoolean>True
    List<Plat> findByVegetarienTrue();

    // Optional — retourne vide si non trouvé
    Optional<Plat> findByNomIgnoreCase(String nom);

    // Compter
    long countByActifTrue();
    long countByCategorie(CategoriePlat categorie);

    // Existence
    boolean existsByNomIgnoreCase(String nom);

    // Supprimer par critère (dans une transaction)
    void deleteByActifFalse();

}

Méthodes héritées de JpaRepository :

// Déjà disponibles sans configuration :
Plat sauvegarde  = platRepository.save(plat);         // INSERT ou UPDATE
Optional<Plat> p = platRepository.findById(1L);        // SELECT WHERE id = ?
List<Plat> tous  = platRepository.findAll();           // SELECT *
long nombre       = platRepository.count();             // SELECT COUNT(*)
boolean existe    = platRepository.existsById(1L);      // SELECT 1 WHERE id = ?
platRepository.deleteById(1L);                          // DELETE WHERE id = ?
platRepository.delete(plat);                            // DELETE WHERE id = ?
platRepository.deleteAll();                             // DELETE * (prudence !)
List<Plat> page  = platRepository.findAll(
    PageRequest.of(0, 10, Sort.by("nom")));             // Pagination

9.2. La couche Service avec @Transactional

// src/main/java/fr/formation/service/PlatService.java
package fr.formation.service;

import fr.formation.exception.PlatNotFoundException;
import fr.formation.model.CategoriePlat;
import fr.formation.model.Plat;
import fr.formation.repository.PlatRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;

@Service                    // Bean Spring de couche service
@Transactional              // Toutes les méthodes sont transactionnelles
@RequiredArgsConstructor    // Lombok : constructeur avec tous les champs final
@Slf4j                      // Lombok : log = LoggerFactory.getLogger(...)
public class PlatService {

    private final PlatRepository platRepository;

    // @Transactional héritée de la classe (lecture-écriture)
    public Plat creer(Plat plat) {
        log.info("Création du plat : {}", plat.getNom());
        if (platRepository.existsByNomIgnoreCase(plat.getNom())) {
            throw new IllegalArgumentException(
                "Un plat nommé '" + plat.getNom() + "' existe déjà.");
        }
        return platRepository.save(plat);
    }

    // @Transactional(readOnly = true) — optimisation pour les lectures
    @Transactional(readOnly = true)
    public List<Plat> trouverTousActifs() {
        return platRepository.findByActifTrueOrderByNomAsc();
    }

    @Transactional(readOnly = true)
    public Plat trouverParId(Long id) {
        return platRepository.findById(id)
            .orElseThrow(() ->
                new PlatNotFoundException("Plat introuvable : id=" + id));
    }

    @Transactional(readOnly = true)
    public List<Plat> rechercherParCategorie(CategoriePlat categorie) {
        return platRepository.findByCategorie(categorie);
    }

    @Transactional(readOnly = true)
    public List<Plat> rechercherVegetariens() {
        return platRepository.findByVegetarienTrue();
    }

    @Transactional(readOnly = true)
    public List<Plat> rechercherParNom(String nom) {
        return platRepository.findByNomContainingIgnoreCase(nom);
    }

    @Transactional(readOnly = true)
    public List<Plat> rechercherParBudget(BigDecimal prixMax) {
        return platRepository.findByPrixLessThanEqual(prixMax);
    }

    public Plat mettreAJour(Long id, Plat modifications) {
        Plat plat = trouverParId(id);
        plat.setNom(modifications.getNom());
        plat.setDescription(modifications.getDescription());
        plat.setPrix(modifications.getPrix());
        plat.setCalories(modifications.getCalories());
        plat.setVegetarien(modifications.isVegetarien());
        log.info("Mise à jour du plat id={}", id);
        return platRepository.save(plat);
    }

    public void desactiver(Long id) {
        Plat plat = trouverParId(id);
        plat.setActif(false);
        platRepository.save(plat);
        log.info("Plat id={} désactivé", id);
    }
}
// src/main/java/fr/formation/exception/PlatNotFoundException.java
package fr.formation.exception;

public class PlatNotFoundException extends RuntimeException {
    public PlatNotFoundException(String message) {
        super(message);
    }
}

9.3. Test du repository avec @DataJpaTest

// src/test/java/fr/formation/repository/PlatRepositoryTest.java
package fr.formation.repository;

import fr.formation.model.CategoriePlat;
import fr.formation.model.Plat;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.*;

// @DataJpaTest — configure H2 en mémoire, charge seulement la couche JPA
@DataJpaTest
@DisplayName("Tests du PlatRepository")
class PlatRepositoryTest {

    @Autowired
    private PlatRepository platRepository;

    @BeforeEach
    void setUp() {
        platRepository.deleteAll();
        platRepository.save(Plat.builder()
            .nom("Salade César").categorie(CategoriePlat.ENTREE)
            .prix(new BigDecimal("8.50")).vegetarien(true).actif(true).build());
        platRepository.save(Plat.builder()
            .nom("Bœuf bourguignon").categorie(CategoriePlat.PLAT_PRINCIPAL)
            .prix(new BigDecimal("14.90")).vegetarien(false).actif(true).build());
        platRepository.save(Plat.builder()
            .nom("Fondant chocolat").categorie(CategoriePlat.DESSERT)
            .prix(new BigDecimal("6.00")).vegetarien(true).actif(true).build());
        platRepository.save(Plat.builder()
            .nom("Soupe froide"). categorie(CategoriePlat.ENTREE)
            .prix(new BigDecimal("7.00")).vegetarien(true).actif(false).build());
    }

    @Test
    @DisplayName("findByActifTrueOrderByNomAsc — retourne les plats actifs triés")
    void findByActifTrue_retournePlatsActifsTriesParNom() {
        List<Plat> actifs = platRepository.findByActifTrueOrderByNomAsc();
        assertThat(actifs)
            .hasSize(3)
            .extracting(Plat::getNom)
            .containsExactly("Bœuf bourguignon", "Fondant chocolat", "Salade César");
    }

    @Test
    @DisplayName("findByCategorie — filtre correctement")
    void findByCategorie_entree_retourneSeulementEntrees() {
        List<Plat> entrees = platRepository.findByCategorie(CategoriePlat.ENTREE);
        assertThat(entrees).hasSize(2);
        assertThat(entrees).allMatch(p -> p.getCategorie() == CategoriePlat.ENTREE);
    }

    @Test
    @DisplayName("findByVegetarienTrue — retourne les plats végétariens")
    void findByVegetarienTrue_retourneSeulementVegetariens() {
        List<Plat> veget = platRepository.findByVegetarienTrue();
        assertThat(veget)
            .hasSize(3)
            .allMatch(Plat::isVegetarien);
    }

    @Test
    @DisplayName("findByPrixLessThanEqual — filtre par prix maximum")
    void findByPrixLessThanEqual_prixMax10_retourneSeulementPlatsMoinsCher() {
        List<Plat> abordables = platRepository.findByPrixLessThanEqual(
            new BigDecimal("10.00"));
        assertThat(abordables).hasSize(3);
        assertThat(abordables).allMatch(p -> p.getPrix().compareTo(new BigDecimal("10.00")) <= 0);
    }

    @Test
    @DisplayName("existsByNomIgnoreCase — détecte les doublons")
    void existsByNomIgnoreCase_nomExistant_retourneTrue() {
        assertThat(platRepository.existsByNomIgnoreCase("salade césar")).isTrue();
        assertThat(platRepository.existsByNomIgnoreCase("Pizza jambon")).isFalse();
    }

    @Test
    @DisplayName("countByActifTrue — compte les plats actifs")
    void countByActifTrue_retourne3() {
        assertThat(platRepository.countByActifTrue()).isEqualTo(3L);
    }
}

10. Requêtes avancées avec Spring Data

10.1. @Query — Requêtes JPQL personnalisées

// Dans PlatRepository
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

@Repository
public interface PlatRepository extends JpaRepository<Plat, Long> {

    // ── JPQL PERSONNALISÉ ─────────────────────────────────────────────────────

    // Requête simple avec @Query
    @Query("SELECT p FROM Plat p WHERE p.actif = true AND p.prix <= :prixMax " +
           "ORDER BY p.prix ASC")
    List<Plat> trouverPlatsAbordables(@Param("prixMax") BigDecimal prixMax);

    // JOIN FETCH — évite le problème N+1
    @Query("SELECT DISTINCT m FROM Menu m " +
           "LEFT JOIN FETCH m.plats " +
           "WHERE m.actif = true AND m.dateMenu = :date")
    List<Menu> trouverMenusDuJourAvecPlats(@Param("date") LocalDate date);

    // Projection — retourner seulement certains champs
    @Query("SELECT p.categorie, COUNT(p), AVG(p.prix) " +
           "FROM Plat p WHERE p.actif = true " +
           "GROUP BY p.categorie ORDER BY p.categorie")
    List<Object[]> statistiquesParCategorie();

    // ── SQL NATIF ─────────────────────────────────────────────────────────────
    // nativeQuery = true → SQL pur (moins portable mais parfois nécessaire)
    @Query(value = "SELECT * FROM plat WHERE MATCH(nom, description) " +
                   "AGAINST (:terme IN BOOLEAN MODE)",
           nativeQuery = true)
    List<Plat> rechercheFullText(@Param("terme") String terme);

    // ── MISE À JOUR AVEC @Modifying ───────────────────────────────────────────
    @Modifying                    // ← Obligatoire pour UPDATE/DELETE
    @Transactional                // ← Méthode transactionnelle
    @Query("UPDATE Plat p SET p.actif = false WHERE p.categorie = :cat")
    int desactiverParCategorie(@Param("cat") CategoriePlat cat);

}

10.2. Interface de Projection

// Projection — récupérer seulement certains champs (plus performant)
public interface PlatResume {
    Long   getId();
    String getNom();
    BigDecimal getPrix();
    boolean isVegetarien();

    // Valeur calculée
    @Value("#{target.nom + ' (' + target.prix + ' €)'}")
    String getLibelle();
}

// Dans le repository
List<PlatResume> findByCategorie(CategoriePlat categorie,
                                  Class<PlatResume> type);
// Utilisation
List<PlatResume> resumes = platRepository.findByCategorie(
    CategoriePlat.ENTREE, PlatResume.class);

10.3. Pagination et tri avec Spring Data

// Dans le Service
@Transactional(readOnly = true)
public Page<Plat> trouverAvecPagination(int page, int taille, String triPar) {
    Pageable pageable = PageRequest.of(
        page,
        taille,
        Sort.by(Sort.Direction.ASC, triPar)
    );
    return platRepository.findAll(pageable);
}

// Utilisation
Page<Plat> page1 = platService.trouverAvecPagination(0, 10, "nom");
System.out.println("Total plats    : " + page1.getTotalElements());
System.out.println("Nombre de pages: " + page1.getTotalPages());
System.out.println("Page courante  : " + page1.getNumber());
page1.getContent().forEach(System.out::println);

10.4. Specification — Critères dynamiques avec Spring Data

// src/main/java/fr/formation/specification/PlatSpecification.java
package fr.formation.specification;

import fr.formation.model.CategoriePlat;
import fr.formation.model.Plat;
import org.springframework.data.jpa.domain.Specification;
import java.math.BigDecimal;

// Factory de Specifications — comme des filtres composables
public class PlatSpecification {

    private PlatSpecification() {}

    public static Specification<Plat> estActif() {
        return (root, query, cb) ->
            cb.isTrue(root.get("actif"));
    }

    public static Specification<Plat> avecCategorie(CategoriePlat cat) {
        return (root, query, cb) ->
            cat == null ? cb.conjunction() :
            cb.equal(root.get("categorie"), cat);
    }

    public static Specification<Plat> prixMaximum(BigDecimal prix) {
        return (root, query, cb) ->
            prix == null ? cb.conjunction() :
            cb.lessThanOrEqualTo(root.get("prix"), prix);
    }

    public static Specification<Plat> estVegetarien() {
        return (root, query, cb) ->
            cb.isTrue(root.get("vegetarien"));
    }

    public static Specification<Plat> nomContient(String terme) {
        return (root, query, cb) ->
            terme == null || terme.isBlank() ? cb.conjunction() :
            cb.like(cb.lower(root.get("nom")), "%" + terme.toLowerCase() + "%");
    }
}
// Repository doit étendre JpaSpecificationExecutor
@Repository
public interface PlatRepository extends JpaRepository<Plat, Long>,
                                         JpaSpecificationExecutor<Plat> {
    // ...
}

// Utilisation dans le Service — combinaison dynamique de critères
@Transactional(readOnly = true)
public List<Plat> rechercherAvancee(String nom, CategoriePlat cat,
                                    BigDecimal prixMax, Boolean vegetarienSeulement) {
    Specification<Plat> spec = Specification.where(PlatSpecification.estActif())
        .and(PlatSpecification.nomContient(nom))
        .and(PlatSpecification.avecCategorie(cat))
        .and(PlatSpecification.prixMaximum(prixMax));

    if (Boolean.TRUE.equals(vegetarienSeulement)) {
        spec = spec.and(PlatSpecification.estVegetarien());
    }

    return platRepository.findAll(spec, Sort.by("nom"));
}

10.5. TP 5 — Couche repository complète

Implémentez pour le projet restaurant :

  1. PlatRepository avec toutes les méthodes dérivées et @Query nécessaires.
  2. MenuRepository avec :
    • findByDateMenuBetween(LocalDate debut, LocalDate fin)
    • findByDateMenuAndTypeRepas(LocalDate date, TypeRepas type)
    • @Query pour charger un menu avec ses plats en JOIN FETCH
  3. MenuService et PlatService avec toutes les opérations métier.
  4. Tests @DataJpaTest pour chaque repository.
  5. Tests @SpringBootTest pour les services.

11. Optimisation et bonnes pratiques

11.1. Le problème N+1 et comment l’éviter

Le problème N+1 est le piège de performance le plus courant avec Hibernate :

//  PROBLÈME N+1 : 1 requête pour les menus + N requêtes pour les plats
List<Menu> menus = menuRepository.findAll();
for (Menu menu : menus) {
    // Chaque accès aux plats déclenche une nouvelle requête SQL !
    System.out.println(menu.getPlats().size()); // SELECT * FROM plat WHERE menu_id = ?
}
// Total : 1 + N requêtes (si 100 menus → 101 requêtes !)

//  SOLUTION 1 : JOIN FETCH dans @Query
@Query("SELECT DISTINCT m FROM Menu m LEFT JOIN FETCH m.plats WHERE m.actif = true")
List<Menu> findAllAvecPlats();
// Total : 1 seule requête avec jointure

//  SOLUTION 2 : @EntityGraph — plus déclaratif
@EntityGraph(attributePaths = {"plats"})
List<Menu> findByActifTrue();

//  SOLUTION 3 : @BatchSize — charge par lots
@BatchSize(size = 30)  // Charge 30 collections en 1 requête IN (...)
@ManyToMany(fetch = FetchType.LAZY)
private Set<Plat> plats;

11.2. Le cache Hibernate

// Niveau 1 : Cache de session (automatique, par défaut)
// Une entité chargée dans la même session n'est pas rechargée

EntityManager em = emf.createEntityManager();
Plat p1 = em.find(Plat.class, 1L);  // SELECT
Plat p2 = em.find(Plat.class, 1L);  // Pas de SQL ! Vient du cache L1
System.out.println(p1 == p2);        // true — même instance !
em.close();

// Niveau 2 : Cache de second niveau (à configurer)
// Partagé entre toutes les sessions — réduire les accès BDD
// Nécessite EHCache ou Caffeine

// Dans pom.xml :
// <dependency>
//   <groupId>org.hibernate.orm</groupId>
//   <artifactId>hibernate-jcache</artifactId>
// </dependency>
// <dependency>
//   <groupId>org.ehcache</groupId>
//   <artifactId>ehcache</artifactId>
// </dependency>

@Entity
@Cacheable                             // ← Activer le cache L2 pour cette entité
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Plat { /* ... */ }

11.3. Bonnes pratiques — Résumé

✅ À FAIRE

Entités :
  ✅ Toujours un constructeur sans argument (requis JPA)
  ✅ equals() et hashCode() basés sur l'ID (ou une clé naturelle stable)
  ✅ FetchType.LAZY sur toutes les associations (par défaut pour @One/ManyToMany)
  ✅ @Version pour l'optimistic locking sur les entités modifiées concurrentiellement
  ✅ Éviter les types primitifs (int → Integer) pour pouvoir stocker NULL

Requêtes :
  ✅ JOIN FETCH pour éviter le N+1
  ✅ @Transactional(readOnly=true) sur les méthodes de lecture
  ✅ Paginer les grandes listes
  ✅ Utiliser des projections (interface) quand vous n'avez pas besoin de l'entité entière

Transactions :
  ✅ @Transactional au niveau de la couche Service (pas Repository, pas Controller)
  ✅ Propager les exceptions runtime pour déclencher le rollback automatique
  ✅ Délimiter les transactions au plus court

Performance :
  ✅ Analyser le SQL généré avec show_sql=true en développement
  ✅ Indexer les colonnes de jointure et de recherche fréquente
  ✅ @BatchSize ou JOIN FETCH pour les collections

❌ À ÉVITER

  ❌ FetchType.EAGER sur les collections (problème N+1 garanti)
  ❌ CascadeType.REMOVE sur les relations @ManyToMany
  ❌ Modifier des entités dans le Controller (violation de couches)
  ❌ Exposer les entités directement dans l'API (utiliser des DTOs)
  ❌ hbm2ddl.auto=create ou drop-create en production
  ❌ Oublier de fermer les EntityManager (sans Spring Boot)

12. TP Final — Menus du restaurant d’entreprise

12.1. Présentation du projet

Vous allez construire RestaurantApp : une application de gestion des menus du restaurant d’entreprise. Ce projet existe en deux versions :

Les 2 versions partagent le même modèle de données et la même logique métier, mais diffèrent dans leur configuration et leur architecture.

12.2. Modèle de données complet

PLAT                    CATEGORIE_PLAT (enum)
────────────────        ─────────────────────
id (PK, AUTO)           ENTREE
nom VARCHAR(150)        PLAT_PRINCIPAL
description TEXT        DESSERT
prix DECIMAL(8,2)       BOISSON
categorie VARCHAR(30)   ACCOMPAGNEMENT
calories INTEGER
vegetarien BOOLEAN
actif BOOLEAN

MENU                    TYPE_REPAS (enum)
────────────────        ─────────────────
id (PK, AUTO)           DEJEUNER
nom VARCHAR(200)        DINER
date_menu DATE          COLLATION
type_repas VARCHAR(20)
tarif DECIMAL(6,2)
actif BOOLEAN

MENU_PLAT (table de jointure)
──────────────────────────────
menu_id (FK → MENU.id)
plat_id (FK → PLAT.id)

12.3. Architecture du projet

restaurant-app/               ← Version A : Hibernate sans Spring
├── pom.xml
└── src/
    ├── main/java/fr/formation/restaurant/
    │   ├── model/
    │   │   ├── Plat.java
    │   │   ├── Menu.java
    │   │   ├── CategoriePlat.java (enum)
    │   │   └── TypeRepas.java (enum)
    │   ├── dao/
    │   │   ├── AbstractDao.java
    │   │   ├── PlatDao.java
    │   │   └── MenuDao.java
    │   ├── service/
    │   │   ├── PlatService.java
    │   │   └── MenuService.java
    │   ├── util/
    │   │   ├── HibernateUtil.java
    │   │   └── TransactionUtil.java
    │   └── App.java
    └── test/java/fr/formation/restaurant/
        ├── dao/
        │   ├── PlatDaoTest.java
        │   └── MenuDaoTest.java
        └── service/
            └── MenuServiceTest.java

restaurant-app-spring/        ← Version B : Spring Boot
├── pom.xml
└── src/
    ├── main/java/fr/formation/restaurant/
    │   ├── model/
    │   │   ├── Plat.java
    │   │   ├── Menu.java
    │   │   ├── CategoriePlat.java
    │   │   └── TypeRepas.java
    │   ├── repository/
    │   │   ├── PlatRepository.java
    │   │   └── MenuRepository.java
    │   ├── service/
    │   │   ├── PlatService.java
    │   │   └── MenuService.java
    │   ├── specification/
    │   │   └── PlatSpecification.java
    │   ├── exception/
    │   │   ├── PlatNotFoundException.java
    │   │   └── MenuNotFoundException.java
    │   └── RestaurantApplication.java
    └── test/java/fr/formation/restaurant/
        ├── repository/
        │   ├── PlatRepositoryTest.java
        │   └── MenuRepositoryTest.java
        └── service/
            ├── PlatServiceTest.java
            └── MenuServiceTest.java

12.4. Les classes complètes — Version Spring Boot

// ── ÉNUMÉRATIONS ──────────────────────────────────────────────────────────────

// src/main/java/fr/formation/restaurant/model/CategoriePlat.java
package fr.formation.restaurant.model;

public enum CategoriePlat {
    ENTREE("Entrée"),
    PLAT_PRINCIPAL("Plat principal"),
    DESSERT("Dessert"),
    BOISSON("Boisson"),
    ACCOMPAGNEMENT("Accompagnement");

    private final String libelle;

    CategoriePlat(String libelle) { this.libelle = libelle; }
    public String getLibelle()    { return libelle; }
}
// src/main/java/fr/formation/restaurant/model/TypeRepas.java
package fr.formation.restaurant.model;

public enum TypeRepas {
    DEJEUNER("Déjeuner"),
    DINER("Dîner"),
    COLLATION("Collation");

    private final String libelle;
    TypeRepas(String libelle) { this.libelle = libelle; }
    public String getLibelle() { return libelle; }
}
// src/main/java/fr/formation/restaurant/repository/MenuRepository.java
package fr.formation.restaurant.repository;

import fr.formation.restaurant.model.Menu;
import fr.formation.restaurant.model.TypeRepas;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;

@Repository
public interface MenuRepository extends JpaRepository<Menu, Long>,
                                         JpaSpecificationExecutor<Menu> {

    // Menus du jour avec plats chargés (évite le N+1)
    @Query("SELECT DISTINCT m FROM Menu m " +
           "LEFT JOIN FETCH m.plats p " +
           "WHERE m.dateMenu = :date AND m.actif = true " +
           "ORDER BY m.typeRepas")
    List<Menu> findMenusDuJourAvecPlats(@Param("date") LocalDate date);

    // Menus sur une période
    List<Menu> findByDateMenuBetweenAndActifTrueOrderByDateMenuAsc(
        LocalDate debut, LocalDate fin);

    // Menu par date et type
    Optional<Menu> findByDateMenuAndTypeRepas(LocalDate date, TypeRepas type);

    // Vérifier existence
    boolean existsByDateMenuAndTypeRepas(LocalDate date, TypeRepas type);

    // Menus de la semaine courante
    @Query("SELECT m FROM Menu m WHERE m.dateMenu >= :lundi " +
           "AND m.dateMenu <= :vendredi AND m.actif = true " +
           "ORDER BY m.dateMenu, m.typeRepas")
    List<Menu> findMenusSemaine(@Param("lundi")    LocalDate lundi,
                                @Param("vendredi") LocalDate vendredi);
}
// src/main/java/fr/formation/restaurant/service/MenuService.java
package fr.formation.restaurant.service;

import fr.formation.restaurant.exception.MenuNotFoundException;
import fr.formation.restaurant.model.*;
import fr.formation.restaurant.repository.MenuRepository;
import fr.formation.restaurant.repository.PlatRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Service
@Transactional
@RequiredArgsConstructor
@Slf4j
public class MenuService {

    private final MenuRepository menuRepository;
    private final PlatRepository platRepository;

    public Menu creerMenu(Menu menu) {
        if (menuRepository.existsByDateMenuAndTypeRepas(
                menu.getDateMenu(), menu.getTypeRepas())) {
            throw new IllegalStateException(
                "Un menu " + menu.getTypeRepas().getLibelle() +
                " existe déjà pour le " + menu.getDateMenu());
        }
        log.info("Création du menu : {} pour le {}", menu.getNom(), menu.getDateMenu());
        return menuRepository.save(menu);
    }

    @Transactional(readOnly = true)
    public Menu trouverParId(Long id) {
        return menuRepository.findById(id)
            .orElseThrow(() ->
                new MenuNotFoundException("Menu introuvable : id=" + id));
    }

    @Transactional(readOnly = true)
    public List<Menu> trouverMenusDuJour(LocalDate date) {
        return menuRepository.findMenusDuJourAvecPlats(date);
    }

    @Transactional(readOnly = true)
    public List<Menu> trouverMenusSemaine() {
        LocalDate lundi    = LocalDate.now().with(DayOfWeek.MONDAY);
        LocalDate vendredi = lundi.plusDays(4);
        return menuRepository.findMenusSemaine(lundi, vendredi);
    }

    @Transactional(readOnly = true)
    public List<Menu> trouverMenusSurPeriode(LocalDate debut, LocalDate fin) {
        return menuRepository.findByDateMenuBetweenAndActifTrueOrderByDateMenuAsc(debut, fin);
    }

    public Menu ajouterPlat(Long menuId, Long platId) {
        Menu menu = trouverParId(menuId);
        Plat plat = platRepository.findById(platId)
            .orElseThrow(() ->
                new fr.formation.restaurant.exception.PlatNotFoundException(
                    "Plat introuvable : id=" + platId));
        menu.ajouterPlat(plat);
        log.info("Plat '{}' ajouté au menu '{}'", plat.getNom(), menu.getNom());
        return menuRepository.save(menu);
    }

    public Menu retirerPlat(Long menuId, Long platId) {
        Menu menu = trouverParId(menuId);
        Plat plat = platRepository.findById(platId)
            .orElseThrow(() ->
                new fr.formation.restaurant.exception.PlatNotFoundException(
                    "Plat introuvable : id=" + platId));
        menu.retirerPlat(plat);
        return menuRepository.save(menu);
    }

    public void desactiver(Long id) {
        Menu menu = trouverParId(id);
        menu.setActif(false);
        menuRepository.save(menu);
    }

    /**
     * Génère le menu de la semaine à partir des plats disponibles.
     */
    @Transactional(readOnly = true)
    public Map<LocalDate, List<Menu>> afficherSemaineComplète() {
        return trouverMenusSemaine().stream()
            .collect(Collectors.groupingBy(Menu::getDateMenu));
    }
}

12.5. Données de test — DataInitializer

// src/main/java/fr/formation/restaurant/DataInitializer.java
package fr.formation.restaurant;

import fr.formation.restaurant.model.*;
import fr.formation.restaurant.repository.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.LocalDate;

/**
 * Initialise la base avec des données de démonstration.
 * Activé seulement avec le profil "dev".
 */
@Component
@Profile("dev")
@RequiredArgsConstructor
@Slf4j
public class DataInitializer implements CommandLineRunner {

    private final PlatRepository platRepository;
    private final MenuRepository menuRepository;

    @Override
    public void run(String... args) {
        if (platRepository.count() > 0) {
            log.info("Données déjà présentes — skip initialisation.");
            return;
        }

        log.info("=== Initialisation des données de démonstration ===");

        // ── Plats ──────────────────────────────────────────────────────────────
        Plat saladeCesar = platRepository.save(Plat.builder()
            .nom("Salade César").description("Laitue romaine, croûtons, parmesan")
            .prix(new BigDecimal("8.50")).categorie(CategoriePlat.ENTREE)
            .calories(320).vegetarien(true).actif(true).build());

        Plat soupeOignon = platRepository.save(Plat.builder()
            .nom("Soupe à l'oignon").description("Soupe gratinée traditionnelle")
            .prix(new BigDecimal("7.00")).categorie(CategoriePlat.ENTREE)
            .calories(280).vegetarien(true).actif(true).build());

        Plat boeufBourguignon = platRepository.save(Plat.builder()
            .nom("Bœuf bourguignon").description("Mijotage 3h au vin rouge de Bourgogne")
            .prix(new BigDecimal("14.90")).categorie(CategoriePlat.PLAT_PRINCIPAL)
            .calories(680).vegetarien(false).actif(true).build());

        Plat saumonGrille = platRepository.save(Plat.builder()
            .nom("Saumon grillé").description("Saumon atlantique, légumes de saison")
            .prix(new BigDecimal("16.50")).categorie(CategoriePlat.PLAT_PRINCIPAL)
            .calories(450).vegetarien(false).actif(true).build());

        Plat risottoChampignons = platRepository.save(Plat.builder()
            .nom("Risotto aux champignons").description("Champignons de Paris, parmesan")
            .prix(new BigDecimal("12.00")).categorie(CategoriePlat.PLAT_PRINCIPAL)
            .calories(520).vegetarien(true).actif(true).build());

        Plat fondantChocolat = platRepository.save(Plat.builder()
            .nom("Fondant au chocolat").description("Coulant au chocolat noir 70%")
            .prix(new BigDecimal("6.50")).categorie(CategoriePlat.DESSERT)
            .calories(420).vegetarien(true).actif(true).build());

        Plat tarteFruits = platRepository.save(Plat.builder()
            .nom("Tarte aux fruits").description("Tarte pâtissière aux fruits de saison")
            .prix(new BigDecimal("5.50")).categorie(CategoriePlat.DESSERT)
            .calories(310).vegetarien(true).actif(true).build());

        Plat eauMineral = platRepository.save(Plat.builder()
            .nom("Eau minérale 50cl").description("")
            .prix(new BigDecimal("1.50")).categorie(CategoriePlat.BOISSON)
            .calories(0).vegetarien(true).actif(true).build());

        log.info("8 plats créés.");

        // ── Menus de la semaine ────────────────────────────────────────────────
        LocalDate lundi = LocalDate.now().with(java.time.DayOfWeek.MONDAY);

        // Lundi déjeuner
        Menu lundiDej = Menu.builder()
            .nom("Menu du Lundi").dateMenu(lundi)
            .typeRepas(TypeRepas.DEJEUNER)
            .tarif(new BigDecimal("13.90")).actif(true).build();
        lundiDej.ajouterPlat(saladeCesar);
        lundiDej.ajouterPlat(boeufBourguignon);
        lundiDej.ajouterPlat(fondantChocolat);
        lundiDej.ajouterPlat(eauMineral);
        menuRepository.save(lundiDej);

        // Mardi déjeuner
        Menu mardiDej = Menu.builder()
            .nom("Menu du Mardi").dateMenu(lundi.plusDays(1))
            .typeRepas(TypeRepas.DEJEUNER)
            .tarif(new BigDecimal("14.90")).actif(true).build();
        mardiDej.ajouterPlat(soupeOignon);
        mardiDej.ajouterPlat(saumonGrille);
        mardiDej.ajouterPlat(tarteFruits);
        mardiDej.ajouterPlat(eauMineral);
        menuRepository.save(mardiDej);

        // Mercredi déjeuner
        Menu mercrediDej = Menu.builder()
            .nom("Menu du Mercredi").dateMenu(lundi.plusDays(2))
            .typeRepas(TypeRepas.DEJEUNER)
            .tarif(new BigDecimal("12.90")).actif(true).build();
        mercrediDej.ajouterPlat(saladeCesar);
        mercrediDej.ajouterPlat(risottoChampignons);
        mercrediDej.ajouterPlat(fondantChocolat);
        mercrediDej.ajouterPlat(eauMineral);
        menuRepository.save(mercrediDej);

        log.info("3 menus de la semaine créés.");
        log.info("=== Initialisation terminée ===");
    }
}

12.6. Application principale — Démonstration

// src/main/java/fr/formation/restaurant/RestaurantApplication.java
package fr.formation.restaurant;

import fr.formation.restaurant.model.*;
import fr.formation.restaurant.service.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;

@SpringBootApplication
@Slf4j
public class RestaurantApplication {

    public static void main(String[] args) {
        SpringApplication.run(RestaurantApplication.class, args);
    }

    @Bean
    CommandLineRunner demo(MenuService menuService, PlatService platService) {
        return args -> {
            System.out.println("\n╔══════════════════════════════════════════╗");
            System.out.println("║    🍽️  Restaurant d'Entreprise            ║");
            System.out.println("║      Système de gestion des menus        ║");
            System.out.println("╚══════════════════════════════════════════╝\n");

            // Menus du jour
            LocalDate aujourd_hui = LocalDate.now();
            System.out.println("📅 Menus du " +
                aujourd_hui.format(DateTimeFormatter.ofPattern("EEEE d MMMM yyyy",
                    java.util.Locale.FRENCH)) + " :");

            List<Menu> menus = menuService.trouverMenusDuJour(aujourd_hui);
            if (menus.isEmpty()) {
                System.out.println("  Aucun menu disponible aujourd'hui.");
            } else {
                for (Menu menu : menus) {
                    System.out.println("\n  🍴 " + menu.getNom() +
                        " (" + menu.getTypeRepas().getLibelle() + ")" +
                        " — " + menu.getTarif() + " €");
                    menu.getPlats().forEach(plat ->
                        System.out.printf("     %-15s %-35s %s€%s%n",
                            "[" + plat.getCategorie().getLibelle() + "]",
                            plat.getNom(),
                            plat.getPrix(),
                            plat.isVegetarien() ? " 🌱" : ""));
                }
            }

            // Menus de la semaine
            System.out.println("\n📆 Menus de la semaine :");
            Map<LocalDate, List<Menu>> semaine =
                menuService.afficherSemaineComplète();
            semaine.forEach((date, menusDuJour) -> {
                System.out.println("\n  " +
                    date.format(DateTimeFormatter.ofPattern("EEEE d/MM",
                        java.util.Locale.FRENCH)) + " :");
                menusDuJour.forEach(m ->
                    System.out.println("    - " + m.getNom() +
                        " (" + m.getPlats().size() + " plats)"));
            });

            // Statistiques
            System.out.println("\n📊 Statistiques des plats :");
            List<Plat> vegetariens = platService.rechercherVegetariens();
            List<Plat> tous = platService.trouverTousActifs();
            System.out.printf("  Total plats actifs : %d%n", tous.size());
            System.out.printf("  Plats végétariens  : %d%n", vegetariens.size());
        };
    }
}

12.7. Tests complets du service

// src/test/java/fr/formation/restaurant/service/MenuServiceTest.java
package fr.formation.restaurant.service;

import fr.formation.restaurant.exception.MenuNotFoundException;
import fr.formation.restaurant.model.*;
import fr.formation.restaurant.repository.*;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.*;

@SpringBootTest
@ActiveProfiles("test")           // Utilise application-test.properties (on peut aussi le faire avec un fichier yaml)
@Transactional                    // permet de rollback après chaque test
@DisplayName("Tests de MenuService")
class MenuServiceTest {

    @Autowired private MenuService  menuService;
    @Autowired private PlatService  platService;
    @Autowired private PlatRepository platRepository;
    @Autowired private MenuRepository menuRepository;

    private Plat entree;
    private Plat platPrincipal;
    private Plat dessert;

    @BeforeEach
    void setUp() {
        entree = platRepository.save(Plat.builder()
            .nom("Soupe du jour").categorie(CategoriePlat.ENTREE)
            .prix(new BigDecimal("6.00")).vegetarien(true).actif(true).build());
        platPrincipal = platRepository.save(Plat.builder()
            .nom("Poulet rôti").categorie(CategoriePlat.PLAT_PRINCIPAL)
            .prix(new BigDecimal("11.00")).vegetarien(false).actif(true).build());
        dessert = platRepository.save(Plat.builder()
            .nom("Mousse au chocolat").categorie(CategoriePlat.DESSERT)
            .prix(new BigDecimal("4.50")).vegetarien(true).actif(true).build());
    }

    @Test
    @DisplayName("creerMenu — menu valide — créé avec succès")
    void creerMenu_menuValide_retourneMenuAvecId() {
        Menu menu = Menu.builder()
            .nom("Menu Test Lundi")
            .dateMenu(LocalDate.now().plusDays(7))
            .typeRepas(TypeRepas.DEJEUNER)
            .tarif(new BigDecimal("12.50"))
            .actif(true).build();

        Menu sauvegarde = menuService.creerMenu(menu);

        assertThat(sauvegarde.getId()).isNotNull();
        assertThat(sauvegarde.getNom()).isEqualTo("Menu Test Lundi");
    }

    @Test
    @DisplayName("creerMenu — doublon date+type — lève IllegalStateException")
    void creerMenu_doublonDateType_leveException() {
        LocalDate date = LocalDate.now().plusDays(14);

        Menu menu1 = Menu.builder().nom("Menu A").dateMenu(date)
            .typeRepas(TypeRepas.DEJEUNER).tarif(BigDecimal.TEN).actif(true).build();
        menuService.creerMenu(menu1);

        Menu menu2 = Menu.builder().nom("Menu B").dateMenu(date)
            .typeRepas(TypeRepas.DEJEUNER).tarif(BigDecimal.TEN).actif(true).build();

        assertThatThrownBy(() -> menuService.creerMenu(menu2))
            .isInstanceOf(IllegalStateException.class)
            .hasMessageContaining("existe déjà");
    }

    @Test
    @DisplayName("ajouterPlat — menu et plat valides — plat ajouté")
    void ajouterPlat_platValide_menuContientPlat() {
        Menu menu = menuRepository.save(Menu.builder()
            .nom("Menu à compléter").dateMenu(LocalDate.now().plusDays(10))
            .typeRepas(TypeRepas.DEJEUNER)
            .tarif(new BigDecimal("12.00")).actif(true).build());

        menuService.ajouterPlat(menu.getId(), entree.getId());
        menuService.ajouterPlat(menu.getId(), platPrincipal.getId());

        Menu menuCharge = menuService.trouverParId(menu.getId());
        assertThat(menuCharge.getPlats())
            .hasSize(2)
            .extracting(Plat::getNom)
            .containsExactlyInAnyOrder("Soupe du jour", "Poulet rôti");
    }

    @Test
    @DisplayName("trouverParId — ID inexistant — lève MenuNotFoundException")
    void trouverParId_idInexistant_leveException() {
        assertThatThrownBy(() -> menuService.trouverParId(99999L))
            .isInstanceOf(MenuNotFoundException.class);
    }

    @Test
    @DisplayName("desactiver — menu actif — menu désactivé")
    void desactiver_menuActif_menuDesactive() {
        Menu menu = menuRepository.save(Menu.builder()
            .nom("Menu à désactiver").dateMenu(LocalDate.now().plusDays(20))
            .typeRepas(TypeRepas.DINER)
            .tarif(new BigDecimal("15.00")).actif(true).build());

        menuService.desactiver(menu.getId());

        Menu desactive = menuService.trouverParId(menu.getId());
        assertThat(desactive.isActif()).isFalse();
    }

    @Test
    @DisplayName("trouverMenusDuJour — menus du jour avec plats chargés")
    void trouverMenusDuJour_avecPlats_pasDeNplusUn() {
        LocalDate demain = LocalDate.now().plusDays(1);
        Menu menu = Menu.builder()
            .nom("Menu Demain").dateMenu(demain)
            .typeRepas(TypeRepas.DEJEUNER)
            .tarif(new BigDecimal("13.00")).actif(true).build();
        menu.ajouterPlat(entree);
        menu.ajouterPlat(platPrincipal);
        menu.ajouterPlat(dessert);
        menuRepository.save(menu);

        // Ne doit PAS lever LazyInitializationException
        List<Menu> menus = menuService.trouverMenusDuJour(demain);
        assertThat(menus).isNotEmpty();
        menus.forEach(m ->
            assertThat(m.getPlats()).isNotEmpty()  // Accès aux plats sans erreur
        );
    }
}

12.8. Missions du TP Final

Mission 1 — Version Hibernate sans Spring Boot (non obligatoire)

  1. Créez le projet Maven restaurant-app avec les dépendances Hibernate.
  2. Configurez persistence.xml pour MySQL et H2 (test).
  3. Implémentez les entités Plat et Menu avec la relation @ManyToMany.
  4. Implémentez PlatDao et MenuDao héritant de AbstractDao.
  5. Implémentez PlatService et MenuService avec la logique métier.
  6. Créez une classe App avec un main() qui :
    • Crée 5 plats (variés : entrées, plats, desserts)
    • Crée 3 menus (lundi, mardi, mercredi)
    • Associe les plats aux menus
    • Affiche le menu de la semaine de façon lisible
  7. Écrivez des tests JUnit 5 avec H2 pour PlatDao et MenuDao.

Mission 2 — Version Spring Boot

  1. Créez le projet restaurant-app-spring depuis Spring Initializr.
  2. Configurez application.properties ou application.yaml pour PostgreSQL.
  3. Utilisez les entités avec Lombok pour éviter d’avoir des classes trop longues.
  4. Implémentez PlatRepository et MenuRepository.
  5. Implémentez PlatService et MenuService.
  6. Implémentez PlatSpecification pour la recherche multicritère.
  7. Créez DataInitializer pour pré-remplir la base de données.
  8. Testez avec @DataJpaTest et @SpringBootTest.

Mission 3 — Fonctionnalités avancées

  1. Implémentez rechercherPlats(String nom, CategoriePlat cat, Double prixMax, Boolean vegetarien) avec Criteria API / Specification.
  2. Implémentez afficherSemaineComplète() retournant un Map<LocalDate, List<Menu>>.
  3. Ajoutez la pagination dans PlatService.trouverTousActifsPagine(int page, int taille).
  4. Résolvez le problème N+1 avec JOIN FETCH sur le chargement des menus avec leurs plats.
  5. Ajoutez @Version sur Menu et écrivez un test qui vérifie l’optimistic locking.

Mission Bonus — Interface console

Ajoutez à la version Spring Boot une interface console interactive :

╔═══════════════════════════════════╗
║    🍽️  Restaurant d'Entreprise    ║
╠═══════════════════════════════════╣
║  1. Menu du jour                  ║
║  2. Menus de la semaine           ║
║  3. Rechercher un plat            ║
║  4. Plats végétariens             ║
║  5. Statistiques                  ║
║  6. Quitter                       ║
╚═══════════════════════════════════╝

Annexe — Aide-mémoire et ressources

Annotations JPA essentielles

Annotation Description Exemple
@Entity Déclare une entité JPA @Entity public class Plat {}
@Table Nom de table et schéma @Table(name="plat")
@Id Clé primaire @Id private Long id;
@GeneratedValue Stratégie de génération @GeneratedValue(strategy=IDENTITY)
@Column Mapping de colonne @Column(nullable=false, length=150)
@ManyToOne Relation N-1 @ManyToOne(fetch=LAZY)
@OneToMany Relation 1-N @OneToMany(mappedBy="...", cascade=ALL)
@ManyToMany Relation N-N @ManyToMany
@JoinColumn Colonne de jointure @JoinColumn(name="client_id")
@JoinTable Table de jointure N-N @JoinTable(name="menu_plat", ...)
@Enumerated Mapping d’enum @Enumerated(EnumType.STRING)
@Lob Champ long @Lob private String description;
@Transient Non persisté @Transient private String tmp;
@Version Optimistic locking @Version private Integer version;
@NamedQuery Requête nommée @NamedQuery(name="...", query="...")

Méthodes dérivées Spring Data JPA

findBy<Champ>
findBy<Champ>And<Champ>
findBy<Champ>Or<Champ>
findBy<Champ>Not
findBy<Champ>LessThan / GreaterThan / Between
findBy<Champ>Like / Containing / StartingWith / EndingWith
findBy<Champ>IgnoreCase
findBy<ChampBoolean>True / False
findBy<Champ>IsNull / IsNotNull
findBy<Champ>In / NotIn
findBy<Champ>OrderBy<Champ>Asc / Desc
countBy<Champ>
existsBy<Champ>
deleteBy<Champ>

Commandes Maven utiles

mvn clean compile           # Compiler
mvn clean test              # Tester
mvn clean package           # Compiler + tester + JAR
mvn dependency:tree         # Voir les dépendances
mvn exec:java               # Exécuter la classe main

Ressources

Ressource URL
Documentation Hibernate https://docs.jboss.org/hibernate/orm/6.4/userguide/html_single
Spring Data JPA https://docs.spring.io/spring-data/jpa/reference
Spring Initializr https://start.spring.io
Hibernate Validator https://hibernate.org/validator
Flyway (migrations) https://flywaydb.org
H2 Database https://www.h2database.com

Checklist de qualité

Entités
☐ Constructeur sans argument (requis JPA)
☐ equals() et hashCode() basés sur l'ID
☐ FetchType.LAZY sur toutes les associations
☐ Méthodes helper pour les associations bidirectionnelles
☐ @Version si modification concurrente possible

Configuration
☐ hbm2ddl.auto=validate (pas create/drop) en production
☐ Credentials dans variables d'environnement (pas en dur)

Requêtes
☐ JOIN FETCH pour éviter le N+1
☐ @Transactional(readOnly=true) sur les lectures
☐ Pagination sur les listes potentiellement longues
☐ Paramètres nommés (:nom) plutôt que positionnels (?1)

Tests
☐ H2 en mémoire pour les tests
☐ @DataJpaTest pour les repositories
☐ @SpringBootTest + @Transactional pour les services
☐ @ActiveProfiles("test") pour utiliser la config de test

.gitignore
☐ target/ exclu
☐ *.class exclu
☐ credentials jamais dans Git