Relations et transactions
Cours · Spring Boot : écrire le serveur (école d'ingénieur), chapitre 6 · école d'ingénieur, informatique · le début du chapitre, la suite sur Adloun
La Librairie accepte des commandes depuis le chapitre 4, mais elle n'en garde aucune trace : elle vérifie le stock, calcule le total, renvoie un numéro… et oublie tout. Le stock, lui, ne bouge pas : on peut vendre indéfiniment le dernier exemplaire de Physique générale. Une vraie commande doit être enregistrée, avec ses lignes (tel livre, en telle quantité, à tel prix), et chaque vente doit diminuer le stock du livre vendu.
Deux difficultés apparaissent. La première est la relation entre objets : une commande a des lignes, une ligne désigne un livre. En Java, ce sont des références ; en base, des clés étrangères. La seconde est la cohérence : enregistrer la commande et diminuer les stocks sont plusieurs écritures ; si l'une échoue au milieu, la base ne doit pas rester à moitié modifiée. C'est le rôle des transactions. Ce chapitre traite les deux, et les deux pièges qui guettent toute application JPA : le chargement paresseux et le problème des « N+1 requêtes ».
Objectifs du chapitre
- Relier des entités par
@ManyToOneet@OneToMany, en sachant quel côté porte la clé étrangère. - Régler les cascades et la suppression des orphelins.
- Délimiter une transaction avec
@Transactional, savoir quand elle est validée ou annulée, et éviter les pièges de l'appel interne et des exceptions vérifiées. - Comprendre le chargement paresseux, la
LazyInitializationExceptionet le réglageopen-in-view. - Repérer et corriger le problème des N+1 requêtes.
- Enregistrer les commandes de la Librairie, en diminuant les stocks dans la même transaction.
Le projet du chapitre est code/ch6/librairie, copie de celui du chapitre précédent ; les démonstrations sur les transactions sont dans code/ch6/demo-transactions, et deux variantes volontairement fautives dans code/ch6/erreurs.
6.1 Deux entités liées : la commande et ses lignes
Une commande porte le nom et l'adresse du client, le mode de retrait, la date et le total. Elle contient une ou plusieurs lignes ; chaque ligne désigne un livre, une quantité, et le prix unitaire au moment de la vente (si le prix du catalogue change demain, la commande d'hier ne doit pas changer).
Les flèches sont les clés étrangères : c'est la table ligne_commande qui contient le numéro de sa commande et l'ISBN de son livre. Plusieurs lignes pour une commande, plusieurs lignes pour un livre : ce sont deux relations « plusieurs à un ».
6.1.1 La ligne : une relation ManyToOne
@Entity
public class LigneCommande {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** Le côté PROPRIÉTAIRE de la relation : c'est lui qui porte la clé étrangère. */
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "commande_numero")
private Commande commande;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "livre_isbn")
private Livre livre;
@Column(nullable = false)
private int quantite;
/** Le prix au moment de la vente : il ne bougera plus si le catalogue change. */
@Column(nullable = false, precision = 8, scale = 2)
private BigDecimal prixUnitaire;
protected LigneCommande() {
}
public LigneCommande(Livre livre, int quantite) {
this.livre = livre;
this.quantite = quantite;
this.prixUnitaire = livre.getPrix();
}
void rattacherA(Commande commande) {
this.commande = commande;
}
public BigDecimal montant() {
return prixUnitaire.multiply(BigDecimal.valueOf(quantite));
}
public Commande getCommande() { return commande; }
public Livre getLivre() { return livre; }
public int getQuantite() { return quantite; }
public BigDecimal getPrixUnitaire() { return prixUnitaire; }
}
@ManyToOne déclare qu'un objet référence une autre entité, et que plusieurs objets peuvent référencer la même. Le champ devient une colonne de clé étrangère, nommée par @JoinColumn. optional = false la rend obligatoire (not null). FetchType.LAZY demande de ne charger l'entité référencée qu'au premier accès (section 6.4) ; sans cette précision, une relation « à un » est chargée immédiatement (EAGER), ce qui est rarement souhaitable.
6.1.2 La commande : OneToMany et le côté propriétaire
@Entity
public class Commande {
/** Le numéro de commande : une séquence qui commence à 1001, comme le serveur d'essai. */
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "numero")
@SequenceGenerator(name = "numero", sequenceName = "commande_numero", initialValue = 1001, allocationSize = 1)
private Long numero;
@Column(nullable = false, length = 100)
private String nom;
@Column(nullable = false, length = 200)
private String courriel;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 10)
private Retrait retrait;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal total = BigDecimal.ZERO;
@Column(nullable = false)
private Instant passeeLe = Instant.now();
@OneToMany(mappedBy = "commande", cascade = CascadeType.ALL, orphanRemoval = true)
private List<LigneCommande> lignes = new ArrayList<>();
protected Commande() {
}
public Commande(String nom, String courriel, Retrait retrait) {
this.nom = nom;
this.courriel = courriel;
this.retrait = retrait;
}
/** Ajoute une ligne en tenant À JOUR LES DEUX CÔTÉS de la relation. */
public void ajouterLigne(LigneCommande ligne) {
lignes.add(ligne);
ligne.rattacherA(this);
total = total.add(ligne.montant());
}
public void ajouterFrais(BigDecimal frais) {
total = total.add(frais);
}
public Long getNumero() { return numero; }
Côté commande, la liste des lignes est déclarée par @OneToMany(mappedBy = "commande"). Ce mappedBy est la clé de tout le chapitre.
Une relation entre deux entités n'est représentée en base que par une clé étrangère, mais elle peut être visible des deux côtés en Java. Le côté propriétaire est celui dont le champ correspond à la clé étrangère : ici, LigneCommande.commande. L'autre côté, marqué mappedBy, est un simple reflet : Hibernate ne le lit jamais pour écrire en base. Si l'on ajoute une ligne à commande.getLignes() sans renseigner ligne.commande, la clé étrangère reste vide (exercice 6.3).
D'où la méthode ajouterLigne, qui tient les deux côtés à jour, et le constructeur de LigneCommande, qui ne reçoit pas la commande : seule la commande peut se rattacher une ligne. Le reste de la déclaration règle le cycle de vie des lignes :
cascade = CascadeType.ALL: les opérations faites sur la commande (enregistrer, supprimer) sont répercutées sur ses lignes ; un seulsave(commande)enregistre tout ;orphanRemoval = true: une ligne retirée de la liste est supprimée de la base ; une ligne n'existe pas sans commande.
La cascade ne va que du parent vers l'enfant
On ne met jamais de cascade sur un @ManyToOne : supprimer une ligne de commande ne doit évidemment pas supprimer le livre ! Les cascades se placent du côté du « tout » (la commande) vers ses « parties » (ses lignes), là où les parties n'ont pas d'existence propre.
Enfin, le numéro de commande. Le serveur d'essai numérotait à partir de 1001 ; une séquence de la base fait de même, de façon sûre même si deux commandes arrivent au même instant. Au démarrage, Hibernate crée la séquence, les tables, puis les clés étrangères :
Hibernate: create sequence commande_numero start with 1001 increment by 1
Hibernate: create table commande (total numeric(10,2) not null, numero bigint not null, passee_le timestamp(6) with time zone not null, nom varchar(100) not null, courriel varchar(200) not null, retrait enum ('BOUTIQUE','CAMPUS') not null, primary key (numero))
Hibernate: create table ligne_commande (prix_unitaire numeric(8,2) not null, quantite integer not null, commande_numero bigint not null, id bigint generated by default as identity, livre_isbn varchar(13) not null, primary key (id))
Hibernate: create table livre (nouveaute boolean not null, prix numeric(8,2) not null, stock integer not null, isbn varchar(13) not null, rayon varchar(50) not null, auteur varchar(100) not null, image varchar(100), titre varchar(200) not null, primary key (isbn))
Hibernate: alter table if exists ligne_commande add constraint FKj563liwv9yrxkxr0dj6lgs11y foreign key (commande_numero) references commande
Hibernate: alter table if exists ligne_commande add constraint FKeb9bqwmrvnbnif5phlon5f05m foreign key (livre_isbn) references livre
(On remarque au passage que le mode de retrait, déclaré @Enumerated(EnumType.STRING), est rangé sous forme de texte : 'BOUTIQUE' ou 'CAMPUS'. Sans cette précision, JPA rangerait le rang de la constante (0 ou 1), et réordonner l'énumération corromprait silencieusement toutes les commandes.)
La suite du cours est sur Adloun
Le chapitre en entier, formules et figures comprises, avec le tuteur, les fiches, les QCM et les exercices.
Lire la suite du coursEn accès libre, sans inscription.