Relations JPA
Les Types de Relations
@OneToMany / @ManyToOne
Cas d'usage : Un auteur a plusieurs livres, un livre a un seul auteur.
Configuration Unidirectionnelle (❌ Rarement recommandée)
@Entity public class Author { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @OneToMany @JoinColumn(name = "author_id") // Crée une FK dans Book private List<Book> books = new ArrayList<>(); } @Entity public class Book { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // Pas de référence à Author }
Problème : Génère des UPDATE supplémentaires !
Lancés automatiquement par Hibernate pour mettre à jour la clé étrangère.
Configuration Bidirectionnelle (✅ Recommandée)
@Entity public class Author { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @OneToMany( mappedBy = "author", // Référence au champ dans Book cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY // Par défaut ) private List<Book> books = new ArrayList<>(); // Méthodes helper pour synchroniser les deux côtés public void addBook(Book book) { books.add(book); book.setAuthor(this); } public void removeBook(Book book) { books.remove(book); book.setAuthor(null); } } @Entity public class Book { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne( fetch = FetchType.LAZY, // Recommandé optional = false // NOT NULL en base ) @JoinColumn(name = "author_id") // Nom de la FK private Author author; }
Le côté @ManyToOne est TOUJOURS le propriétaire (owner) de la relation.
Toutes les modifications portant sur cette relation devront se faire côté owner.
@ManyToMany
Cas d'usage : Un étudiant suit plusieurs cours, un cours a plusieurs étudiants.
Configuration Unidirectionnelle
@Entity public class Student { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToMany @JoinTable( name = "student_course", joinColumns = @JoinColumn(name = "student_id"), inverseJoinColumns = @JoinColumn(name = "course_id") ) private Set<Course> courses = new HashSet<>(); } @Entity public class Course { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // Pas de référence à Student }
Configuration Bidirectionnelle (✅ Recommandée)
@Entity public class Student { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToMany( cascade = {CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.LAZY ) @JoinTable( name = "student_course", joinColumns = @JoinColumn(name = "student_id"), inverseJoinColumns = @JoinColumn(name = "course_id") ) private Set<Course> courses = new HashSet<>(); public void enrollCourse(Course course) { courses.add(course); course.getStudents().add(this); } public void unenrollCourse(Course course) { courses.remove(course); course.getStudents().remove(this); } } @Entity public class Course { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToMany(mappedBy = "courses") private Set<Student> students = new HashSet<>(); }
ManyToMany avec attributs
Ce n'est pas vraiment une ManyToMany…
Avec @EmbeddedId
@Entity public class Enrollment { @EmbeddedId private EnrollmentId id; @ManyToOne @MapsId("studentId") private Student student; @ManyToOne @MapsId("courseId") private Course course; private LocalDate enrollmentDate; private Integer grade; } @Embeddable public class EnrollmentId implements Serializable { private Long studentId; private Long courseId; // equals() et hashCode() } @Entity public class Student { @OneToMany(mappedBy = "student", cascade = CascadeType.ALL, orphanRemoval = true) private Set<Enrollment> enrollments = new HashSet<>(); } @Entity public class Course { @OneToMany(mappedBy = "course", cascade = CascadeType.ALL, orphanRemoval = true) private Set<Enrollment> enrollments = new HashSet<>(); }
Sans @EmbeddedId, mais avec Id supplémentaire (✅ Recommandée)
@Entity @Table( name = "enrollment", indexes = { @Index( name = "idx_enrollment_pk", columnList = "student_id, course_id", unique = true ) } ) public class Enrollment { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // ← Clé primaire auto-générée simple @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "student_id", nullable = false) private Student student; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "course_id", nullable = false) private Course course; private Integer grade; private LocalDate enrollmentDate; // Constructeurs // Getters/Setters // equals et hashCode @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Enrollment)) return false; Enrollment that = (Enrollment) o; return id != null && id.equals(that.getId()); } @Override public int hashCode() { return getClass().hashCode(); } @Override public String toString() { return "Enrollment{" + "id=" + id + ", student=" + (student != null ? student.getName() : "null") + ", course=" + (course != null ? course.getName() : "null") + ", grade=" + grade + ", enrollmentDate=" + enrollmentDate + '}'; } }
Avantages de cette approche :
- Code simple et lisible
- Pas de classe @Embeddable à gérer
- Fonctionne parfaitement avec Spring Data
- L'index unique garantit l'unicité (student_id, course_id)
- Performance identique à une PK composite
FetchType : LAZY vs EAGER
LAZY (✅ Recommandé par défaut)
@Entity public class Book { @ManyToOne(fetch = FetchType.LAZY) private Author author; }
Comportement :
- Les données ne sont chargées que si on y accède
- Évite les requêtes inutiles
- Peut lancer une
LazyInitializationException
si la session Hibernate est fermée
Exemple :
Book book = bookRepository.findById(1L).get(); // Pas de requête vers Author ici String authorName = book.getAuthor().getName(); // ← Requête SQL lancée ici (si session ouverte)
LazyInitializationException : Se produit quand on accède à une relation LAZY en dehors d'une transaction.
Solutions :
- Utiliser
@Transactional
sur la méthode - Utiliser un
@EntityGraph
- Utiliser une query avec
JOIN FETCH
EAGER (⚠️ À utiliser avec précaution)
@Entity public class Book { @ManyToOne(fetch = FetchType.EAGER) private Author author; }
Comportement :
- Les données sont chargées immédiatement avec l'entité principale
- Peut créer des problèmes de performance (N+1)
- Utile seulement si on a toujours besoin de la relation
FetchType par défaut
Annotation | FetchType par défaut |
---|---|
@ManyToOne | LAZY (depuis Hibernate 5.1+) ou EAGER (JPA standard) |
@OneToOne | EAGER |
@OneToMany | LAZY |
@ManyToMany | LAZY |
Bonne pratique : Toujours spécifier explicitement le FetchType
pour éviter les surprises.
Optimiser avec JOIN FETCH
public interface BookRepository extends JpaRepository<Book, Long> { // ✅ Charge Book + Author en une seule requête @Query("SELECT b FROM Book b JOIN FETCH b.author WHERE b.id = :id") Optional<Book> findByIdWithAuthor(@Param("id") Long id); // ✅ Charge tous les Books + leurs Authors @Query("SELECT DISTINCT b FROM Book b JOIN FETCH b.author") List<Book> findAllWithAuthors(); }
Optimiser avec @EntityGraph
public interface BookRepository extends JpaRepository<Book, Long> { @EntityGraph(attributePaths = {"author"}) Optional<Book> findById(Long id); @EntityGraph(attributePaths = {"author", "publisher"}) List<Book> findAll(); }
Cascade
Les CascadeType
définissent quelles opérations sont propagées aux entités liées.
Les différents types
public enum CascadeType { PERSIST, // entityManager.persist() MERGE, // entityManager.merge() REMOVE, // entityManager.remove() REFRESH, // entityManager.refresh() DETACH, // entityManager.detach() ALL // Tous les types ci-dessus }
Exemples
PERSIST : Propager la création
@Entity public class Author { @OneToMany( mappedBy = "author", cascade = CascadeType.PERSIST ) private List<Book> books = new ArrayList<>(); } // Utilisation Author author = new Author("John Doe"); Book book1 = new Book("Book 1"); Book book2 = new Book("Book 2"); author.addBook(book1); author.addBook(book2); entityManager.persist(author); // ✅ author, book1 et book2 sont tous persistés automatiquement
REMOVE : Propager la suppression (⚠️ Dangereux)
@Entity public class Author { @OneToMany( mappedBy = "author", cascade = CascadeType.REMOVE // ⚠️ Attention ! ) private List<Book> books = new ArrayList<>(); } // Utilisation Author author = authorRepository.findById(1L).get(); authorRepository.delete(author); // ⚠️ Tous les livres de cet auteur sont aussi supprimés !
CascadeType.REMOVE
vs orphanRemoval
:
REMOVE
: Supprime les entités liées quand on supprime le parentorphanRemoval
: Supprime les entités liées quand on les retire de la collection
Exemple :
// Avec CascadeType.REMOVE uniquement author.getBooks().remove(book); // ❌ book reste en base avec author_id = NULL // Avec orphanRemoval = true author.getBooks().remove(book); // ✅ book est supprimé de la base
ALL : Propager toutes les opérations
@Entity public class Author { @OneToMany( mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true ) private List<Book> books = new ArrayList<>(); }
Bonnes pratiques Cascade
Recommandations :
@OneToMany
: UtiliserCascadeType.ALL
+orphanRemoval = true
pour les relations de composition (ex: Order → OrderItems)@ManyToOne
: NE PAS utiliser de cascade (sauf cas très particuliers)@ManyToMany
: UtiliserCascadeType.PERSIST
etCascadeType.MERGE
uniquementCascadeType.REMOVE
: Attention aux suppressions en cascade non voulues !
orphanRemoval
orphanRemoval = true
supprime automatiquement les entités “orphelines” (qui ne sont plus dans la collection parente).
Exemple
@Entity public class Author { @OneToMany( mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true // ← Suppression automatique des orphelins ) private List<Book> books = new ArrayList<>(); public void removeBook(Book book) { books.remove(book); book.setAuthor(null); } } // Utilisation Author author = authorRepository.findById(1L).get(); Book book = author.getBooks().get(0); author.removeBook(book); authorRepository.save(author); // ✅ book est automatiquement supprimé de la base
Différence avec CascadeType.REMOVE
Opération | orphanRemoval | CascadeType.REMOVE |
---|---|---|
Supprimer le parent | ✅ Supprime les enfants | ✅ Supprime les enfants |
Retirer un enfant de la collection | ✅ Supprime l'enfant | ❌ L'enfant reste (FK = NULL) |
orphanRemoval = true est idéal pour les relations de composition (parent-enfant fort) où l'enfant n'a pas de sens sans le parent.
Exemples : Order → OrderItem, Invoice → InvoiceLine, Blog → Comment
Incidence du Owner (propriétaire) de la Relation
Définition
Le owner (propriétaire) de la relation est le côté qui :
- Possède la colonne de clé étrangère (FK) en base de données
- Est responsable de la synchronisation avec la base de données
- N'a PAS l'attribut
mappedBy
Règle absolue : Pour qu'une modification de relation soit persistée, elle DOIT être faite côté owner.
Schéma de base de données
@Entity public class Author { @OneToMany(mappedBy = "author") // ❌ N'est PAS le owner private List<Book> books; } @Entity public class Book { @ManyToOne @JoinColumn(name = "author_id") // ✅ EST le owner private Author author; }
En base de données :
CREATE TABLE author ( id BIGINT PRIMARY KEY, name VARCHAR(255) ); CREATE TABLE book ( id BIGINT PRIMARY KEY, title VARCHAR(255), author_id BIGINT, -- ← LA CLÉ ÉTRANGÈRE EST ICI ! CONSTRAINT fk_author FOREIGN KEY (author_id) REFERENCES author(id) );
Conséquence : Seul le Owner peut persister la relation
❌ Ce qui NE fonctionne PAS
Author author = new Author("John Doe"); Book book = new Book("My Book"); // ❌ Modifier uniquement le côté inverse (non-owner) author.getBooks().add(book); entityManager.persist(author); entityManager.flush(); // Résultat en base : // book.author_id = NULL ❌ // La FK n'est PAS remplie !
Pourquoi ? Hibernate ignore le côté @OneToMany
sans mappedBy
pour la persistance.
✅ La bonne méthode
Author author = new Author("John Doe"); Book book = new Book("My Book"); // ✅ Modifier le côté owner (@ManyToOne) book.setAuthor(author); entityManager.persist(author); entityManager.persist(book); entityManager.flush(); // Résultat en base : // book.author_id = 1 ✅
✅ La méthode recommandée : Méthodes helper
@Entity public class Author { @OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true) private List<Book> books = new ArrayList<>(); // ✅ Méthode qui synchronise les deux côtés public void addBook(Book book) { books.add(book); // Côté inverse (pour cohérence en mémoire) book.setAuthor(this); // Côté owner (pour persistance) } public void removeBook(Book book) { books.remove(book); book.setAuthor(null); } } // Utilisation Author author = new Author("John Doe"); Book book = new Book("My Book"); author.addBook(book); // ✅ Les deux côtés sont synchronisés authorRepository.save(author); // ✅ Tout est persisté correctement
Performance : Unidirectionnel vs Bidirectionnel
Configuration Unidirectionnelle @OneToMany (❌ Mauvaise perf)
@Entity public class Author { @OneToMany @JoinColumn(name = "author_id") private List<Book> books = new ArrayList<>(); }
Problème : Génère des requêtes supplémentaires !
INSERT INTO author (name) VALUES ('John Doe'); INSERT INTO book (title) VALUES ('Book 1'); INSERT INTO book (title) VALUES ('Book 2'); -- ⚠️ UPDATE supplémentaires pour chaque livre ! UPDATE book SET author_id = 1 WHERE id = 1; UPDATE book SET author_id = 1 WHERE id = 2;
Configuration Bidirectionnelle (✅ Meilleure perf)
@Entity public class Author { @OneToMany(mappedBy = "author", cascade = CascadeType.ALL) private List<Book> books = new ArrayList<>(); } @Entity public class Book { @ManyToOne @JoinColumn(name = "author_id") private Author author; }
Requêtes optimisées :
INSERT INTO author (name) VALUES ('John Doe'); INSERT INTO book (title, author_id) VALUES ('Book 1', 1); -- ✅ FK directement INSERT INTO book (title, author_id) VALUES ('Book 2', 1); -- ✅ Pas d'UPDATE
ManyToMany : Qui est le owner ?
@Entity public class Student { @ManyToMany // ✅ Le côté SANS mappedBy est le owner @JoinTable( name = "student_course", joinColumns = @JoinColumn(name = "student_id"), inverseJoinColumns = @JoinColumn(name = "course_id") ) private Set<Course> courses = new HashSet<>(); } @Entity public class Course { @ManyToMany(mappedBy = "courses") // ❌ Côté inverse private Set<Student> students = new HashSet<>(); }
Conséquence : Pour ajouter une relation, il faut modifier Student.courses
(le owner) :
// ✅ Modifier le owner student.getCourses().add(course); studentRepository.save(student); // ❌ Modifier l'inverse (ignoré par Hibernate) course.getStudents().add(student); courseRepository.save(course); // Ne persiste PAS la relation !
Toujours synchroniser les deux côtés avec des méthodes helper :
public void enrollCourse(Course course) { courses.add(course); // Owner course.getStudents().add(this); // Inverse }
Queries et Problèmes Courants
Problème N+1
Le problème N+1 se produit quand on charge une collection d'entités, puis qu'on accède à une relation, déclenchant une requête SQL par entité.
Exemple du problème
// 1 requête pour charger tous les livres List<Book> books = bookRepository.findAll(); // N requêtes supplémentaires (une par livre) pour charger les auteurs ! for (Book book : books) { System.out.println(book.getAuthor().getName()); // ⚠️ 1 requête SQL } // Total : 1 + N requêtes !
SELECT * FROM book; -- Requête 1 SELECT * FROM author WHERE id = 1; -- Requête 2 SELECT * FROM author WHERE id = 2; -- Requête 3 SELECT * FROM author WHERE id = 3; -- Requête 4 ...
Solution 1 : JOIN FETCH
public interface BookRepository extends JpaRepository<Book, Long> { @Query("SELECT b FROM Book b JOIN FETCH b.author") List<Book> findAllWithAuthors(); } // Utilisation List<Book> books = bookRepository.findAllWithAuthors(); // ✅ 1 seule requête SQL avec JOIN ! for (Book book : books) { System.out.println(book.getAuthor().getName()); // ✅ Pas de requête }
-- ✅ Une seule requête ! SELECT b.*, a.* FROM book b INNER JOIN author a ON b.author_id = a.id;
Solution 2 : @EntityGraph
public interface BookRepository extends JpaRepository<Book, Long> { @EntityGraph(attributePaths = {"author"}) List<Book> findAll(); @EntityGraph(attributePaths = {"author", "publisher"}) Optional<Book> findById(Long id); }
Solution 3 : @Fetch(FetchMode.SUBSELECT)
@Entity public class Author { @OneToMany(mappedBy = "author") @Fetch(FetchMode.SUBSELECT) // ✅ 2 requêtes au lieu de N+1 private List<Book> books = new ArrayList<>(); }
SELECT * FROM author; -- Requête 1 -- Requête 2 : charge tous les livres en une fois SELECT * FROM book WHERE author_id IN ( SELECT id FROM author );
LazyInitializationException
Erreur : org.hibernate.LazyInitializationException: could not initialize proxy - no Session
Cause : Accès à une relation LAZY en dehors d'une transaction/session Hibernate.
Exemple du problème
@Service public class BookService { public Book getBook(Long id) { return bookRepository.findById(id).get(); } // ← La transaction se termine ici } // Controller Book book = bookService.getBook(1L); String authorName = book.getAuthor().getName(); // ❌ LazyInitializationException !
Solution 1 : @Transactional
@Service public class BookService { @Transactional(readOnly = true) public Book getBook(Long id) { Book book = bookRepository.findById(id).get(); // Accéder aux relations LAZY ici book.getAuthor().getName(); // ✅ OK, on est dans la transaction return book; } }
Solution 2 : JOIN FETCH
@Query("SELECT b FROM Book b JOIN FETCH b.author WHERE b.id = :id") Optional<Book> findByIdWithAuthor(@Param("id") Long id);
Solution 3 : @EntityGraph
@EntityGraph(attributePaths = {"author"}) Optional<Book> findById(Long id);
Solution 4 : DTO Projection
public interface BookDTO { Long getId(); String getTitle(); String getAuthorName(); // author.name } @Query("SELECT b.id as id, b.title as title, b.author.name as authorName FROM Book b WHERE b.id = :id") Optional<BookDTO> findBookDTOById(@Param("id") Long id);
Bonnes Pratiques
1. Toujours initialiser les collections
@Entity public class Author { @OneToMany(mappedBy = "author") private List<Book> books = new ArrayList<>(); // ✅ Initialiser directement }
Évite : NullPointerException
et facilite l'ajout d'éléments.
2. Utiliser des méthodes helper
@Entity public class Author { @OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true) private List<Book> books = new ArrayList<>(); // ✅ Encapsule la logique de synchronisation public void addBook(Book book) { books.add(book); book.setAuthor(this); } public void removeBook(Book book) { books.remove(book); book.setAuthor(null); } }
3. Implémenter equals() et hashCode() correctement
@Entity public class Book { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Book)) return false; Book book = (Book) o; return id != null && id.equals(book.getId()); } @Override public int hashCode() { return getClass().hashCode(); // ✅ Stable, ne change pas } }
NE PAS utiliser tous les champs dans hashCode()
: Le hash doit rester constant, même si l'entité change.
NE PAS utiliser l'ID dans hashCode()
: L'ID peut être null
avant persistance.
4. Utiliser Set au lieu de List pour @ManyToMany
// ✅ Recommandé @ManyToMany private Set<Course> courses = new HashSet<>(); // ❌ Éviter (problèmes avec equals/hashCode et doublons) @ManyToMany private List<Course> courses = new ArrayList<>();
5. Spécifier fetch = FetchType.LAZY partout
@ManyToOne(fetch = FetchType.LAZY) // ✅ Toujours explicite private Author author;
6. Éviter CascadeType.REMOVE sur @ManyToOne
@Entity public class Book { @ManyToOne(cascade = CascadeType.REMOVE) // ❌ DANGEREUX ! private Author author; } // Si on supprime un livre, l'auteur est aussi supprimé ! bookRepository.delete(book); // ⚠️ Supprime aussi l'auteur
7. Utiliser orphanRemoval pour les compositions
@Entity public class Order { @OneToMany( mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true // ✅ Supprimer les items orphelins ) private List<OrderItem> items = new ArrayList<>(); }
8. Éviter les relations bidirectionnelles inutiles
Si vous n'avez jamais besoin de naviguer de Course
vers Student
, restez unidirectionnel :
@Entity public class Student { @ManyToMany private Set<Course> courses = new HashSet<>(); } @Entity public class Course { // Pas de référence à Student }
9. Utiliser @Transactional(readOnly = true) pour les lectures
@Service @Transactional(readOnly = true) // ✅ Optimisation public class BookService { public List<Book> getAllBooks() { return bookRepository.findAll(); } }
10. Logger les requêtes SQL en développement
application.properties
:
# Afficher les requêtes SQL spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true # Logger les paramètres logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE # Détecter le problème N+1 spring.jpa.properties.hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS=10
Mémo Final
Template de relation bidirectionnelle OneToMany/ManyToOne :
@Entity public class Parent { @OneToMany( mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY ) private List<Child> children = new ArrayList<>(); public void addChild(Child child) { children.add(child); child.setParent(this); } public void removeChild(Child child) { children.remove(child); child.setParent(null); } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Parent)) return false; Parent parent = (Parent) o; return id != null && id.equals(parent.getId()); } @Override public int hashCode() { return getClass().hashCode(); } } @Entity public class Child { @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "parent_id") private Parent parent; @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Child)) return false; Child child = (Child) o; return id != null && id.equals(child.getId()); } @Override public int hashCode() { return getClass().hashCode(); } }