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.
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.
Client
Commande
Produit
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 :
null
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.
ResultSet
// 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);
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 !
@Entity
EntityManager
@OneToMany
Pour cela, il faut vérifier les importations de vos packaages !
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 :
javax.persistence
jakarta.persistence
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.
javax.*
jakarta.*
┌─────────────────────────────────────────────────────────┐ │ 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 │ └────────────────┘
Créez un projet Maven dans Eclipse (File puis New puis Maven Project) avec les dépendances suivantes :
File puis New puis Maven Project
<?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>
Sans Spring Boot, Hibernate est configuré via le fichier persistence.xml placé dans src/main/resources/META-INF/ :
persistence.xml
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&serverTimezone=Europe/Paris &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.
hibernate.hbm2ddl.auto=update
validate
none
// 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."); } } }
// 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(); }
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>
Objectif : Configurer un projet Hibernate fonctionnel.
hibernate-tp1
pom.xml
hibernate_demo
CREATE DATABASE hibernate_demo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
HibernateUtil.java
TestConnexion.java
main()
HibernateUtil.getEntityManagerFactory()
mvn exec:java
Run As → Java Application
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); } }
// 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)
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)
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 :
Plat
CategoriePlat
ENTREE
PLAT_PRINCIPAL
DESSERT
BOISSON
@Enumerated(EnumType.STRING)
categorie
hbm2ddl.auto=create
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é) │ └─────────────────┘
// 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(); } } }
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().
MANAGED
persist()
save
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.
// 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();
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(); } } }
Les associations JPA correspondent aux relations entre tables SQL :
@ManyToOne
@OneToOne
@ManyToMany
// 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; } }
// 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.
// 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
// 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 !
// 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; } }
@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; }
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 :
Menu
menu_plat
@JoinTable
quantite
MenuPlat
MenuDao
PlatDao
AbstractDao
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]); }
// 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();
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();
// 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();
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(); }
Implémentez dans PlatDao et MenuDao les requêtes suivantes :
trouverPlatsDuJour(LocalDate date)
trouverPlatsVegetariens()
calculerPrixMoyenParCategorie()
Map<CategoriePlat, Double>
trouverMenusParIntervalle(LocalDate debut, LocalDate fin)
rechercherPlats(String nom, CategoriePlat cat, Double prixMax)
Les transactions garantissent la cohérence des données selon les propriétés ACID :
// 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; });
// 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
@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."); }
Avec Spring Boot, la configuration d’Hibernate se réduit drastiquement :
HibernateUtil
Spring Boot configure automatiquement :
application.properties
EntityManagerFactory
@Transactional
Via Spring Initializr (https://start.spring.io) :
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>
# 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
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); } }
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 :
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
// 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); } }
// 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); } }
// 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); }
// 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);
// 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);
// 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")); }
Implémentez pour le projet restaurant :
PlatRepository
@Query
MenuRepository
findByDateMenuBetween(LocalDate debut, LocalDate fin)
findByDateMenuAndTypeRepas(LocalDate date, TypeRepas type)
JOIN FETCH
MenuService
PlatService
@DataJpaTest
@SpringBootTest
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;
// 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 { /* ... */ }
✅ À 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)
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.
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)
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
// ── É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)); } }
// 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 ==="); } }
// 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()); }; } }
// 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 ); } }
Mission 1 — Version Hibernate sans Spring Boot (non obligatoire)
restaurant-app
App
Mission 2 — Version Spring Boot
restaurant-app-spring
application.yaml
PlatSpecification
DataInitializer
Mission 3 — Fonctionnalités avancées
rechercherPlats(String nom, CategoriePlat cat, Double prixMax, Boolean vegetarien)
afficherSemaineComplète()
Map<LocalDate, List<Menu>>
PlatService.trouverTousActifsPagine(int page, int taille)
@Version
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 ║ ╚═══════════════════════════════════╝
@Entity public class Plat {}
@Table
@Table(name="plat")
@Id
@Id private Long id;
@GeneratedValue
@GeneratedValue(strategy=IDENTITY)
@Column
@Column(nullable=false, length=150)
@ManyToOne(fetch=LAZY)
@OneToMany(mappedBy="...", cascade=ALL)
@JoinColumn
@JoinColumn(name="client_id")
@JoinTable(name="menu_plat", ...)
@Enumerated
@Lob
@Lob private String description;
@Transient
@Transient private String tmp;
@Version private Integer version;
@NamedQuery
@NamedQuery(name="...", query="...")
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>
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
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