| Les deux révisions précédentes Révision précédente Prochaine révision | Révision précédente |
| framework-web:spring:relations [2025/10/07 14:27] – jcheron | framework-web:spring:relations [2025/10/08 01:24] (Version actuelle) – [Schéma de base de données] jcheron |
|---|
| ====== Relation JPA ====== | ====== Relations JPA ====== |
| |
| ===== Les Types de Relations ===== | ===== Types de Relations ===== |
| |
| ==== @OneToMany / @ManyToOne ==== | ==== @OneToMany / @ManyToOne ==== |
| Cas d'usage : Un auteur a plusieurs livres, un livre a un seul auteur. | Cas d'usage : Un auteur a plusieurs livres, un livre a un seul auteur. |
| |
| === Configuration Unidirectionnelle (Rarement recommandée) === | === Configuration Unidirectionnelle (🟰 Rarement recommandée) === |
| <sxh java> | <sxh java> |
| @Entity | @Entity |
| } | } |
| </sxh> | </sxh> |
| <WRAP round important 60%> | <WRAP round important> |
| Problème : Génère des UPDATE supplémentaires ! | **Problème** : Génère des UPDATE supplémentaires ! |
| | |
| | Lancés automatiquement par Hibernate pour mettre à jour la clé étrangère. |
| </WRAP> | </WRAP> |
| |
| |
| === Configuration Bidirectionnelle (Recommandée) === | === Configuration Bidirectionnelle (✅ Recommandée) === |
| <sxh java> | <sxh java> |
| @Entity | @Entity |
| } | } |
| </sxh> | </sxh> |
| <WRAP round tip 60%> | <WRAP round tip> |
| Règle d'or : Le côté @ManyToOne est TOUJOURS le propriétaire (owner) de la relation. | 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**. |
| | </WRAP> |
| | |
| | |
| | ==== @ManyToMany ==== |
| | |
| | Cas d'usage : Un étudiant suit plusieurs cours, un cours a plusieurs étudiants. |
| | |
| | === Configuration Unidirectionnelle === |
| | |
| | <sxh java> |
| | @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 |
| | } |
| | </sxh> |
| | |
| | === Configuration Bidirectionnelle (✅ Recommandée) === |
| | |
| | <sxh java> |
| | @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<>(); |
| | } |
| | </sxh> |
| | |
| | ==== ManyToMany avec attributs ==== |
| | Ce n'est pas vraiment une ManyToMany... |
| | |
| | === Avec @EmbeddedId === |
| | |
| | <sxh java> |
| | @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<>(); |
| | } |
| | </sxh> |
| | |
| | === Sans @EmbeddedId, mais avec Id supplémentaire (✅ Recommandée) === |
| | |
| | <sxh java> |
| | @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 + |
| | '}'; |
| | } |
| | } |
| | </sxh> |
| | |
| | <WRAP round tip> |
| | **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 |
| | </WRAP> |
| | |
| | |
| | ===== FetchType : LAZY vs EAGER ===== |
| | |
| | ==== LAZY (✅ Recommandé par défaut) ==== |
| | |
| | <sxh java> |
| | @Entity |
| | public class Book { |
| | @ManyToOne(fetch = FetchType.LAZY) |
| | private Author author; |
| | } |
| | </sxh> |
| | |
| | **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** : |
| | <sxh java> |
| | 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) |
| | </sxh> |
| | |
| | <WRAP round important> |
| | **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'' |
| | </WRAP> |
| | |
| | ==== EAGER (⚠️ À utiliser avec précaution) ==== |
| | |
| | <sxh java> |
| | @Entity |
| | public class Book { |
| | @ManyToOne(fetch = FetchType.EAGER) |
| | private Author author; |
| | } |
| | </sxh> |
| | |
| | **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'' | |
| | |
| | <WRAP round tip> |
| | **Bonne pratique** : Toujours spécifier explicitement le ''FetchType'' pour éviter les surprises. |
| | </WRAP> |
| | |
| | ==== Optimiser avec JOIN FETCH ==== |
| | |
| | <sxh java> |
| | 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(); |
| | } |
| | </sxh> |
| | |
| | ==== Optimiser avec @EntityGraph ==== |
| | |
| | <sxh java> |
| | public interface BookRepository extends JpaRepository<Book, Long> { |
| | |
| | @EntityGraph(attributePaths = {"author"}) |
| | Optional<Book> findById(Long id); |
| | |
| | @EntityGraph(attributePaths = {"author", "publisher"}) |
| | List<Book> findAll(); |
| | } |
| | </sxh> |
| | |
| | |
| | ===== Cascade ===== |
| | |
| | Les ''CascadeType'' définissent quelles opérations sont propagées aux entités liées. |
| | |
| | ==== Les différents types ==== |
| | |
| | <sxh java> |
| | public enum CascadeType { |
| | PERSIST, // entityManager.persist() |
| | MERGE, // entityManager.merge() |
| | REMOVE, // entityManager.remove() |
| | REFRESH, // entityManager.refresh() |
| | DETACH, // entityManager.detach() |
| | ALL // Tous les types ci-dessus |
| | } |
| | </sxh> |
| | |
| | ==== Exemples ==== |
| | |
| | === PERSIST : Propager la création === |
| | |
| | <sxh java> |
| | @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 |
| | </sxh> |
| | |
| | === REMOVE : Propager la suppression (⚠️ Dangereux) === |
| | |
| | <sxh java> |
| | @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 ! |
| | </sxh> |
| | |
| | <WRAP round important> |
| | **''CascadeType.REMOVE'' vs ''orphanRemoval''** : |
| | * ''REMOVE'' : Supprime les entités liées quand on supprime le parent |
| | * ''orphanRemoval'' : Supprime les entités liées quand on les retire de la collection |
| | |
| | **Exemple** : |
| | <sxh java> |
| | // 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 |
| | </sxh> |
| | </WRAP> |
| | |
| | === ALL : Propager toutes les opérations === |
| | |
| | <sxh java> |
| | @Entity |
| | public class Author { |
| | @OneToMany( |
| | mappedBy = "author", |
| | cascade = CascadeType.ALL, |
| | orphanRemoval = true |
| | ) |
| | private List<Book> books = new ArrayList<>(); |
| | } |
| | </sxh> |
| | |
| | ==== Bonnes pratiques Cascade ==== |
| | |
| | <WRAP round tip> |
| | **Recommandations** : |
| | * **''@OneToMany''** : Utiliser ''CascadeType.ALL'' + ''orphanRemoval = true'' pour les relations de composition (ex: Order → OrderItems) |
| | * **''@ManyToOne''** : NE PAS utiliser de cascade (sauf cas très particuliers) |
| | * **''@ManyToMany''** : Utiliser ''CascadeType.PERSIST'' et ''CascadeType.MERGE'' uniquement |
| | * **''CascadeType.REMOVE''** : Attention aux suppressions en cascade non voulues ! |
| | </WRAP> |
| | |
| | |
| | ===== orphanRemoval ===== |
| | |
| | ''orphanRemoval = true'' supprime automatiquement les entités "orphelines" (qui ne sont plus dans la collection parente). |
| | |
| | ==== Exemple ==== |
| | |
| | <sxh java> |
| | @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 |
| | </sxh> |
| | |
| | ==== 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) | |
| | |
| | <WRAP round tip> |
| | **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 |
| | </WRAP> |
| | |
| | |
| | ===== 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'' |
| | |
| | <WRAP round important> |
| | **Règle absolue** : Pour qu'une modification de relation soit persistée, elle DOIT être faite côté owner. |
| | </WRAP> |
| | |
| | ==== Schéma de base de données ==== |
| | |
| | <sxh java> |
| | @Entity |
| | public class Author { |
| | @OneToMany(mappedBy = "author") // ❌ Non owner |
| | private List<Book> books; |
| | } |
| | |
| | @Entity |
| | public class Book { |
| | @ManyToOne |
| | @JoinColumn(name = "author_id") // ✅ Owner |
| | private Author author; |
| | } |
| | </sxh> |
| | |
| | **En base de données** : |
| | <sxh sql> |
| | CREATE TABLE author ( |
| | id BIGINT PRIMARY KEY, |
| | name VARCHAR(255) |
| | ); |
| | |
| | CREATE TABLE book ( |
| | id BIGINT PRIMARY KEY, |
| | title VARCHAR(255), |
| | author_id BIGINT, |
| | CONSTRAINT fk_author FOREIGN KEY (author_id) REFERENCES author(id) |
| | ); |
| | </sxh> |
| | |
| | ==== Conséquence : Seul le Owner peut persister la relation ==== |
| | |
| | === ❌ Ce qui NE fonctionne PAS === |
| | |
| | <sxh java> |
| | 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 ! |
| | </sxh> |
| | |
| | **Pourquoi ?** Hibernate ignore le côté ''@OneToMany'' sans ''mappedBy'' pour la persistance. |
| | |
| | === ✅ La bonne méthode === |
| | |
| | <sxh java> |
| | 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 ✅ |
| | </sxh> |
| | |
| | === ✅ La méthode recommandée : Méthodes helper === |
| | |
| | <sxh java> |
| | @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 |
| | </sxh> |
| | |
| | ==== Performance : Unidirectionnel vs Bidirectionnel ==== |
| | |
| | === Configuration Unidirectionnelle @OneToMany (❌ Mauvaise perf) === |
| | |
| | <sxh java> |
| | @Entity |
| | public class Author { |
| | @OneToMany |
| | @JoinColumn(name = "author_id") |
| | private List<Book> books = new ArrayList<>(); |
| | } |
| | </sxh> |
| | |
| | **Problème** : Génère des requêtes supplémentaires ! |
| | |
| | <sxh sql> |
| | 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; |
| | </sxh> |
| | |
| | === Configuration Bidirectionnelle (✅ Meilleure perf) === |
| | |
| | <sxh java> |
| | @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; |
| | } |
| | </sxh> |
| | |
| | **Requêtes optimisées** : |
| | |
| | <sxh sql> |
| | 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 |
| | </sxh> |
| | |
| | ==== ManyToMany : Qui est le owner ? ==== |
| | |
| | <sxh java> |
| | @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<>(); |
| | } |
| | </sxh> |
| | |
| | **Conséquence** : Pour ajouter une relation, il faut modifier ''Student.courses'' (le owner) : |
| | |
| | <sxh java> |
| | // ✅ 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 ! |
| | </sxh> |
| | |
| | <WRAP round tip> |
| | **Toujours synchroniser les deux côtés** avec des méthodes helper : |
| | <sxh java> |
| | public void enrollCourse(Course course) { |
| | courses.add(course); // Owner |
| | course.getStudents().add(this); // Inverse |
| | } |
| | </sxh> |
| | </WRAP> |
| | |
| | |
| | ===== 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 === |
| | |
| | <sxh java> |
| | // 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 ! |
| | </sxh> |
| | |
| | <sxh sql> |
| | 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 |
| | ... |
| | </sxh> |
| | |
| | === Solution 1 : JOIN FETCH === |
| | |
| | <sxh java> |
| | 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 |
| | } |
| | </sxh> |
| | |
| | <sxh sql> |
| | -- ✅ Une seule requête ! |
| | SELECT b.*, a.* |
| | FROM book b |
| | INNER JOIN author a ON b.author_id = a.id; |
| | </sxh> |
| | |
| | === Solution 2 : @EntityGraph === |
| | |
| | <sxh java> |
| | public interface BookRepository extends JpaRepository<Book, Long> { |
| | |
| | @EntityGraph(attributePaths = {"author"}) |
| | List<Book> findAll(); |
| | |
| | @EntityGraph(attributePaths = {"author", "publisher"}) |
| | Optional<Book> findById(Long id); |
| | } |
| | </sxh> |
| | |
| | === Solution 3 : @Fetch(FetchMode.SUBSELECT) === |
| | |
| | <sxh java> |
| | @Entity |
| | public class Author { |
| | @OneToMany(mappedBy = "author") |
| | @Fetch(FetchMode.SUBSELECT) // ✅ 2 requêtes au lieu de N+1 |
| | private List<Book> books = new ArrayList<>(); |
| | } |
| | </sxh> |
| | |
| | <sxh sql> |
| | 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 |
| | ); |
| | </sxh> |
| | |
| | ==== 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 === |
| | |
| | <sxh java> |
| | @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 ! |
| | </sxh> |
| | |
| | === Solution 1 : @Transactional === |
| | |
| | <sxh java> |
| | @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; |
| | } |
| | } |
| | </sxh> |
| | |
| | === Solution 2 : JOIN FETCH === |
| | |
| | <sxh java> |
| | @Query("SELECT b FROM Book b JOIN FETCH b.author WHERE b.id = :id") |
| | Optional<Book> findByIdWithAuthor(@Param("id") Long id); |
| | </sxh> |
| | |
| | === Solution 3 : @EntityGraph === |
| | |
| | <sxh java> |
| | @EntityGraph(attributePaths = {"author"}) |
| | Optional<Book> findById(Long id); |
| | </sxh> |
| | |
| | === Solution 4 : DTO Projection === |
| | |
| | <sxh java> |
| | 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); |
| | </sxh> |
| | |
| | |
| | ===== Bonnes Pratiques ===== |
| | |
| | ==== 1. Toujours initialiser les collections ==== |
| | |
| | <sxh java> |
| | @Entity |
| | public class Author { |
| | @OneToMany(mappedBy = "author") |
| | private List<Book> books = new ArrayList<>(); // ✅ Initialiser directement |
| | } |
| | </sxh> |
| | |
| | **Évite** : ''NullPointerException'' et facilite l'ajout d'éléments. |
| | |
| | ==== 2. Utiliser des méthodes helper ==== |
| | |
| | <sxh java> |
| | @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); |
| | } |
| | } |
| | </sxh> |
| | |
| | ==== 3. Implémenter equals() et hashCode() correctement ==== |
| | |
| | <sxh java> |
| | @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 |
| | } |
| | } |
| | </sxh> |
| | |
| | <WRAP round important> |
| | **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. |
| | </WRAP> |
| | |
| | ==== 4. Utiliser Set au lieu de List pour @ManyToMany ==== |
| | |
| | <sxh java> |
| | // ✅ Recommandé |
| | @ManyToMany |
| | private Set<Course> courses = new HashSet<>(); |
| | |
| | // ❌ Éviter (problèmes avec equals/hashCode et doublons) |
| | @ManyToMany |
| | private List<Course> courses = new ArrayList<>(); |
| | </sxh> |
| | |
| | ==== 5. Spécifier fetch = FetchType.LAZY partout ==== |
| | |
| | <sxh java> |
| | @ManyToOne(fetch = FetchType.LAZY) // ✅ Toujours explicite |
| | private Author author; |
| | </sxh> |
| | |
| | ==== 6. Éviter CascadeType.REMOVE sur @ManyToOne ==== |
| | |
| | <sxh java> |
| | @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 |
| | </sxh> |
| | |
| | ==== 7. Utiliser orphanRemoval pour les compositions ==== |
| | |
| | <sxh java> |
| | @Entity |
| | public class Order { |
| | @OneToMany( |
| | mappedBy = "order", |
| | cascade = CascadeType.ALL, |
| | orphanRemoval = true // ✅ Supprimer les items orphelins |
| | ) |
| | private List<OrderItem> items = new ArrayList<>(); |
| | } |
| | </sxh> |
| | |
| | ==== 8. Éviter les relations bidirectionnelles inutiles ==== |
| | |
| | Si vous n'avez **jamais** besoin de naviguer de ''Course'' vers ''Student'', restez unidirectionnel : |
| | |
| | <sxh java> |
| | @Entity |
| | public class Student { |
| | @ManyToMany |
| | private Set<Course> courses = new HashSet<>(); |
| | } |
| | |
| | @Entity |
| | public class Course { |
| | // Pas de référence à Student |
| | } |
| | </sxh> |
| | |
| | ==== 9. Utiliser @Transactional(readOnly = true) pour les lectures ==== |
| | |
| | <sxh java> |
| | @Service |
| | @Transactional(readOnly = true) // ✅ Optimisation |
| | public class BookService { |
| | |
| | public List<Book> getAllBooks() { |
| | return bookRepository.findAll(); |
| | } |
| | } |
| | </sxh> |
| | |
| | ==== 10. Logger les requêtes SQL en développement ==== |
| | |
| | ''application.properties'' : |
| | <sxh bash> |
| | # 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 |
| | </sxh> |
| | |
| | |
| | ===== Mémo Final ===== |
| | |
| | <WRAP round tip> |
| | **Template de relation bidirectionnelle OneToMany/ManyToOne** : |
| | |
| | <sxh java> |
| | @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(); |
| | } |
| | } |
| | </sxh> |
| </WRAP> | </WRAP> |
| |