CodeGym /Cours /Spring Test /Cascade et orphan removal dans les relations de l’article...

Cascade et orphan removal dans les relations de l’article

Spring Test
Niveau 17, Leçon 1
Disponible

1. Rôle de cascade et orphanRemoval

Le fait même de la relation a déjà été vérifié : la clé étrangère s'est placée là où il faut, et le round-trip via la base l'a confirmé. Mais le graphe d'entités n'est pas intéressant seulement en statique. Il faut aussi comprendre ce qui se passe lorsque l'entité parente commence à sauvegarder, supprimer et réorganiser les objets enfants.

Quand vous voyez pour la première fois cascade = ... et orphanRemoval = true, on a envie de réagir honnêtement en junior : « Oh, encore deux paramètres dans une annotation. Il faut probablement les mettre comme dans le tuto et les oublier ». Mais dans une application réelle, ces configurations ne sont pas cosmétiques, mais un comportement qui décide s'il reste dans votre base des enregistrements « orphelins », et si la sauvegarde/suppression du graphe d'entités fonctionne de manière prévisible.

Sur le domaine ContentHub, cela se ressent littéralement à la main. Un article a des pièces jointes (ArticleAttachment) – des métadonnées de fichiers téléchargés. Lorsque l'éditeur supprime une pièce jointe ou la remplace, il est important pour nous qu'il ne reste pas de « fantômes » dans la table des pièces jointes : des enregistrements pour des fichiers qui n'existent plus, ou des enregistrements qui n'appartiennent plus à aucun article. Et inversement : lors de la sauvegarde d'un nouvel article avec des pièces jointes, nous voulons que les pièces jointes soient effectivement sauvegardées, et non perdues quelque part entre persist(article) et la réalité de la base de données.

Si on devait le dire en une phrase, aujourd'hui nous apprenons à prouver par des tests deux choses : les opérations sur l'entité parente (Article) se propagent correctement aux enfants (ArticleAttachment), et la suppression d'une pièce jointe de la collection de l'article produit un effet prévisible dans la base, et pas seulement dans la liste Java.

2. Cascade vs orphanRemoval : mécanismes différents

Les deux mots sonnent comme s'il s'agissait de « suppression automatique de tout ce qui bouge », mais ce sont des histoires différentes. cascade concerne la façon dont une opération de EntityManager se « propage » le long du graphe d'objets. orphanRemoval concerne le sort d'un enregistrement enfant lorsqu'il a cessé de faire partie de la collection du parent. Les confondre revient à confondre « l'eau a circulé dans les tuyaux » et « le robinet a été retiré du mur ».

Fixons la différence de manière aussi pratique que possible, en termes de « ce que je fais » → « ce qui se passe ».

Mécanisme Ce que vous faites dans le code Ce que JPA fait en réponse Sens typique pour ContentHub
cascade = PERSIST entityManager.persist(article) JPA appelle automatiquement persist aussi pour article.getAttachments() « Je crée un article directement avec des pièces jointes »
cascade = REMOVE entityManager.remove(article) JPA supprime aussi les pièces jointes appartenant à l'article « Je supprime l'article – les pièces jointes doivent aussi disparaître »
orphanRemoval = true article.getAttachments() .remove(x) JPA supprime l'enregistrement ArticleAttachment de la base « J'ai retiré une pièce jointe de l'article – elle ne doit pas rester dans la table »

Il est très important de saisir l'intuition : cascade s'active lorsque vous appelez une opération sur le parent (persist/remove/merge/...). orphanRemoval s'active lorsque vous modifiez la composition de la collection chez le parent. Le mécanisme ressemble à un « nettoyeur intelligent » : il voit que l'objet n'appartient plus au parent – il est donc « orphelin », et il doit être supprimé.

Pour fixer les idées, voici un petit schéma de comportement. Oui, c'est presque une bande dessinée, mais le cerveau retient mieux :

flowchart TD
    %% Entité parente
    A[Article] -->|a plusieurs| B[ArticleAttachment]
    %% Les opérations EntityManager déclenchent le cascade du parent vers les enfants
    EM[EntityManager] -->|persist Article| A
    A -->|cascade persist| B
    %% Orphan removal se déclenche quand vous modifiez la composition de la collection chez le parent
    A -->|supprimer attachment de la liste| OR[orphanRemoval]
    OR -->|DELETE en BD| B

Le schéma ne dit pas « il faut toujours faire comme ça ». Il dit que cela se produira si vous avez configuré les paramètres correspondants. Et notre tâche est d'écrire des tests qui confirment ce comportement, afin qu'une modification ultérieure de l'annotation ne se transforme pas en une régression silencieuse.

3. Mapping dans ContentHub : où le cascade est pertinent

Nous parlons ici de la relation Article → ArticleAttachment, et là, le cascade est souvent vraiment utile. Les pièces jointes dans notre domaine vivent comme une partie de l'article : séparément, elles n'ont pas de sens. Cela ressemble à un dossier avec des fichiers à l'intérieur : un fichier sans dossier, dans notre scénario, ne doit pas traîner sur le bureau de la base de données.

Exemple de mapping simplifié (l'idée est plus importante que les détails des champs) :

import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;

@Entity
class Article {

    // Collection côté inverse (mappedBy) – reflet de la relation.
    // La vraie clé étrangère se trouve généralement du côté propriétaire (dans ArticleAttachment.article).
    @OneToMany(mappedBy = "article", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<ArticleAttachment> attachments = new ArrayList<>(); // Pour éviter NPE lors de l'ajout de pièces jointes
}

Ici, nous utilisons CascadeType.ALL parce que c'est une variante pédagogique et illustrative. En production, vous limiterez souvent le cascade à ce qui est réellement nécessaire (par exemple, PERSIST et REMOVE), pour éviter que ce qui ne devrait pas fuiter ne fuite accidentellement. Mais pour l'instant, l'important est de comprendre la mécanique et d'apprendre à la tester.

Par contre, pour la relation Article → Category, la situation est différente. Les catégories sont un référentiel, elles sont communes, créées par migrations, et une catégorie appartient à plusieurs articles. Donc, un cascade sur une catégorie est dangereux. Si par habitude vous mettez cascade = REMOVE sur @ManyToOne Category category, alors « supprimer un article » pourrait soudainement se transformer en « supprimer une catégorie », et ensuite… eh bien, ensuite ce sera amusant, mais seulement les cinq premières minutes, jusqu'à ce que vous ouvriez les logs de production.

Dans notre domaine, on peut le formuler de manière très humaine : une pièce jointe est une « partie de l'article », une catégorie est un « référentiel pour les articles ». Le cascade et l'orphan removal sont généralement appropriés pour les « parties », et généralement inappropriés pour les « référentiels ».

4. Méthodes addAttachment / removeAttachment comme filet de sécurité

Lorsque vous avez une relation bidirectionnelle (Article contient une collection de pièces jointes, et ArticleAttachment contient une référence vers l'article), un petit désagrément apparaît : JPA stocke la clé étrangère du côté propriétaire. Dans notre cas, le côté propriétaire est presque toujours ArticleAttachment.article (c'est-à-dire @ManyToOne du côté de la pièce jointe), car c'est là que se trouve @JoinColumn et que se trouve physiquement la clé étrangère dans la table.

Cela conduit à un bogue classique : le développeur a ajouté une pièce jointe dans article.getAttachments().add(attachment), mais n'a pas défini attachment.setArticle(article). En mémoire, la liste a l'air belle, les tests sans flush/clear « passent » parfois, et dans la base, la clé étrangère n'est pas définie, donc soit l'enregistrement ne s'insère pas, soit il s'insère avec NULL (si vous l'avez autorisé pour une raison quelconque), soit une violation de contrainte est levée.

C'est pourquoi, en pratique, on crée presque toujours des méthodes wrapper qui synchronisent les deux côtés :

public void addAttachment(ArticleAttachment attachment) {
    // Ajout à la collection (côté inverse)
    attachments.add(attachment);

    // Synchronisation obligatoire du côté propriétaire, sinon la clé étrangère ne sera pas définie en base
    attachment.setArticle(this);
}

public void removeAttachment(ArticleAttachment attachment) {
    // Retrait de la collection (sinon orphanRemoval peut ne pas fonctionner)
    attachments.remove(attachment);

    // Rupture du côté propriétaire – c'est le signal clé pour JPA que la relation est vraiment rompue
    attachment.setArticle(null);
}

Cela ressemble à « deux méthodes superflues », mais c'est justement le cas où le « superflu » économise des heures d'investigation. Et d'ailleurs, ces méthodes améliorent la testabilité : dans le test, vous faites article.addAttachment(...) et vous êtes sûr que la clé étrangère sera correcte, et que l'orphan removal fonctionnera comme attendu.

Une autre idée importante : orphanRemoval réagit au fait que l'entité fille a cessé de faire partie de la collection. Mais dans une relation bidirectionnelle, « cesser de faire partie » ne consiste pas seulement à remove de la liste, mais aussi à rompre le côté propriétaire. C'est pourquoi un bon removeAttachment effectue les deux actions, pour que JPA n'ait pas une « vision contradictoire du monde ».

5. Test sur cascade : les pièces jointes aussi en base

Maintenant, transformons l'annotation en comportement observable. Nous voulons un test qui prouve : si je crée un Article, j'y ajoute de nouvelles ArticleAttachment (pas encore sauvegardées), puis je ne sauvegarde que l'article, les pièces jointes sont quand même en base. C'est l'effet du cascade persist.

Le squelette du test dans @DataJpaTest ressemble généralement à ceci :

import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.beans.factory.annotation.Autowired;

@DataJpaTest
class ArticleAttachmentCascadeDataJpaTest {

    @Autowired TestEntityManager entityManager; // EntityManager pratique pour les tests (persist/flush/clear)
}

Ajoutons les repositories, car nous avons besoin de vérifier commodément l'état de la base après clear() :

import org.springframework.beans.factory.annotation.Autowired;

@Autowired ArticleRepository articleRepository; // Vérifie la suppression/présence des articles si nécessaire
@Autowired ArticleAttachmentRepository attachmentRepository; // Vérifie que les pièces jointes sont vraiment dans la table

Et maintenant le scénario lui-même. Essayez de le garder le plus « fin » possible : une catégorie, un article, une pièce jointe. Moins il y a de décoration, plus il est facile de comprendre pourquoi le test a échoué.

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

@Test
void persistArticle_shouldPersistAttachments_viaCascade() {
    // On sauvegarde la catégorie séparément : on ne suppose pas de cascade sur les référentiels
    Category java = entityManager.persist(new Category("java", "Java"));

    // L'article référence une catégorie déjà existante
    Article article = new Article("JPA basics", java);

    // Ajout d'une pièce jointe via la méthode helper pour que le côté propriétaire (attachment.article) soit bien rempli
    article.addAttachment(new ArticleAttachment("guide.pdf"));

    // persist seulement du parent – la pièce jointe doit être sauvegardée par cascade
    entityManager.persistAndFlush(article);

    // Vidage du contexte de persistence pour lire ensuite directement depuis la base, pas depuis le cache de niveau 1
    entityManager.clear();

    // Assertion principale : l'enregistrement de la pièce jointe a bien été inséré dans la table
    assertThat(attachmentRepository.count()).isEqualTo(1L);
}

Ce qu'il est important de noter ici, en tant qu'auteur de tests :

Nous sauvegardons la catégorie séparément, car nous ne supposons pas de cascade pour la catégorie. C'est un modèle normal : l'article référence une catégorie déjà existante.

Nous ajoutons la pièce jointe via addAttachment pour que le côté propriétaire (attachment.article) soit bien rempli.

Nous utilisons persistAndFlush car sans flush, JPA peut différer l'INSERT jusqu'à la fin de la transaction, et vous vérifierez non pas la base, mais les plans de la base.

Nous faisons un clear() pour que les vérifications suivantes ne « piochent » pas dans le contexte de persistence. Après () attachmentRepository.count() ira bien en base.

Si dans le mapping on enlève cascade = PERSIST (ou ALL), selon la façon dont la sauvegarde est configurée, vous obtiendrez soit une exception concernant une entité transiente, soit la pièce jointe ne sera simplement pas sauvegardée. Et ce n'est pas un « mauvais test » – c'est un test qui montre honnêtement que le comportement a changé.

6. Test sur orphanRemoval : l'enregistrement disparaît de la base

L'orphan removal, c'est comme un « nettoyage en profondeur » de la base : si l'objet ne fait plus partie du graphe, on le supprime. Mais pour que cela ne reste pas des mots, nous avons besoin d'un test : créer un article avec deux pièces jointes, en retirer une, faire flush/clear, et vérifier que l'enregistrement de pièce jointe a disparu de la table.

Pour que le test reste court et ne se transforme pas en thriller de 80 lignes, il est pratique d'avoir une petite méthode helper à l'intérieur de la classe de test, qui crée un article avec des pièces jointes. Ce n'est pas de la « magie cachée » si elle est honnête et transparente.

Par exemple, voici un helper très compact :

private Article persistArticleWithTwoAttachments(Category category) {
    // Création d'un article dans la catégorie donnée
    Article article = new Article("A", category);

    // Ajout de 2 pièces jointes pour avoir de quoi « orpheliner » en en supprimant une
    article.addAttachment(new ArticleAttachment("first.txt"));
    article.addAttachment(new ArticleAttachment("second.txt"));

    // Sauvegarde et flush immédiat pour que les pièces jointes reçoivent un id garanti
    return entityManager.persistAndFlush(article);
}

Et maintenant le test principal. Notez : nous devons prendre l'id de la pièce jointe supprimée avant la suppression, car après la suppression, l'objet en mémoire existe encore, mais dans la base il doit déjà avoir disparu.

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

@Test
void removeAttachment_shouldDeleteRow_viaOrphanRemoval() {
    // Le référentiel (catégorie) vit séparément, sans cascade
    Category java = entityManager.persist(new Category("java", "Java"));

    // Création d'un article et de 2 pièces jointes (les deux doivent être en base après persistAndFlush)
    Article article = persistArticleWithTwoAttachments(java);

    // Prise de l'id à l'avance : après orphanRemoval, l'enregistrement doit disparaître de la table
    Long removedId = article.getAttachments().getFirst().getId();

    // Suppression de la pièce jointe par la méthode correcte qui rompt les deux côtés de la relation
    article.removeAttachment(article.getAttachments().getFirst());

    // Synchronisation du contexte de persistence avec la base (sinon le DELETE peut être différé)
    entityManager.flush();

    // Vidage du contexte pour que findById aille en base, et non retourne un objet managé
    entityManager.clear();

    // Vérification sur la base : la ligne avec removedId ne doit plus exister
    assertThat(attachmentRepository.findById(removedId)).isEmpty();
}

Le sens du test est précisément de prouver l'effet au niveau de la base. Vérifier article.getAttachments().size() serait une vérification de la collection Java, pas du comportement de persistence. Oui, il est important que la collection ait diminué. Mais pour la qualité de la couche data, il est bien plus important que la ligne dans la table ait réellement disparu.

Si on enlève orphanRemoval = true, le résultat dépend de la façon exacte dont vous rompez la relation et de ce que permet le mapping en cours. Dans notre variante, removeAttachment() fait attachment.setArticle(null), et article_id est défini non-nullable, donc lors du flush vous obtiendrez plutôt une violation de contrainte qu'une ligne « discrètement restée ». Si la FK autorisait NULL ou si le côté propriétaire n'était pas du tout rompu, l'enregistrement pourrait rester en base comme orphelin. Et c'est là que la valeur de orphanRemoval est particulièrement visible : il transforme le sort ambigu d'une ligne enfant en une suppression prévisible.

Et encore une remarque pratique : l'orphan removal supprime l'enregistrement, mais ne supprime pas le fichier du système de fichiers. Dans ContentHub, ce sera la responsabilité d'une autre couche (storage/service), et le test de la couche data prouve seulement la correction des métadonnées de base de données. C'est normal : un test – une couche, sinon vous obtenez un mille-feuille d'aléas.

7. Cascade REMOVE lors de la suppression de l'article

Supprimer une pièce jointe de la collection est un scénario. Un autre scénario concret : l'article est supprimé entièrement (ou, à tout le moins, dans certains projets cela peut être le cas ; dans ContentHub, nous aurons plus souvent de l'archivage, mais il est important de comprendre le mécanisme du cascade). Si on supprime un article, il est logique d'attendre que les pièces jointes soient supprimées avec lui. C'est là que cascade = REMOVE (ou ALL) est utile.

Mais en même temps, on ne veut absolument pas que la suppression de l'article supprime la catégorie. La catégorie est un référentiel. C'est pourquoi il est bon d'écrire le test sur le cascade remove de manière à vérifier les deux côtés : les pièces jointes disparaissent, la catégorie reste.

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

@Test
void deleteArticle_shouldDeleteAttachments_butNotCategory() {
    // Création d'une catégorie-référentiel (sans suppression en cascade du côté de l'article)
    Category java = entityManager.persist(new Category("java", "Java"));

    // Création d'un article avec des pièces jointes
    Article article = persistArticleWithTwoAttachments(java);

    // Mémorisation de l'id de la catégorie pour vérifier qu'elle n'a pas été supprimée par erreur
    Long categoryId = java.getId();

    // Suppression de l'article : on s'attend à ce que les pièces jointes partent par cascade, et la catégorie reste
    articleRepository.delete(article);

    // Envoi forcé du DELETE en base
    entityManager.flush();

    // Vidage du cache pour que les vérifications ci-dessous lisent bien la base
    entityManager.clear();

    // Les pièces jointes doivent disparaître
    assertThat(attachmentRepository.count()).isZero();

    // La catégorie doit rester (sinon un cascade indu a été activé quelque part)
    assertThat(entityManager.find(Category.class, categoryId)).isNotNull();
}

Ce test est utile comme « filet de sécurité contre une annotation accidentelle ». Si quelqu'un ajoute un cascade sur la catégorie, le test pourrait soudainement devenir rouge, et vous comprendrez la cause avant que le « référentiel de catégories » ne disparaisse inopinément en production. C'est une classe de régressions très agréable : il vaut mieux les attraper par un test que par les utilisateurs.

8. Nuances : remove de la liste et état de la base

Parfois, on a l'impression que JPA devrait « deviner » ce que vous vouliez. Mais JPA n'est pas un télépathe. Elle regarde honnêtement les objets managés et leur état dans le contexte de persistence, et ce qui a changé exactement. Si vous avez supprimé un objet de la collection, mais que le côté propriétaire pointe toujours vers le parent, JPA peut décider que la relation n'est pas rompue. Si vous n'avez pas fait de flush, JPA peut même ne rien avoir envoyé du tout à la base. Si vous n'avez pas fait de clear, votre findById ultérieur peut retourner l'objet depuis le cache de premier niveau.

C'est pourquoi les tests data ont une discipline de fer : vous modifiez le graphe – vous faites un flush() ; vous voulez vérifier la base – vous faites un clear() ; vous vérifiez via le repository ou via entityManager.find après le nettoyage. Ce n'est pas un « rituel », c'est une façon d'éliminer les « heureux hasards » du test.

Il faut aussi garder à l'esprit la nature de @DataJpaTest : le test fonctionne le plus souvent dans une seule transaction, qui sera annulée à la fin. Par conséquent, la suppression dans le test n'est pas « définitive » – et c'est une bonne chose. Mais flush reste obligatoire si vous voulez voir les violations de contrainte et les vrais DELETE/INSERT dans le cadre du test en cours.

9. Erreurs typiques avec cascade et orphanRemoval

Erreur n°1 : mettre CascadeType.ALL « parce que c'est comme ça dans l'exemple », et ensuite s'étonner des effets secondaires.
ALL inclut non seulement PERSIST et REMOVE, mais aussi MERGE et d'autres opérations. Dans du code pédagogique, c'est pratique, mais dans un projet réel, cela peut entraîner des mises à jour inattendues des entités filles lors d'un save() du parent. La bonne habitude est d'abord de formuler le comportement souhaité, puis de choisir l'ensemble minimal de cascades pour ce comportement.

Erreur n°2 : cascader REMOVE sur les référentiels et les entités partagées.
Pour la relation Article → Category, le cascade de suppression est presque toujours nuisible. La catégorie n'« appartient » pas à un article, elle est utilisée par plusieurs articles. Si vous activez le cascade, la suppression d'un seul article pourrait théoriquement supprimer la catégorie (ou tenter de la supprimer et échouer à cause de la clé étrangère). Un test qui vérifie que « la catégorie est restée » est une excellente protection contre cela.

Erreur n°3 : modifier seulement le côté inverse de la relation (la collection), en oubliant le côté propriétaire (la clé étrangère).
Avec mappedBy = "article", la collection Article.attachments est le reflet de la relation, mais la clé étrangère est stockée dans ArticleAttachment.article. Si vous faites article.getAttachments().add(a) mais que vous ne faites pas a.setArticle(article), alors soit la relation n'apparaîtra pas en base, soit une violation de contrainte se produira. C'est exactement pourquoi les méthodes addAttachment/removeAttachment ne sont pas un caprice, mais un moyen de ne pas mener d'enquête du style « pourquoi mes pièces jointes ne se sont pas liées ».

Erreur n°4 : vérifier l'orphan removal par la taille de la collection Java sans vérifier la base.
attachments.size() peut être 1 parce que vous avez supprimé l'objet de la liste. Mais l'enregistrement dans la table peut rester. Dans un test de couche data, la preuve principale est une requête en base après flush() et clear() : par exemple, attachmentRepository.findById(id) doit retourner empty.

Erreur n°5 : oublier le flush et s'attendre à ce que la suppression « ait déjà eu lieu ».
JPA n'est pas obligée d'exécuter immédiatement du SQL pour chaque action que vous faites. Elle accumule les changements et les synchronise avec la base lors du flush ou à la fin de la transaction. Si vous supprimez une pièce jointe et vérifiez immédiatement le repository, le test peut donner des résultats étranges, surtout si le contexte de persistence contient encore les anciens objets. flush rapproche le moment de vérité et transforme le test en un test honnête.

1
Mission
Spring Test, niveau 17, leçon 1
Bloqué
Sauvegarde en cascade des pièces jointes d'un article
Sauvegarde en cascade des pièces jointes d'un article
1
Mission
Spring Test, niveau 17, leçon 1
Bloqué
Suppression de la piece jointe orpheline
Suppression de la piece jointe orpheline
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION