11. Hibernate
Prérequis : la version 7 de l'application [Impots], présentée à la fin de ce chapitre, réutilise les modules [impots-dao], [impots-metier] et [impots-ui] de la version 5 (chapitre 6). Avant de construire ou d'ouvrir ses projets, il faut avoir installé la version 5 dans le dépôt Maven local par la commande [mvn install] tapée dans le dossier [06-architectures/ImpotsV5] (cf. chapitre 6, section sur [mvn install]).
Le chapitre précédent a montré comment une application Java dialogue avec une base de données au travers d'un pilote JDBC : on ouvre une connexion, on écrit des ordres SQL, on lit les lignes du résultat colonne par colonne et on les recopie dans des objets. Ce travail de recopie, des lignes vers les objets et des objets vers les ordres [INSERT], [UPDATE] et [DELETE], est répétitif. Il peut être confié à un outil : un ORM (« Object-Relational Mapper »). Ce chapitre présente l'ORM le plus utilisé du monde Java, Hibernate, et la norme qu'il met en œuvre, Jakarta Persistence.
Le chapitre reprend la base exemple des articles du chapitre 10 et se termine par la version 7 de l'application de calcul de l'impôt, dont la couche [dao] utilise Hibernate.
11.1. Qu'est-ce qu'un ORM ?
11.1.1. Des objets et des tables
Une application Java manipule des objets : un article a un nom, un prix, un stock ; une commande a un client et une liste de lignes, chacune faisant référence à un article. Une base de données relationnelle range ces mêmes informations dans des tables : une ligne de la table [articles] par article, une ligne de la table [commandes] par commande, les liens entre lignes étant exprimés par des clés étrangères. Les deux mondes ne coïncident pas tout à fait :
- un objet référence directement d'autres objets ([commande.getClient()]) ; une ligne contient la valeur de la clé primaire d'une autre ligne ([client_id]) ;
- un objet peut contenir une collection ([commande.getLignes()]) ; une table ne contient que des valeurs simples, la collection est représentée par les lignes d'une autre table qui pointent vers la commande ;
- les types Java ([String], [BigDecimal], [LocalDate], [boolean]) et les types SQL ([varchar(30)], [decimal(10,2)], [date], [bit]) ne sont pas les mêmes ;
- on interroge les objets avec Java (et l'API Stream) et les tables avec SQL.
Un ORM fait le lien entre les deux mondes. On lui décrit une fois pour toutes la correspondance entre les classes et les tables (le modèle, ou « mapping ») ; il se charge ensuite :
- de traduire en ordres SQL [SELECT] des requêtes écrites dans un langage qui parle d'objets (HQL) ou construites par du code Java (l'API Criteria) ;
- de créer les objets à partir des lignes lues (on parle de matérialisation) ;
- de mémoriser les objets lus et de détecter ceux qui ont été modifiés, ajoutés ou supprimés ;
- de générer les ordres [INSERT], [UPDATE] et [DELETE] qui répercutent ces changements dans la base ;
- éventuellement de créer et de faire évoluer le schéma de la base (tables, colonnes, index) à partir des classes.
L'architecture d'une application qui utilise Hibernate avec MySQL est la suivante :

- [1] : l'application. Sa couche [dao] ne dialogue plus directement avec le pilote JDBC mais avec Hibernate, au travers d'un objet [Session] et de requêtes HQL ;
- [2] : Hibernate, indépendant du SGBD : modèle, traduction des requêtes, suivi des modifications, génération du schéma ;
- [3] : le dialecte propre au SGBD (ici [MySQLDialect]) : il connaît la syntaxe SQL du SGBD, ses types de colonnes et ses fonctions. Les dialectes des principaux SGBD font partie d'Hibernate lui-même : il n'y a rien à ajouter au projet ;
- [4] : le pilote JDBC du SGBD, celui du chapitre 10 (MySQL Connector/J) : Hibernate ne remplace pas JDBC, il l'utilise ;
- [5] : le SGBD.
11.1.2. Jakarta Persistence et Hibernate
Hibernate est né en 2001, à l'initiative de Gavin King, en réaction à la lourdeur des « entity beans » de la plate-forme Java EE de l'époque. Son succès a été tel que ses idées ont été reprises dans une norme, JPA (Java Persistence API), parue en 2006 avec Java EE 5. Lorsque Java EE a été confié à la fondation Eclipse et renommé Jakarta EE, JPA est devenue Jakarta Persistence, et ses paquetages sont passés de [javax.persistence] à [jakarta.persistence] (version 3.0, 2020). La version actuelle est Jakarta Persistence 3.2 (2024).
Jakarta Persistence est une spécification : un ensemble d'interfaces ([EntityManagerFactory], [EntityManager]...), d'annotations ([@Entity], [@Id], [@Column]...) et un langage de requêtes, JPQL. Elle est mise en œuvre par plusieurs produits : Hibernate ORM (le plus utilisé, celui de Spring Boot et de Quarkus), EclipseLink (l'implémentation de référence)... Le cours utilise Hibernate ORM 7.4.5, qui met en œuvre Jakarta Persistence 3.2.
Hibernate offre deux façons de l'utiliser :
- au travers des seules interfaces de la norme : le code ne dépend alors que de [jakarta.persistence], et l'on pourrait en principe changer d'implémentation ;
- au travers de son API propre (« native »), plus riche, dont les interfaces étendent celles de la norme : une [SessionFactory] est une [EntityManagerFactory], une [Session] est un [EntityManager].
Nous utilisons l'API propre d'Hibernate, comme le recommande sa documentation : on dispose de toutes les méthodes de la norme ([persist], [find], [merge], [remove]...) et des compléments d'Hibernate. Les annotations, elles, sont toujours celles de la norme. Le tableau suivant donne les correspondances :
Jakarta Persistence | Hibernate | Rôle |
[EntityManagerFactory] | [SessionFactory] | la configuration d'Hibernate pour une base : un objet lourd, créé une fois, partagé par toute l'application |
[EntityManager] | [Session] | une session de travail avec la base : un objet léger, de courte durée |
[EntityTransaction] | [Transaction] | une transaction |
JPQL | HQL | le langage de requêtes ; HQL est un sur-ensemble de JPQL |
[CriteriaBuilder] | [HibernateCriteriaBuilder] | la construction de requêtes par du code Java |
11.1.3. Les bibliothèques
Les bibliothèques utilisées dans ce chapitre sont les suivantes (elles sont récapitulées dans l'annexe sur les dépendances Maven) :
Dépendance Maven | Rôle |
[org.hibernate.orm:hibernate-core] 7.4.5.Final | Hibernate lui-même ; il amène l'API de Jakarta Persistence 3.2 ([jakarta.persistence-api]) et ses propres dépendances (Byte Buddy pour la génération de classes, ANTLR pour l'analyse des requêtes, JBoss Logging pour la journalisation...) |
[com.mysql:mysql-connector-j] 9.7.0 | le pilote JDBC de MySQL, celui du chapitre 10 |
[ch.qos.logback:logback-classic] 1.5.38 | Logback, qui recevra le journal d'Hibernate |
Hibernate écrit son journal (les ordres SQL exécutés, les avertissements...) au travers de JBoss Logging, une petite bibliothèque qui ne fait que transmettre les messages au système de journalisation qu'elle trouve dans le classpath : Log4j 2, ou Logback (au travers de SLF4J), ou à défaut celui du JDK, [java.util.logging]. Sans configuration, ce dernier affiche chaque message sur deux lignes, en rouge dans la plupart des consoles, ce qui rend la sortie des programmes peu lisible. Nous ajoutons donc Logback à nos projets et le configurons avec un fichier [logback.xml].
11.1.4. Le code d'abord ou la base d'abord
Le modèle (la correspondance entre classes et tables) peut être construit de deux façons :
- le code d'abord (« code first ») : on écrit les classes Java, et Hibernate en déduit le schéma de la base, qu'il peut créer lui-même. C'est l'approche de ce chapitre ;
- la base d'abord (« database first ») : la base existe déjà, et l'on génère les classes à partir de son schéma, avec les outils de rétro-ingénierie d'Hibernate (Hibernate Tools) ou ceux des environnements de développement (IntelliJ IDEA sait générer les entités à partir des tables d'une base). C'est l'approche des applications qui doivent exploiter une base existante. Le code généré est du même type que celui que nous écrirons à la main.
11.2. Un premier exemple
11.2.1. Le projet
Les exemples du chapitre sont les modules d'un projet Maven multi-modules (cf. chapitre 6), dans le dossier [11-hibernate] :
| 11-hibernate/
pom.xml le projet parent
outils/ bibliothèque : le journal SQL compact
articles/ bibliothèque : l'entité Article et la base [hbarticles]
premier/ un premier exemple
modele/ le modèle : annotations, fichier XML, script de création
crud/ persist, find, merge, remove
hql/ les requêtes HQL
criteria/ les requêtes de l'API Criteria
relations/ les relations entre entités
clients/ l'évolution du schéma de la base
asynchrone/ Hibernate et les threads virtuels
transactions/ les transactions
concurrence/ la concurrence optimiste
sqlbrut/ le SQL natif
majenmasse/ les mises à jour en masse
injection/ Hibernate et Spring
ImpotsV7/ l'application de calcul de l'impôt, version 7 (projet distinct)
|
Le projet parent fixe les versions des bibliothèques et des extensions de Maven pour tous les modules :
| <?xml version="1.0" encoding="UTF-8"?>
<!-- les exemples du chapitre 11 : Hibernate ; un module Maven par exemple -->
<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>exemples.hibernate</groupId>
<artifactId>hibernate</artifactId>
<version>1.0</version>
<packaging>pom</packaging>
<!-- les modules, dans l'ordre de leurs dépendances -->
<modules>
<module>outils</module>
<module>articles</module>
<module>premier</module>
<module>modele</module>
<module>crud</module>
<module>hql</module>
<module>criteria</module>
<module>relations</module>
<module>clients</module>
<module>asynchrone</module>
<module>transactions</module>
<module>concurrence</module>
<module>sqlbrut</module>
<module>majenmasse</module>
<module>injection</module>
</modules>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<!-- les versions des bibliothèques, communes à tous les modules -->
<dependencyManagement>
<dependencies>
<!-- les deux bibliothèques communes du chapitre -->
<dependency>
<groupId>exemples.hibernate</groupId>
<artifactId>outils</artifactId>
<version>1.0</version>
</dependency>
<dependency>
<groupId>exemples.hibernate</groupId>
<artifactId>articles</artifactId>
<version>1.0</version>
</dependency>
<!-- Hibernate ORM (Jakarta Persistence 3.2) -->
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>7.4.5.Final</version>
</dependency>
<!-- le pilote JDBC de MySQL -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>9.7.0</version>
</dependency>
<!-- la journalisation : Logback (au travers de SLF4J) -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.38</version>
</dependency>
<!-- Spring Framework : injection de dépendances et transactions -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>7.0.9</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-orm</artifactId>
<version>7.0.9</version>
</dependency>
<!-- le pool de connexions HikariCP -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>7.0.2</version>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.15.0</version>
<configuration>
<parameters>true</parameters>
<compilerArgs>
<arg>-Xlint:all</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.6</version>
</plugin>
<!-- mvn exec:java exécute la classe ${exec.mainClass}, définie par chaque module -->
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.1</version>
<configuration>
<mainClass>${exec.mainClass}</mainClass>
</configuration>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>
|
- lignes 14-30 : les modules, dans l'ordre où Maven doit les construire : les deux bibliothèques communes d'abord ;
- lignes 38-87 : la section [dependencyManagement] fixe la version de chaque bibliothèque. Un module qui en a besoin la cite sans version ;
- lignes 108-116 : l'extension [exec-maven-plugin] exécutera la classe désignée par la propriété [exec.mainClass], que chaque module définit.
Le premier exemple est le module [premier]. Il est indépendant des autres :
| <?xml version="1.0" encoding="UTF-8"?>
<!-- un premier exemple : une entité, une base, quelques ordres SQL générés par Hibernate -->
<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>exemples.hibernate</groupId>
<artifactId>hibernate</artifactId>
<version>1.0</version>
</parent>
<artifactId>premier</artifactId>
<properties>
<!-- le programme exécuté par mvn exec:java -->
<exec.mainClass>premier.Main</exec.mainClass>
</properties>
<dependencies>
<!-- Hibernate et le pilote JDBC de MySQL -->
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<!-- Logback : il reçoit le journal d'Hibernate -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
|
- lignes 8-12 : le projet parent ;
- ligne 18 : la classe principale du module ;
- lignes 22-35 : Hibernate, le pilote JDBC de MySQL et Logback, sans numéro de version (il est donné par le projet parent).
Pour exécuter un exemple, on construit une fois pour toutes l'ensemble des modules dans le dossier [11-hibernate] ([mvn install] installe les bibliothèques communes dans le dépôt Maven local), puis on se place dans le dossier du module :
| > cd 11-hibernate
> mvn install
> cd premier
> mvn compile exec:java
|
La base de données utilisée est la base [hbpremier] du serveur MySQL local, celui de Laragon (cf. annexes). Elle n'a pas besoin d'exister : MySQL la créera.
11.2.2. L'entité [Article]
Une classe dont les objets sont enregistrés dans une table est appelée une entité. Notre première entité est un article du magasin, comme dans la table [articles] du chapitre 10 :
| package premier;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.math.BigDecimal;
// un article du magasin : une classe Java ordinaire, plus quelques annotations
@Entity
public class Article {
// la clé primaire, générée par le SGBD
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
// les autres colonnes de la table
private String nom;
private BigDecimal prix;
private int stockActuel;
private int stockMinimum;
// le constructeur sans paramètre exigé par Hibernate
protected Article() {
}
// le constructeur utilisé par le programme
public Article(String nom, BigDecimal prix, int stockActuel, int stockMinimum) {
this.nom = nom;
this.prix = prix;
this.stockActuel = stockActuel;
this.stockMinimum = stockMinimum;
}
// affichage
@Override
public String toString() {
return "[%d, %s, %.2f, %d, %d]".formatted(id, nom, prix, stockActuel, stockMinimum);
}
}
|
- ligne 10 : l'annotation [@Entity] (paquetage [jakarta.persistence]) fait de la classe une entité. C'est presque une classe Java ordinaire, qui ne dérive d'aucune classe d'Hibernate : on parle de POJO (« Plain Old Java Object ») ;
- lignes 14-16 : la clé primaire est désignée par l'annotation [@Id]. Il n'y a pas de convention de nom : l'annotation est obligatoire. [@GeneratedValue(strategy = GenerationType.IDENTITY)] indique que ses valeurs seront générées par le SGBD (colonne [AUTO_INCREMENT] de MySQL). Son type est [Integer] et non [int] : la valeur [null] signifie « pas encore enregistré dans la base » ;
- lignes 18-21 : chaque champ de la classe devient une colonne de même nom. Comme l'annotation [@Id] est placée sur un champ, Hibernate accède directement aux champs (il les lit et les écrit par réflexion, même s'ils sont privés) : la classe n'a pas besoin d'accesseurs ;
- lignes 24-25 : Jakarta Persistence exige un constructeur sans paramètre, qu'Hibernate utilise pour créer les objets lus dans la base. Il peut être protégé ([protected]) : le code de l'application ne l'utilisera pas ;
- lignes 28-33 : le constructeur utilisé par le programme. On ne donne pas d'identifiant : il sera généré par le SGBD ;
- lignes 36-39 : un affichage commode de l'article. Le format [%.2f] affiche le prix avec deux décimales, selon les conventions de la culture courante (une virgule en France).
Une entité ne peut pas être un record (chapitre 4) : Hibernate doit pouvoir créer l'objet vide puis remplir ses champs, et modifier ces champs quand l'application les change, ce que l'immuabilité d'un record interdit. Les records restent très utiles avec Hibernate, pour les résultats des requêtes qui ne sont pas des entités (les projections), comme nous le verrons.
11.2.3. La [SessionFactory] et la [Session]
Le programme utilise trois objets d'Hibernate :
- la [SessionFactory] représente Hibernate configuré pour une base : la liste des entités, la base, les options. Sa construction est coûteuse (Hibernate analyse les entités, se connecte au serveur pour connaître sa version, prépare le SQL des opérations courantes) : on en crée une seule par base et par application, au démarrage. Elle est thread-safe : tous les threads de l'application peuvent l'utiliser ;
- la [Session] représente une session de travail avec la base. Elle obtient une connexion quand elle en a besoin, mémorise les entités qu'elle a lues ou enregistrées et sait répercuter leurs modifications dans la base. C'est un objet léger, de courte durée, qui n'est pas thread-safe : on en ouvre une pour chaque unité de travail et on la ferme ensuite ;
- la [Transaction] : avec Hibernate, toute écriture dans la base se fait dans une transaction explicite, ouverte par [session.beginTransaction()] et terminée par [commit] (validation) ou [rollback] (annulation).
[SessionFactory] et [Session] implémentent l'interface [AutoCloseable] : on les crée dans une instruction [try] avec ressources, qui les ferme à la fin du bloc.
11.2.4. Le programme
| package premier;
import java.math.BigDecimal;
import java.util.List;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
import org.hibernate.jpa.HibernatePersistenceConfiguration;
public class Main {
public static void main(String[] args) {
// la SessionFactory : Hibernate configuré pour la base [hbpremier]
try (SessionFactory sessionFactory = new HibernatePersistenceConfiguration("premier")
// les entités
.managedClass(Article.class)
// la base de données ; MySQL la crée si elle n'existe pas
.jdbcUrl("jdbc:mysql://localhost:3306/hbpremier?createDatabaseIfNotExist=true")
.jdbcCredentials("root", "")
// affichage des ordres SQL, mis en forme, sans couleurs
.showSql(true, true, false)
.createEntityManagerFactory()) {
// les tables sont supprimées puis recréées à partir des entités
sessionFactory.getSchemaManager().drop(false);
sessionFactory.getSchemaManager().create(false);
// ajout de trois articles, dans une transaction
try (Session session = sessionFactory.openSession()) {
Transaction transaction = session.beginTransaction();
IO.println("--- persist");
session.persist(new Article("vélo", BigDecimal.valueOf(500), 10, 5));
session.persist(new Article("pompe", BigDecimal.valueOf(10), 10, 2));
session.persist(new Article("arc", BigDecimal.valueOf(600), 4, 1));
IO.println("--- commit");
transaction.commit();
}
// lecture de la table, avec une nouvelle session
IO.println("--- lecture des articles");
try (Session session = sessionFactory.openSession()) {
List<Article> articles = session
.createSelectionQuery("from Article order by nom", Article.class)
.getResultList();
for (Article article : articles) {
IO.println(article);
}
}
}
}
}
|
- lignes 14-22 : la création de la [SessionFactory]. Il existe plusieurs façons de configurer Hibernate (nous les citons plus loin) ; nous utilisons la classe [HibernatePersistenceConfiguration] d'Hibernate 7, version enrichie de la classe [PersistenceConfiguration] apparue avec Jakarta Persistence 3.2, qui permet de tout configurer par du code. Son paramètre est le nom de l'unité de persistance (« persistence unit »), un nom libre qui apparaît dans les messages d'Hibernate. Chaque méthode rend la configuration elle-même, ce qui permet d'enchaîner les appels ;
- ligne 16 : la liste des entités ;
- lignes 18-19 : l'URL JDBC de la base [hbpremier], de la même forme qu'au chapitre 10, et l'utilisateur ([root], sans mot de passe sous Laragon). Le paramètre [createDatabaseIfNotExist=true] de l'URL est une option du pilote MySQL : il crée la base si elle n'existe pas ;
- ligne 21 : [showSql(true, true, false)] demande à Hibernate d'écrire sur la console chaque ordre SQL qu'il exécute, mis en forme sur plusieurs lignes (deuxième paramètre), sans couleurs (troisième paramètre). Voir le SQL envoyé au serveur est le meilleur moyen de comprendre ce que fait Hibernate : nous le ferons dans tous les exemples ;
- ligne 22 : [createEntityManagerFactory] construit la [SessionFactory] (le nom de la méthode est celui de la norme ; le type rendu est celui d'Hibernate) ;
- lignes 25-26 : l'objet [SchemaManager] de la [SessionFactory] (Jakarta Persistence 3.2) gère les tables des entités : [drop(false)] les supprime si elles existent, [create(false)] les crée (le paramètre [false] indique qu'il ne faut pas supprimer ou créer la base elle-même). C'est commode pour des exemples et des tests ; une application réelle fait évoluer sa base autrement, comme nous le verrons ;
- ligne 29 : [openSession()] ouvre une session ;
- ligne 30 : le début d'une transaction ;
- lignes 32-34 : [persist] confie trois nouveaux articles à la session : ils seront enregistrés dans la base. On ne donne pas de valeur à l'identifiant, qui sera généré par le SGBD ;
- ligne 36 : [commit] valide la transaction. Auparavant, la session envoie au serveur les ordres SQL qui restent nécessaires pour enregistrer les changements qu'elle a mémorisés : on dit qu'elle se synchronise avec la base (« flush ») ;
- lignes 42-44 : une requête écrite en HQL, le langage de requêtes d'Hibernate : les articles par ordre alphabétique de leur nom. On y parle de l'entité [Article] et de son champ [nom], pas de la table. Le second paramètre de [createSelectionQuery] est le type des résultats : [getResultList] rend une [List<Article>].
Le fichier [src/main/resources/logback.xml] du module configure Logback. Il affiche les messages d'Hibernate de niveau [INFO] ou supérieur, sur une ligne chacun (niveau, nom du journal, message) :
| <?xml version="1.0" encoding="UTF-8"?>
<!-- configuration de Logback : messages d'information d'Hibernate, sur une ligne chacun -->
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%-5level %logger - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
|
Résultats :
| > mvn compile exec:java
INFO org.hibernate.orm.jpa - HHH008540: Processing PersistenceUnitInfo [name: premier]
INFO org.hibernate.orm.core - HHH000001: Hibernate ORM core version 7.4.5.Final
WARN org.hibernate.orm.connections.pooling - HHH10001002: Using built-in connection pool (not intended for production use)
INFO org.hibernate.orm.connections.pooling - HHH10001005: Database info:
Database JDBC URL [jdbc:mysql://localhost:3306/hbpremier?createDatabaseIfNotExist=true]
Database driver: MySQL Connector/J
Database dialect: MySQLDialect
Database version: 8.0.46
Default catalog/schema: hbpremier/undefined
Autocommit mode: false
Isolation level: REPEATABLE_READ
JDBC fetch size: none
Pool: DriverManagerConnectionProvider
Minimum pool size: 1
Maximum pool size: 20
INFO org.hibernate.orm.core - HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
Hibernate:
drop table if exists Article
Hibernate:
create table Article (
id integer not null auto_increment,
nom varchar(255),
prix decimal(38,2),
stockActuel integer not null,
stockMinimum integer not null,
primary key (id)
) engine=InnoDB
--- persist
Hibernate:
insert
into
Article
(nom, prix, stockActuel, stockMinimum)
values
(?, ?, ?, ?)
Hibernate:
insert
into
Article
(nom, prix, stockActuel, stockMinimum)
values
(?, ?, ?, ?)
Hibernate:
insert
into
Article
(nom, prix, stockActuel, stockMinimum)
values
(?, ?, ?, ?)
--- commit
--- lecture des articles
Hibernate:
select
a1_0.id,
a1_0.nom,
a1_0.prix,
a1_0.stockActuel,
a1_0.stockMinimum
from
Article a1_0
order by
a1_0.nom
[3, arc, 600,00, 4, 1]
[2, pompe, 10,00, 10, 2]
[1, vélo, 500,00, 10, 5]
|
Commentaires :
- lignes 2-17 : les messages d'information d'Hibernate : le nom de l'unité de persistance, la version d'Hibernate, et les caractéristiques de la base (URL, pilote, dialecte choisi, version du serveur, niveau d'isolation des transactions, taille du pool de connexions). Hibernate a interrogé le serveur pour choisir le dialecte [MySQLDialect] adapté à sa version ;
- ligne 4 : un avertissement. Hibernate gère lui-même un petit pool de connexions, qui n'est pas destiné à une application en production. Nous verrons, avec Spring, comment lui fournir le pool HikariCP du chapitre 10 ; pour nos exemples, le pool intégré suffit ;
- lignes 18-28 : la suppression puis la création de la table [Article] à partir de l'entité : le nom de la table est celui de l'entité, les colonnes portent le nom des champs, la colonne [id] est la clé primaire, à valeur générée ([auto_increment]). Les colonnes des champs de type primitif ([int]) sont [not null] : un [int] ne peut pas valoir [null]. Celles des champs de type référence ([String], [BigDecimal]) acceptent la valeur [NULL] ;
- on remarquera les types choisis par défaut : [varchar(255)] pour une chaîne, et [decimal(38,2)] pour un [BigDecimal] (38 chiffres dont 2 après la virgule). Ils sont rarement ceux que l'on veut : le paragraphe suivant montre comment les préciser ;
- lignes 29-51 : les trois ordres [INSERT] paramétrés ont été exécutés dès l'appel de [persist], avant le [commit]. C'est une conséquence de la génération de la clé par le SGBD ([IDENTITY]) : la session doit connaître l'identifiant d'une entité dès qu'elle la prend en charge, et pour l'obtenir elle doit exécuter l'insertion (le pilote JDBC rend la clé générée, cf. chapitre 10). Avec d'autres modes de génération, les insertions sont différées jusqu'à la synchronisation de la session. Les valeurs des paramètres ([?]) ne sont pas affichées : nous verrons comment les faire apparaître ;
- lignes 53-63 : la requête HQL [from Article order by nom] a été traduite en un ordre [SELECT ... ORDER BY]. Hibernate a donné l'alias [a1_0] à la table et a créé un objet [Article] par ligne lue (lignes 64-66).
Nous n'avons écrit aucun ordre SQL, aucun code de lecture de colonne : c'est tout l'intérêt d'un ORM. On voit aussi qu'il ne dispense pas de connaître SQL : pour savoir si le SQL généré est correct et efficace, il faut savoir le lire.
11.3. Le modèle
Hibernate construit son modèle à partir des entités, en trois étapes :
- il applique d'abord ses règles par défaut : tout ce qui n'est pas précisé reçoit une valeur raisonnable (on parle de « configuration par exception ») ;
- il les complète ou les corrige avec les annotations placées sur les entités ;
- il applique enfin les fichiers de correspondance XML ([orm.xml]), qui peuvent décrire entièrement une classe sans annotation, ou modifier ce que disent ses annotations.
11.3.1. Les règles par défaut
Nous en avons vu l'essentiel dans le premier exemple :
- une entité porte l'annotation [@Entity] et a un identifiant désigné par [@Id] ; ces deux annotations sont obligatoires ;
- la place de l'annotation [@Id] fixe le mode d'accès : placée sur un champ, Hibernate lit et écrit directement les champs ; placée sur un accesseur ([getId]), il passe par les accesseurs ([getX]/[setX]). L'accès par les champs est le plus courant : il laisse la classe libre d'avoir les méthodes qu'elle veut ;
- tout champ non statique est enregistré dans une colonne, sauf s'il porte l'annotation [@Transient] ou le mot clé [transient] ;
- le nom de la table est celui de l'entité (le nom simple de la classe), le nom d'une colonne celui du champ. Ces noms passent ensuite par une stratégie de nommage : celle d'Hibernate garde les noms tels quels, une autre, que nous essaierons ci-dessous, les transforme en « snake case » ([stockActuel] devient [stock_actuel]). Spring Boot utilise cette dernière par défaut ;
- une colonne d'un type primitif ([int], [double], [boolean]) est [NOT NULL] ; une colonne d'un type référence ([Integer], [String], [BigDecimal], [LocalDate]) accepte la valeur [NULL] ;
- les types SQL sont choisis par le dialecte. Avec MySQL : [integer] pour [int], [bigint] pour [long], [bit] pour [boolean], [varchar(255)] pour [String], [decimal(38,2)] pour [BigDecimal], [date] pour [LocalDate], [datetime(6)] (à la microseconde) pour [LocalDateTime]. Une énumération est enregistrée par défaut par son numéro d'ordre (cf. chapitre 3) ; l'annotation [@Enumerated(EnumType.STRING)] l'enregistre par son nom, ce qui est plus sûr ;
- un champ qui fait référence à une autre entité, ou à une collection d'entités, définit une relation (nous le verrons plus loin).
11.3.2. Les annotations et le fichier de correspondance XML
Le module [modele] définit deux entités configurées de deux façons différentes. La première l'est par des annotations :
| package modele;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;
import java.math.BigDecimal;
import java.time.LocalDate;
// une entité configurée par des annotations
@Entity
@Table(name = "produits")
public class Produit {
// la clé primaire : @Id, quel que soit son nom
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer code;
// chaîne obligatoire de 50 caractères au plus
@Column(nullable = false, length = 50)
private String nom;
// chaîne facultative (colonne NULL) de 200 caractères au plus
@Column(length = 200)
private String description;
// nom et type SQL de la colonne imposés
@Column(name = "prix_ht", nullable = false, precision = 10, scale = 2)
private BigDecimal prixHT;
// une date facultative : un objet peut être null
private LocalDate dateRetrait;
// un champ sans colonne
@Transient
private boolean selectionne;
protected Produit() {
}
// une méthode n'est jamais enregistrée : Hibernate accède ici aux champs, pas aux méthodes
public BigDecimal getPrixTTC() {
return prixHT.multiply(new BigDecimal("1.2"));
}
public boolean isSelectionne() {
return selectionne;
}
}
|
- lignes 3-9 : les annotations de Jakarta Persistence ;
- ligne 15 : [@Table] donne le nom de la table ;
- lignes 19-21 : la clé primaire, désignée par [@Id] quel que soit son nom ;
- lignes 24-25 : [@Column] précise la colonne : [nullable = false] la rend obligatoire ([NOT NULL]), [length = 50] fixe sa longueur ([varchar(50)]) ;
- lignes 28-29 : une description facultative de 200 caractères au plus : la colonne acceptera la valeur [NULL] ;
- lignes 32-33 : [@Column] fixe aussi le nom de la colonne ([prix_ht]) et, pour un nombre décimal, sa précision (le nombre total de chiffres) et son échelle (le nombre de chiffres après la virgule) : [decimal(10,2)] ;
- ligne 36 : une date facultative. Le type [LocalDate] (chapitre 5) représente une date sans heure : il est associé au type SQL [date] ;
- lignes 39-40 : [@Transient] exclut un champ du modèle : ici, une information utile à l'interface utilisateur, qui n'a pas sa place dans la base ;
- lignes 46-48 : une méthode, même nommée comme un accesseur, n'est pas enregistrée, puisque Hibernate accède aux champs. Avec un accès par les accesseurs, il faudrait lui ajouter l'annotation [@Transient].
La seconde entité ne porte aucune annotation :
| package modele;
import java.time.LocalDateTime;
// une classe sans aucune annotation : sa correspondance est décrite par le fichier [fournisseur.orm.xml]
public class Fournisseur {
private Integer numero;
private String raisonSociale;
private String telephone;
private LocalDateTime dateCreation;
protected Fournisseur() {
}
public Fournisseur(String raisonSociale, String telephone) {
this.raisonSociale = raisonSociale;
this.telephone = telephone;
}
}
|
Sa correspondance avec la table est décrite par un fichier XML, placé dans le dossier [src/main/resources/META-INF] du module :
| <?xml version="1.0" encoding="UTF-8"?>
<!-- la correspondance de la classe [Fournisseur] avec sa table, décrite en XML (Jakarta Persistence 3.2) -->
<entity-mappings xmlns="https://jakarta.ee/xml/ns/persistence/orm"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence/orm
https://jakarta.ee/xml/ns/persistence/orm/orm_3_2.xsd"
version="3.2">
<entity class="modele.Fournisseur" access="FIELD">
<table name="fournisseurs">
<!-- un index unique : deux fournisseurs ne peuvent avoir la même raison sociale -->
<index name="ix_fournisseurs_raison_sociale" column-list="raisonSociale" unique="true"/>
</table>
<attributes>
<id name="numero">
<generated-value strategy="IDENTITY"/>
</id>
<basic name="raisonSociale">
<column nullable="false" length="60"/>
</basic>
<basic name="telephone">
<column name="tel" length="20"/>
</basic>
<!-- une valeur par défaut calculée par le SGBD : Hibernate n'écrit jamais cette colonne -->
<basic name="dateCreation">
<column nullable="false" insertable="false" updatable="false"
column-definition="datetime(6) default current_timestamp(6)"/>
</basic>
</attributes>
</entity>
</entity-mappings>
|
- lignes 3-7 : le schéma XML des fichiers de correspondance de Jakarta Persistence 3.2 ;
- ligne 8 : la classe décrite, avec un accès par les champs ;
- lignes 9-12 : la table, et un index unique sur la raison sociale : deux fournisseurs ne peuvent avoir la même. L'annotation équivalente est [@Table(name = "fournisseurs", indexes = @Index(...))] ;
- lignes 14-16 : l'identifiant, généré par le SGBD ;
- lignes 17-19 : une colonne obligatoire d'au plus 60 caractères ;
- lignes 20-22 : le champ [telephone] est enregistré dans la colonne [tel] ;
- lignes 24-27 : la date de création a une valeur par défaut, calculée par le SGBD. L'attribut [column-definition] donne le texte SQL de la définition de la colonne, recopié tel quel dans l'ordre [CREATE TABLE] ; [insertable="false"] et [updatable="false"] indiquent qu'Hibernate ne doit jamais écrire cette colonne, ce qui laisse le SGBD utiliser [current_timestamp(6)].
Tout ce que font les annotations peut être fait en XML. Les annotations ont l'avantage de placer la configuration à côté du champ, et c'est la façon de faire la plus répandue ; le fichier XML, celui de laisser les classes indépendantes de Jakarta Persistence et de rassembler la configuration en un seul endroit. Les deux peuvent être mélangés : le fichier XML a priorité en cas de conflit. Jakarta Persistence ne prévoit pas de configuration du modèle par du code Java, comme le permettent d'autres ORM (par exemple Entity Framework, en C#).
Le programme affiche le modèle construit par Hibernate, puis le script SQL qui créerait les tables :
| package modele;
import jakarta.persistence.metamodel.Attribute;
import jakarta.persistence.metamodel.EntityType;
import jakarta.persistence.metamodel.SingularAttribute;
import java.io.StringWriter;
import org.hibernate.SessionFactory;
import org.hibernate.boot.model.naming.PhysicalNamingStrategySnakeCaseImpl;
import org.hibernate.cfg.AvailableSettings;
import org.hibernate.jpa.HibernatePersistenceConfiguration;
public class Main {
public static void main(String[] args) {
// le script de création des tables sera écrit dans cet objet
StringWriter script = new StringWriter();
// une SessionFactory qui ne se connecte jamais au serveur
try (SessionFactory sessionFactory = new HibernatePersistenceConfiguration("modele")
// une entité annotée et une correspondance décrite en XML
.managedClass(Produit.class)
.mappingFile("META-INF/fournisseur.orm.xml")
// pas de base de données : le SGBD et sa version sont donnés, pour le dialecte SQL
.property(AvailableSettings.JAKARTA_HBM2DDL_DB_NAME, "MySQL")
.property(AvailableSettings.JAKARTA_HBM2DDL_DB_MAJOR_VERSION, "8")
.property(AvailableSettings.ALLOW_METADATA_ON_BOOT, false)
// les noms de colonnes en « snake case » : raisonSociale -> raison_sociale
.property(AvailableSettings.PHYSICAL_NAMING_STRATEGY,
PhysicalNamingStrategySnakeCaseImpl.class.getName())
// le script de création des tables, sans l'exécuter
.property(AvailableSettings.JAKARTA_HBM2DDL_SCRIPTS_ACTION, "create")
.property(AvailableSettings.JAKARTA_HBM2DDL_SCRIPTS_CREATE_TARGET, script)
.property(AvailableSettings.HBM2DDL_DELIMITER, ";")
.property(AvailableSettings.FORMAT_SQL, true)
.createEntityManagerFactory()) {
// le modèle construit par Hibernate : entités et attributs (API Jakarta Persistence)
for (EntityType<?> entite : sessionFactory.getMetamodel().getEntities()) {
IO.println("entité %s (%s)".formatted(entite.getName(), entite.getJavaType().getName()));
for (Attribute<?, ?> attribut : entite.getAttributes()) {
boolean id = attribut instanceof SingularAttribute<?, ?> s && s.isId();
IO.println(" %-14s %s%s".formatted(attribut.getName(),
attribut.getJavaType().getSimpleName(), id ? " (identifiant)" : ""));
}
}
}
// le script de création des tables, sans le retrait de 4 espaces de ses lignes
IO.println("--- script de création");
script.toString().strip().lines()
.map(ligne -> ligne.startsWith(" ") ? ligne.substring(4) : ligne)
.forEach(ligne -> IO.println(ligne.stripTrailing()));
}
}
|
- ligne 16 : un [StringWriter] (paquetage [java.io]), flux d'écriture dans une chaîne en mémoire, recevra le script SQL ;
- lignes 20-21 : l'entité annotée est déclarée par [managedClass], le fichier XML par [mappingFile] ;
- lignes 23-25 : la base [hbmodele] n'existera jamais : cette [SessionFactory] ne sert qu'à examiner le modèle. Il n'y a donc pas d'URL JDBC. Pour choisir le dialecte SQL sans interroger le serveur, Hibernate a besoin du nom du SGBD et de sa version ; la propriété [ALLOW_METADATA_ON_BOOT] ([hibernate.boot.allow_jdbc_metadata_access]) lui interdit de se connecter au démarrage. Les noms des propriétés de configuration sont des constantes de l'interface [AvailableSettings] ;
- lignes 27-28 : la stratégie de nommage « snake case » ;
- lignes 30-33 : Hibernate écrira le script de création des tables (["create"]) dans l'objet [script], sans l'exécuter, les ordres étant terminés par un point-virgule et mis en forme sur plusieurs lignes ;
- ligne 37 : la méthode [getMetamodel] (Jakarta Persistence) donne accès au modèle : ses entités ([EntityType]) et, pour chacune, ses attributs ([Attribute]) ;
- lignes 38-43 : pour chaque entité, son nom et sa classe ; pour chaque attribut, son nom, son type Java et s'il est l'identifiant. Le filtrage par motif de la ligne 40 ([instanceof SingularAttribute<?, ?> s && s.isId()], chapitre 4) teste le type de l'attribut et, s'il convient, l'utilise aussitôt ;
- lignes 48-50 : l'affichage du script, sans le retrait de quatre espaces qu'Hibernate donne à ses lignes.
Résultats :
| > mvn compile exec:java
WARN connection - HHH102003: No appropriate connection provider encountered; client must supply connections
entité Produit (modele.Produit)
code Integer (identifiant)
dateRetrait LocalDate
description String
nom String
prixHT BigDecimal
entité Fournisseur (modele.Fournisseur)
numero Integer (identifiant)
dateCreation LocalDateTime
raisonSociale String
telephone String
--- script de création
create table fournisseurs (
numero integer not null auto_increment,
date_creation datetime(6) default current_timestamp(6) not null,
tel varchar(20),
raison_sociale varchar(60) not null,
primary key (numero)
) engine=InnoDB;
create table produits (
code integer not null auto_increment,
date_retrait date,
prix_ht decimal(10,2) not null,
nom varchar(50) not null,
description varchar(200),
primary key (code)
) engine=InnoDB;
alter table fournisseurs
add constraint ix_fournisseurs_raison_sociale unique (raison_sociale);
|
- ligne 2 : l'avertissement d'Hibernate confirme qu'il n'a aucun moyen d'obtenir une connexion ;
- lignes 3-13 : les deux entités et leurs attributs (l'identifiant d'abord, puis les autres par ordre alphabétique). Le champ [selectionne] et la méthode [getPrixTTC] ne figurent pas dans le modèle ;
- lignes 15-21 : la table [fournisseurs], décrite par le fichier XML. La stratégie de nommage a transformé [raisonSociale] et [dateCreation] en [raison_sociale] et [date_creation] ; la colonne [tel], dont le nom est imposé, n'est pas touchée. La définition de la colonne [date_creation] est celle du fichier XML, complétée par [not null] ;
- lignes 23-30 : la table [produits]. Les types SQL viennent des annotations ([varchar(50)], [decimal(10,2)]) ou des choix du dialecte pour un type Java ([integer], [date]) ;
- lignes 32-33 : l'index unique, créé sous MySQL par une contrainte d'unicité ([unique]).
11.3.3. Les identifiants générés
L'annotation [@GeneratedValue] indique que la valeur de l'identifiant est générée. Son paramètre [strategy] précise comment :
Stratégie | Génération de l'identifiant |
[IDENTITY] | par le SGBD, lors de l'insertion de la ligne (colonne [AUTO_INCREMENT] de MySQL, [IDENTITY] de SQL Server et PostgreSQL) |
[SEQUENCE] | par une séquence du SGBD (PostgreSQL, Oracle), qu'Hibernate interroge avant l'insertion |
[TABLE] | par une table de compteurs, gérée par Hibernate |
[UUID] | par Hibernate : un identifiant universel aléatoire, de type [java.util.UUID] |
[AUTO] (par défaut) | Hibernate choisit ; avec MySQL, qui n'a pas de séquences, il les simule par une table [<table>_SEQ] |
Avec MySQL, nous utilisons toujours [IDENTITY], qui correspond au fonctionnement naturel de la colonne [AUTO_INCREMENT]. Nous avons vu sa conséquence : l'ordre [INSERT] est exécuté dès l'appel de [persist], et Hibernate ne peut pas regrouper plusieurs insertions en un lot (nous verrons les lots plus loin). La stratégie [SEQUENCE], qui permet de connaître l'identifiant avant l'insertion, est préférée avec les SGBD qui ont des séquences.
11.3.4. Valeurs facultatives et colonnes [NULL]
Le caractère facultatif d'une colonne se décide donc en Java par le type du champ (primitif ou référence) et par l'attribut [nullable] de [@Column]. Il faut choisir avec soin : la règle exprime à la fois une contrainte du code Java (« cette valeur peut manquer ») et une contrainte de la base. Quelques remarques :
- [@Column(nullable = false)] donne la contrainte [NOT NULL] du schéma créé par Hibernate, et Hibernate s'en sert aussi avant d'envoyer un ordre [INSERT] ou [UPDATE] : si le champ vaut [null], il n'envoie pas l'ordre et lance une [PropertyValueException] (« not-null property references a null or transient value »). Ce contrôle d'Hibernate est désactivé par défaut lorsque Jakarta Validation (ci-dessous) est présente : la vérification revient alors à ses annotations, ou, à défaut, au SGBD, qui refuse l'ordre ;
- pour une vérification plus complète faite par Java, avant tout accès à la base, on utilise Jakarta Validation (bibliothèque [hibernate-validator]) et ses annotations [@NotNull], [@Size(max = 30)], [@Positive]... Lorsqu'elle est présente dans le classpath, Hibernate vérifie les entités avant de les enregistrer et tient compte de ces annotations pour créer les colonnes ;
- un champ ne peut pas être de type [Optional] (chapitre 4), qui n'est pas prévu pour cet usage ; en revanche, un accesseur peut rendre un [Optional] construit à partir d'un champ facultatif : [return Optional.ofNullable(description);].
11.4. Les exemples suivants : deux bibliothèques communes
Les exemples des paragraphes suivants travaillent presque tous sur la base [hbarticles] et sa table [articles], remplie avec les six articles du chapitre 10. Plutôt que de répéter l'entité et la configuration d'Hibernate dans chaque module, nous les plaçons dans une bibliothèque, le module [articles], dont les autres modules dépendent. Nous remplaçons aussi l'affichage du SQL de [showSql], trop bavard pour nos exemples, par un journal plus compact, placé dans une seconde bibliothèque, le module [outils].
11.4.1. Le journal SQL compact
Plutôt que d'écrire elle-même sur la console ([showSql]), Hibernate peut transmettre au système de journalisation (ici Logback) ce qu'il fait, chaque catégorie de messages étant écrite dans un journal (« logger ») différent :
Journal | Niveau | Messages |
[org.hibernate.SQL] | [DEBUG] | les ordres SQL exécutés |
[org.hibernate.orm.jdbc.bind] | [TRACE] | la valeur de chaque paramètre d'un ordre SQL |
[org.hibernate.orm.resource.jdbc] | [TRACE] | entre autres, le début, la validation et l'annulation des transactions |
[org.hibernate.orm.jdbc.batch] | [TRACE] | l'exécution des lots d'ordres SQL |
[org.hibernate.orm.jdbc.error] | [WARN] | les erreurs signalées par le SGBD |
Un journal n'écrit que les messages de niveau supérieur ou égal au sien. Il suffit donc de donner à ces journaux le niveau voulu dans le fichier [logback.xml] pour obtenir ces messages. Nous les faisons passer par une classe de mise en page (« layout ») qui les rend plus compacts :
| package outils;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.LayoutBase;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.slf4j.LoggerFactory;
// un journal compact des ordres SQL exécutés par Hibernate : une mise en page (« layout ») de Logback
public class JournalSql extends LayoutBase<ILoggingEvent> {
// les journaux (« loggers ») d'Hibernate utilisés
public static final String SQL = "org.hibernate.SQL";
public static final String PARAMETRES = "org.hibernate.orm.jdbc.bind";
public static final String TRANSACTIONS = "org.hibernate.orm.resource.jdbc";
public static final String ERREURS = "org.hibernate.orm.jdbc.error";
public static final String LOTS = "org.hibernate.orm.jdbc.batch";
// la longueur maximale d'une clause écrite sur une seule ligne
private static final int LARGEUR = 100;
// l'exécution d'un lot d'ordres SQL : « Executing JDBC batch (3 / 3) - [...] »
private static final Pattern LOT = Pattern.compile("Executing JDBC batch \\((\\d+) / \\d+\\).*");
// un paramètre lié à un ordre SQL : « binding parameter (1:VARCHAR) <- [vélo] »
private static final Pattern PARAMETRE =
Pattern.compile("binding parameter \\((\\d+):\\w+\\) <- \\[(.*)]");
// mise en page d'un message : on rend le texte à écrire ("" : rien n'est écrit)
@Override
public String doLayout(ILoggingEvent evenement) {
String message = evenement.getFormattedMessage();
return switch (evenement.getLoggerName()) {
case SQL -> ordreSql(message);
case PARAMETRES -> parametre(message);
case TRANSACTIONS -> transaction(message);
case ERREURS -> erreur(message);
case LOTS -> lot(message);
default -> "";
};
}
// l'ordre SQL mis en forme par Hibernate, rendu plus compact : une ligne par clause (select, from...)
private static String ordreSql(String sql) {
// les lignes non vides, sans les espaces de fin
List<String> lignes = sql.lines().map(String::stripTrailing).filter(l -> !l.isBlank()).toList();
// les clauses : une ligne de premier niveau (le moins en retrait) et les lignes qui la suivent
int retrait = lignes.stream().mapToInt(JournalSql::retrait).min().orElse(0);
List<List<String>> clauses = new ArrayList<>();
for (String ligne : lignes) {
if (clauses.isEmpty() || retrait(ligne) == retrait) {
clauses.add(new ArrayList<>());
}
clauses.getLast().add(ligne.substring(retrait));
}
// chaque clause est écrite sur une ligne, si elle n'est pas trop longue
StringBuilder texte = new StringBuilder();
String debut = "";
for (List<String> clause : clauses) {
String ligne = debut + clause.stream().map(String::strip).collect(Collectors.joining(" "));
if (ligne.equals("insert") || ligne.equals("delete")) {
// [insert into ...], [delete from ...] : une seule ligne
debut = ligne + " ";
continue;
}
if (ligne.length() <= LARGEUR) {
texte.append(" SQL> ").append(ligne).append('\n');
} else {
// clause trop longue : on garde les lignes d'Hibernate
for (int i = 0; i < clause.size(); i++) {
texte.append(" SQL> ").append(i == 0 ? debut : "").append(clause.get(i)).append('\n');
}
}
debut = "";
}
return texte.toString();
}
// le nombre d'espaces en début de ligne
private static int retrait(String ligne) {
return ligne.length() - ligne.stripLeading().length();
}
// la valeur d'un paramètre de l'ordre SQL
private static String parametre(String message) {
Matcher m = PARAMETRE.matcher(message);
return m.matches() ? " SQL> -- paramètre " + m.group(1) + " = " + m.group(2) + "\n" : "";
}
// début, validation et annulation des transactions (les autres messages sont ignorés)
private static String transaction(String message) {
if (message.startsWith("HHH10003002")) {
return " SQL> -- début de transaction\n";
}
if (message.startsWith("HHH10003004")) {
return " SQL> -- transaction validée (COMMIT)\n";
}
if (message.startsWith("HHH10003006")) {
return " SQL> -- transaction annulée (ROLLBACK)\n";
}
return "";
}
// l'erreur signalée par le SGBD (on ignore la ligne qui donne ses codes d'erreur)
private static String erreur(String message) {
return message.startsWith("HHH000247") ? "" : " SQL> -- échec : " + message + "\n";
}
// l'exécution d'un lot d'ordres SQL (les autres messages sont ignorés)
private static String lot(String message) {
Matcher m = LOT.matcher(message);
return m.matches() ? " SQL> -- exécution d'un lot de " + m.group(1) + " ordre(s)\n" : "";
}
// exécute une action sans journal SQL
public static void sansJournal(Runnable action) {
List<Logger> journaux = Stream.of(SQL, PARAMETRES, TRANSACTIONS, ERREURS, LOTS)
.map(JournalSql::journal)
.toList();
// les niveaux actuels, rétablis à la fin
List<Level> niveaux = journaux.stream().map(Logger::getLevel).toList();
journaux.forEach(j -> j.setLevel(Level.OFF));
try {
action.run();
} finally {
for (int i = 0; i < journaux.size(); i++) {
journaux.get(i).setLevel(niveaux.get(i));
}
}
}
// le journal Logback de nom donné
private static Logger journal(String nom) {
return (Logger) LoggerFactory.getLogger(nom);
}
}
|
- ligne 16 : une mise en page de Logback dérive de la classe [LayoutBase<ILoggingEvent>] ; sa méthode [doLayout] (lignes 36-47) reçoit chaque message (un [ILoggingEvent]) et rend le texte à écrire ;
- lignes 19-23 : les noms des journaux d'Hibernate utilisés ;
- lignes 39-46 : le traitement dépend du journal qui a émis le message ([switch] sur une chaîne, avec la syntaxe « flèche » de Java 14) ;
- lignes 50-83 : l'ordre SQL, tel qu'Hibernate le met en forme ([format_sql]), sur de nombreuses lignes, avec une colonne par ligne. Nous le rendons plus compact. Les lignes non vides sont réparties en clauses (lignes 54-61) : une clause commence par une ligne de premier niveau ([select], [from], [where]...) et comprend les lignes plus en retrait qui la suivent. Chaque clause est ensuite écrite sur une seule ligne, précédée de [SQL>], si elle ne dépasse pas 100 caractères (lignes 72-73), sinon ses lignes sont gardées (lignes 76-78). Les clauses [insert] et [delete] sont réunies avec la suivante (lignes 67-71) ;
- lignes 91-94 : le message d'Hibernate qui donne la valeur d'un paramètre ressemble à [binding parameter (1:VARCHAR) <- [vélo]]. L'expression régulière des lignes 32-33 (chapitre 5) en extrait le numéro du paramètre et sa valeur ;
- lignes 97-108 : parmi les nombreux messages du journal [org.hibernate.orm.resource.jdbc], on ne garde que ceux qui signalent le début, la validation et l'annulation d'une transaction. Chaque message d'Hibernate commence par un code ([HHH10003002]...) qui l'identifie sans ambiguïté ;
- lignes 111-113 : une erreur du SGBD. Hibernate l'écrit en deux messages, l'un donnant les codes d'erreur, qu'on ignore, l'autre le texte de l'erreur ;
- lignes 116-119 : l'exécution d'un lot d'ordres SQL, que nous rencontrerons à la fin du chapitre ;
- lignes 122-136 : la méthode [sansJournal] exécute une action sans journal SQL. Elle utilise l'API de Logback : chaque journal ([Logger]) a un niveau, modifiable pendant l'exécution. On met les cinq journaux au niveau [OFF], on exécute l'action, puis on rétablit les niveaux d'origine dans une clause [finally], pour qu'ils le soient même si l'action lance une exception ;
- lignes 139-141 : [LoggerFactory] est la classe de SLF4J qui rend un journal ; Logback étant l'implémentation de SLF4J présente, l'objet rendu est un [ch.qos.logback.classic.Logger].
Le module [outils] contient aussi le fichier [logback.xml] utilisé par les exemples. Placé à la racine du classpath ([src/main/resources]), il est trouvé par Logback au démarrage du programme :
| <?xml version="1.0" encoding="UTF-8"?>
<!-- configuration de Logback pour les exemples du chapitre -->
<configuration>
<!-- le journal SQL compact : mise en page par la classe [outils.JournalSql] -->
<appender name="SQL" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
<layout class="outils.JournalSql"/>
</encoder>
</appender>
<!-- les autres messages : niveau, nom court du journal, texte -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%-5level %logger{0} - %msg%n</pattern>
</encoder>
</appender>
<!-- les ordres SQL (DEBUG), leurs paramètres, les transactions, les lots (TRACE) et les erreurs -->
<logger name="org.hibernate.SQL" level="DEBUG" additivity="false">
<appender-ref ref="SQL"/>
</logger>
<logger name="org.hibernate.orm.jdbc.bind" level="TRACE" additivity="false">
<appender-ref ref="SQL"/>
</logger>
<logger name="org.hibernate.orm.resource.jdbc" level="TRACE" additivity="false">
<appender-ref ref="SQL"/>
</logger>
<logger name="org.hibernate.orm.jdbc.batch" level="TRACE" additivity="false">
<appender-ref ref="SQL"/>
</logger>
<logger name="org.hibernate.orm.jdbc.error" level="WARN" additivity="false">
<appender-ref ref="SQL"/>
</logger>
<!-- le pool de connexions intégré d'Hibernate suffit à nos exemples : on tait son avertissement -->
<logger name="org.hibernate.orm.connections.pooling" level="ERROR"/>
<!-- la suppression des tables d'une base vide provoque des erreurs sans conséquence : on les tait -->
<logger name="org.hibernate.tool.schema" level="OFF"/>
<!-- tous les autres journaux : avertissements et erreurs seulement -->
<root level="WARN">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
|
- lignes 6-10 : un « appender » écrit les messages, ici sur la console. Celui-ci utilise notre mise en page ;
- lignes 13-17 : un second « appender », pour les autres messages : niveau, nom court du journal, texte ;
- lignes 20-34 : les journaux d'Hibernate du tableau précédent, avec leur niveau. Leurs messages vont à l'« appender » [SQL] et à lui seul ([additivity="false"] : ils ne sont pas transmis en plus au journal racine) ;
- ligne 37 : l'avertissement sur le pool de connexions intégré d'Hibernate est tu ;
- ligne 40 : Hibernate supprime les tables en commençant par leurs clés étrangères ; sur une base vide, ces suppressions échouent sans conséquence, mais Hibernate le signale longuement. On tait ces messages ;
- lignes 43-45 : les autres journaux n'écrivent que les avertissements et les erreurs.
Le module [outils] n'a qu'une dépendance, Logback. Une bibliothèque n'impose pas d'ordinaire sa configuration de journalisation aux applications qui l'utilisent ; nous le faisons ici pour ne pas répéter ce fichier dans chaque exemple.
11.4.2. L'entité et la base des articles
L'entité [Article] du module [articles] utilise les annotations du paragraphe précédent. En voici le début ; la suite contient les deux constructeurs de l'entité du premier exemple, des accesseurs [getX]/[setX] pour chaque champ (sauf [setId] : l'identifiant est géré par Hibernate) et la même méthode [toString] :
| package articles;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.math.BigDecimal;
// un article du magasin, avec les annotations qui précisent sa table
@Entity
@Table(name = "articles",
uniqueConstraints = @UniqueConstraint(name = "uk_articles_nom", columnNames = "nom"))
public class Article {
// clé primaire, générée par le SGBD (AUTO_INCREMENT)
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
// nom de l'article : obligatoire, 30 caractères au plus, unique (cf. @Table)
@Column(nullable = false, length = 30)
private String nom;
// prix : 10 chiffres dont 2 après la virgule
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal prix;
// stock actuel et stock minimum en deçà duquel il faut réapprovisionner
private int stockActuel;
private int stockMinimum;
// le constructeur sans paramètre exigé par Hibernate (il peut être protégé)
protected Article() {
}
|
- lignes 14-15 : la table s'appelle [articles], comme au chapitre 10. Le paramètre [uniqueConstraints] déclare une contrainte d'unicité, nommée [uk_articles_nom], sur la colonne [nom] : deux articles ne peuvent avoir le même nom. Sans nom explicite, Hibernate en fabriquerait un illisible ([UK3f8k...]), qui apparaîtrait dans les messages d'erreur ;
- lignes 24-25 : un nom obligatoire de 30 caractères au plus ;
- lignes 28-29 : un prix en [decimal(10,2)].
La classe [BaseArticles] rassemble la configuration d'Hibernate pour la base [hbarticles] :
| package articles;
import java.math.BigDecimal;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.AvailableSettings;
import org.hibernate.jpa.HibernatePersistenceConfiguration;
import outils.JournalSql;
// la base [hbarticles] : configuration d'Hibernate et initialisation de la base
public final class BaseArticles {
// la base de données ; MySQL la crée si elle n'existe pas (createDatabaseIfNotExist)
public static final String URL = "jdbc:mysql://localhost:3306/hbarticles?createDatabaseIfNotExist=true";
public static final String UTILISATEUR = "root";
public static final String MOT_DE_PASSE = "";
// classe utilitaire : pas d'instance
private BaseArticles() {
}
// la configuration d'Hibernate : entités, base de données, mise en forme du SQL journalisé
public static HibernatePersistenceConfiguration configuration() {
return new HibernatePersistenceConfiguration("articles")
.managedClass(Article.class)
.jdbcUrl(URL)
.jdbcCredentials(UTILISATEUR, MOT_DE_PASSE)
.property(AvailableSettings.FORMAT_SQL, true);
}
// une SessionFactory pour la base [hbarticles], avec la configuration précédente
public static SessionFactory creerSessionFactory() {
return configuration().createEntityManagerFactory();
}
// (re)création de la table [articles] avec ses six articles, sans journal SQL
public static void initialiser(SessionFactory sessionFactory) {
JournalSql.sansJournal(() -> {
// suppression puis création des tables des entités
sessionFactory.getSchemaManager().drop(false);
sessionFactory.getSchemaManager().create(false);
// les six articles, ajoutés dans une transaction
sessionFactory.inTransaction(session -> {
session.persist(new Article("vélo", BigDecimal.valueOf(500), 10, 5));
session.persist(new Article("pompe", BigDecimal.valueOf(10), 10, 2));
session.persist(new Article("arc", BigDecimal.valueOf(600), 4, 1));
session.persist(new Article("flèches - lot de 6", BigDecimal.valueOf(100), 12, 20));
session.persist(new Article("combinaison de plongée", BigDecimal.valueOf(300), 8, 2));
session.persist(new Article("bouteilles d'oxygène", BigDecimal.valueOf(120), 10, 5));
});
});
}
}
|
- lignes 13-15 : l'URL de la base et l'utilisateur, publics pour que les autres modules puissent les utiliser ;
- lignes 22-28 : la configuration d'Hibernate. Elle est rendue par une méthode publique, ce qui permettra à un exemple de la compléter avant de créer la [SessionFactory]. La propriété [FORMAT_SQL] ([hibernate.format_sql]) demande à Hibernate de mettre en forme le SQL qu'il transmet au journal [org.hibernate.SQL] : c'est cette mise en forme que la classe [JournalSql] rend ensuite plus compacte ;
- lignes 31-33 : la [SessionFactory] de la base [hbarticles] ;
- lignes 36-51 : une méthode qui recrée la table [articles] avec ses six articles, sans journal SQL : on ne veut pas voir le SQL de cette initialisation dans les résultats des exemples ;
- ligne 42 : [sessionFactory.inTransaction(session -> ...)] ouvre une session, commence une transaction, exécute la lambda (chapitre 7) en lui passant la session, valide la transaction (ou l'annule si la lambda lance une exception) et ferme la session. C'est l'écriture la plus courte d'une unité de travail ; nous l'utiliserons souvent, ainsi que ses variantes [inSession] (une session sans transaction), [fromTransaction] et [fromSession] (qui rendent le résultat de la lambda).
Le module [articles] dépend du module [outils], d'Hibernate et du pilote JDBC :
| <dependencies>
<!-- le journal SQL compact (et Logback) -->
<dependency>
<groupId>exemples.hibernate</groupId>
<artifactId>outils</artifactId>
</dependency>
<!-- Hibernate et le pilote JDBC de MySQL -->
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
</dependencies>
|
Les modules d'exemple n'ont alors besoin que d'une dépendance, celle du module [articles] ; Maven y ajoute les dépendances de celui-ci (on parle de dépendances transitives). Voici celles du module [crud] :
| <dependencies>
<!-- l'entité [Article] et la base [hbarticles] (Hibernate, MySQL et le journal SQL compris) -->
<dependency>
<groupId>exemples.hibernate</groupId>
<artifactId>articles</artifactId>
</dependency>
</dependencies>
|
Nous configurons Hibernate par du code, avec [HibernatePersistenceConfiguration]. Il existe d'autres façons de faire, que l'on rencontre dans les applications et les documentations :
- un fichier [META-INF/persistence.xml], la façon historique de Jakarta Persistence : il décrit une ou plusieurs unités de persistance (classes des entités, propriétés de connexion, options), et l'on crée la fabrique par [Persistence.createEntityManagerFactory("articles")] ;
- un fichier [hibernate.properties] (ou [hibernate.cfg.xml]), propre à Hibernate, lu par la classe [org.hibernate.cfg.Configuration] : [new Configuration().addAnnotatedClass(Article.class).buildSessionFactory()]. C'est la façon de faire des versions anciennes d'Hibernate ;
- dans une application Spring Boot, les propriétés [spring.datasource.*] et [spring.jpa.*] du fichier [application.properties] : Spring Boot crée lui-même la fabrique.
Les propriétés sont partout les mêmes ([hibernate.format_sql], [jakarta.persistence.jdbc.url]...) : seule la façon de les donner change.
11.5. Les opérations de base : ajouter, lire, modifier, supprimer
11.5.1. Le programme [crud]
Le module [crud] montre les quatre opérations de base (CRUD : Create, Read, Update, Delete), ainsi que le suivi des entités par la session. Pour rendre ce suivi visible, il utilise quelques méthodes internes d'Hibernate (son « SPI », Service Provider Interface), destinées aux extensions d'Hibernate plutôt qu'aux applications : elles sont regroupées dans les méthodes utilitaires de la fin du programme.
| package crud;
import articles.Article;
import articles.BaseArticles;
import java.math.BigDecimal;
import java.util.Map;
import java.util.Objects;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
import org.hibernate.engine.spi.EntityEntry;
import org.hibernate.engine.spi.PersistenceContext;
import org.hibernate.engine.spi.SessionImplementor;
import org.hibernate.persister.entity.EntityPersister;
public class Main {
public static void main(String[] args) {
try (SessionFactory sessionFactory = BaseArticles.creerSessionFactory()) {
// la table [articles] est recréée avec ses six articles
BaseArticles.initialiser(sessionFactory);
try (Session session = sessionFactory.openSession()) {
// 1 - recherche par la clé primaire
IO.println("--- find(1)");
Article velo = session.find(Article.class, 1);
IO.println(velo);
IO.println("--- find(1) une seconde fois");
Article velo2 = session.find(Article.class, 1);
IO.println("même objet : " + (velo == velo2));
// 2 - ajout d'un article
IO.println("--- persist");
Transaction transaction = session.beginTransaction();
Article x = new Article("x", BigDecimal.valueOf(100), 10, 1);
IO.println("avant persist : état=" + etat(session, x) + ", id=" + x.getId());
session.persist(x);
IO.println("après persist : état=" + etat(session, x) + ", id=" + x.getId());
transaction.commit();
// 3 - modification : il suffit de modifier l'objet
IO.println("--- modification du prix");
transaction = session.beginTransaction();
x.setPrix(x.getPrix().multiply(new BigDecimal("1.1")));
IO.println("session modifiée : %s, prix d'origine=%s, prix actuel=%s"
.formatted(session.isDirty(), valeurOrigine(session, x, "prix"), x.getPrix()));
IO.println("--- commit");
transaction.commit();
IO.println("après commit : session modifiée : " + session.isDirty());
// 4 - suppression
IO.println("--- remove");
transaction = session.beginTransaction();
session.remove(x);
IO.println("avant commit : état=" + etat(session, x));
transaction.commit();
IO.println("après commit : état=" + etat(session, x));
// 5 - un objet détaché, modifié puis fusionné (merge) avec la session
IO.println("--- detach et merge");
Article arc = session.find(Article.class, 3);
session.detach(arc);
IO.println("après detach : état=" + etat(session, arc));
arc.setPrix(BigDecimal.valueOf(550));
transaction = session.beginTransaction();
Article arcGere = session.merge(arc);
IO.println("merge : même objet=%s, état de l'objet rendu=%s"
.formatted(arc == arcGere, etat(session, arcGere)));
transaction.commit();
// 6 - les entités suivies par la session
IO.println("--- les entités suivies");
velo.setStockActuel(velo.getStockActuel() - 1);
afficherEntitesSuivies(session);
}
}
}
// l'état d'un article vis-à-vis de la session (API interne d'Hibernate)
private static String etat(Session session, Article article) {
EntityEntry entree = contexte(session).getEntry(article);
if (entree != null) {
// MANAGED, DELETED...
return entree.getStatus().name();
}
// un objet inconnu de la session : jamais enregistré (sans identifiant) ou détaché
return article.getId() == null ? "TRANSIENT" : "DETACHED";
}
// la valeur d'une propriété telle qu'elle a été lue dans la base (API interne d'Hibernate)
private static Object valeurOrigine(Session session, Object entite, String propriete) {
return contexte(session).getEntry(entite).getLoadedValue(propriete);
}
// les entités du contexte de persistance de la session (API interne d'Hibernate)
private static void afficherEntitesSuivies(Session session) {
for (Map.Entry<Object, EntityEntry> paire : contexte(session).reentrantSafeEntityEntries()) {
EntityEntry entree = paire.getValue();
EntityPersister persister = entree.getPersister();
IO.println("%s #%s %s".formatted(persister.getEntityName(), entree.getId(), entree.getStatus()));
// valeurs actuelles des propriétés, comparées aux valeurs lues dans la base
String[] noms = persister.getPropertyNames();
Object[] actuelles = persister.getValues(paire.getKey());
Object[] origines = entree.getLoadedState();
for (int i = 0; i < noms.length; i++) {
String modification = Objects.equals(actuelles[i], origines[i]) ? ""
: " (modifiée, valeur d'origine : " + origines[i] + ")";
IO.println(" %s : %s%s".formatted(noms[i], actuelles[i], modification));
}
}
IO.println("session modifiée : " + session.isDirty());
}
// le contexte de persistance de la session (API interne d'Hibernate)
private static PersistenceContext contexte(Session session) {
return session.unwrap(SessionImplementor.class).getPersistenceContext();
}
}
|
Résultats :
| > mvn compile exec:java
--- find(1)
SQL> select a1_0.id, a1_0.nom, a1_0.prix, a1_0.stockActuel, a1_0.stockMinimum
SQL> from articles a1_0
SQL> where a1_0.id=?
SQL> -- paramètre 1 = 1
[1, vélo, 500,00, 10, 5]
--- find(1) une seconde fois
même objet : true
--- persist
SQL> -- début de transaction
avant persist : état=TRANSIENT, id=null
SQL> insert into articles (nom, prix, stockActuel, stockMinimum)
SQL> values (?, ?, ?, ?)
SQL> -- paramètre 1 = x
SQL> -- paramètre 2 = 100
SQL> -- paramètre 3 = 10
SQL> -- paramètre 4 = 1
après persist : état=MANAGED, id=7
SQL> -- transaction validée (COMMIT)
--- modification du prix
SQL> -- début de transaction
session modifiée : true, prix d'origine=100, prix actuel=110.0
--- commit
SQL> update articles
SQL> set nom=?, prix=?, stockActuel=?, stockMinimum=?
SQL> where id=?
SQL> -- paramètre 1 = x
SQL> -- paramètre 2 = 110.0
SQL> -- paramètre 3 = 10
SQL> -- paramètre 4 = 1
SQL> -- paramètre 5 = 7
SQL> -- transaction validée (COMMIT)
après commit : session modifiée : false
--- remove
SQL> -- début de transaction
avant commit : état=DELETED
SQL> delete from articles
SQL> where id=?
SQL> -- paramètre 1 = 7
SQL> -- transaction validée (COMMIT)
après commit : état=DETACHED
--- detach et merge
SQL> select a1_0.id, a1_0.nom, a1_0.prix, a1_0.stockActuel, a1_0.stockMinimum
SQL> from articles a1_0
SQL> where a1_0.id=?
SQL> -- paramètre 1 = 3
après detach : état=DETACHED
SQL> -- début de transaction
SQL> select a1_0.id, a1_0.nom, a1_0.prix, a1_0.stockActuel, a1_0.stockMinimum
SQL> from articles a1_0
SQL> where a1_0.id=?
SQL> -- paramètre 1 = 3
merge : même objet=false, état de l'objet rendu=MANAGED
SQL> update articles
SQL> set nom=?, prix=?, stockActuel=?, stockMinimum=?
SQL> where id=?
SQL> -- paramètre 1 = arc
SQL> -- paramètre 2 = 550
SQL> -- paramètre 3 = 4
SQL> -- paramètre 4 = 1
SQL> -- paramètre 5 = 3
SQL> -- transaction validée (COMMIT)
--- les entités suivies
articles.Article #1 MANAGED
nom : vélo
prix : 500.00
stockActuel : 9 (modifiée, valeur d'origine : 10)
stockMinimum : 5
articles.Article #3 MANAGED
nom : arc
prix : 550
stockActuel : 4
stockMinimum : 1
session modifiée : true
|
Commentaires :
- ligne 25 : [session.find(Article.class, 1)] cherche l'article de clé primaire 1. Elle rend [null] si la clé n'existe pas. Le SQL généré (lignes 3-6 des résultats) est un [SELECT] paramétré, dont le paramètre vaut 1 : le journal compact affiche maintenant la valeur des paramètres ;
- ligne 28 : le second appel de [find(Article.class, 1)] n'envoie aucun ordre SQL et rend le même objet (ligne 9). La session mémorise les entités qu'elle a lues ou enregistrées dans son contexte de persistance (« persistence context »), que l'on appelle aussi le cache de premier niveau : avant d'interroger la base, [find] regarde si l'entité demandée n'y est pas déjà. Une session ne contient jamais deux objets pour une même ligne de la base ;
- lignes 34-37 : ajout d'un article. Avant [persist], l'objet est [TRANSIENT] (« transitoire ») : la session ne le connaît pas et son identifiant vaut [null] (ligne 12). [persist] le fait entrer dans la session, qui exécute aussitôt l'ordre [INSERT] (lignes 13-18) pour obtenir la clé générée, et la place dans le champ [id]. L'article est alors [MANAGED] (« géré » ou « persistant ») et son identifiant vaut 7 (ligne 19). La transaction est ouverte à la ligne 33 et validée à la ligne 38 (lignes 11 et 20 des résultats) ;
- ligne 43 : pour modifier une ligne de la base, il suffit de modifier l'objet. Aucune méthode [update] n'est nécessaire : la session a gardé une copie des valeurs lues ou enregistrées (l'état d'origine) et compare les valeurs actuelles à celles-ci. C'est le suivi des modifications (« dirty checking ») ;
- lignes 44-45 : [session.isDirty()] indique si la session contient des modifications qui n'ont pas encore été répercutées dans la base : c'est le cas (ligne 23). La méthode [valeurOrigine] (lignes 90-92) lit dans le contexte de persistance la valeur d'origine du prix, 100, alors que la valeur actuelle est 110,0 ;
- ligne 47 : le [commit] synchronise la session avec la base (« flush ») : l'ordre [UPDATE] est exécuté (lignes 25-32), puis la transaction est validée. Après quoi la session n'a plus de modification en attente (ligne 34) ;
- on remarquera que l'ordre [UPDATE] écrit toutes les colonnes de la table, et pas seulement celle qui a changé. C'est le choix d'Hibernate par défaut : un même ordre SQL, préparé une fois pour toutes, sert à toutes les modifications d'articles. L'annotation [@DynamicUpdate] placée sur l'entité lui demande au contraire de générer à chaque fois un ordre limité aux colonnes modifiées ;
- lignes 53-56 : la suppression. [remove] fait passer l'article dans l'état [DELETED] (ligne 37) ; le [commit] envoie l'ordre [DELETE] (lignes 38-40). L'objet n'est alors plus connu de la session : il est [DETACHED] (« détaché », ligne 42) ;
- lignes 60-63 : l'arc est lu (lignes 44-47), puis détaché de la session par [detach] : la session l'oublie (ligne 48). Le changement de son prix est alors ignoré. Un objet détaché se rencontre souvent dans une application : c'est un objet lu par une session aujourd'hui fermée, par exemple lors d'une requête HTTP précédente, et modifié depuis par l'utilisateur ;
- ligne 65 : [session.merge(arc)] fusionne l'objet détaché avec la session. Celle-ci lit l'article dans la base (lignes 50-53), recopie l'état de l'objet détaché dans l'entité qu'elle gère, et rend cette dernière : ce n'est pas le même objet (ligne 54). L'objet passé à [merge] reste détaché ; c'est l'objet rendu qu'il faut utiliser ensuite. Le [commit] enregistre le nouveau prix (lignes 55-62) ;
- ligne 72 : on modifie le stock du vélo, sans valider ;
- lignes 95-111 : l'affichage du contexte de persistance. Pour chaque entité suivie ([EntityEntry]), on affiche son nom, son identifiant et son état, puis, pour chaque propriété, sa valeur actuelle, et sa valeur d'origine si elle est différente. L'objet [EntityPersister] est celui qui, dans Hibernate, connaît la correspondance d'une entité avec sa table : il donne le nom des propriétés et leurs valeurs ;
- lignes 65-75 des résultats : la session suit le vélo, dont le stock est passé de 10 à 9, et l'arc. La modification du vélo n'est pas enregistrée : la session est fermée sans synchronisation. Il n'y a pas de [commit], et une session ne se synchronise pas d'elle-même à sa fermeture.
11.5.2. Les états d'une entité
Une entité est, vis-à-vis d'une session, dans l'un des états suivants (Jakarta Persistence les appelle « new », « managed », « detached » et « removed ») :
État | Signification |
transitoire ([TRANSIENT]) | l'objet a été créé par [new] et n'a jamais été enregistré : il n'a pas d'identifiant et aucune session ne le connaît |
géré ([MANAGED]) | l'objet a été lu ou enregistré par la session, qui le suit : ses modifications seront répercutées dans la base lors de la prochaine synchronisation |
supprimé ([DELETED]) | l'objet a été marqué pour suppression ([remove]) : la prochaine synchronisation enverra l'ordre [DELETE] |
détaché ([DETACHED]) | l'objet correspond à une ligne de la base, mais la session qui l'a lu est fermée, ou l'a oublié ([detach], [clear]) : ses modifications sont ignorées, sauf à le fusionner avec une session ([merge]) |
La synchronisation (« flush ») de la session envoie au SGBD les ordres SQL qui répercutent les changements des entités gérées. Elle a lieu :
- lors du [commit] de la transaction ;
- avant l'exécution d'une requête dont le résultat pourrait dépendre de modifications non encore enregistrées (mode [FlushMode.AUTO], celui par défaut) : la requête voit ainsi les données de la session ;
- lors d'un appel explicite de [session.flush()]. Les ordres sont alors exécutés, mais ils ne seront définitivement enregistrés qu'au [commit] de la transaction.
On retiendra le cycle de travail avec Hibernate : on ouvre une session et une transaction, on lit des entités, on les modifie comme des objets ordinaires, on en ajoute ([persist]) et on en supprime ([remove]), puis on valide la transaction, ce qui envoie à la base, en une fois, tous les ordres nécessaires. La session joue le rôle d'une unité de travail (« unit of work »). C'est un objet léger, prévu pour une durée de vie courte : une unité de travail, par exemple une requête HTTP dans une application web. On n'utilise pas une même session pendant toute la vie d'une application : elle accumulerait les entités suivies dans son contexte de persistance.
11.6. Les requêtes HQL
11.6.1. HQL et JPQL
Pour retrouver des entités autrement que par leur clé primaire, on écrit des requêtes dans un langage proche de SQL, mais qui parle d'entités et d'attributs plutôt que de tables et de colonnes : HQL (Hibernate Query Language). Jakarta Persistence définit un langage semblable, JPQL (Jakarta Persistence Query Language), dont HQL est un sur-ensemble : une requête JPQL est une requête HQL valide. Hibernate traduit la requête en SQL, dans le dialecte du SGBD, et c'est le SGBD qui l'exécute.
Quelques différences avec SQL :
- on écrit le nom de l'entité ([Article]), non celui de la table ([articles]), et le nom des attributs ([stockActuel]), non celui des colonnes. Les noms des entités et des attributs respectent la casse, comme en Java ;
- la clause [select] est facultative en HQL : [from Article] rend des entités [Article]. JPQL exige d'écrire [select a from Article a] ;
- les relations entre entités se parcourent avec un point ([p.categorie.nom]), Hibernate écrivant lui-même les jointures, comme nous le verrons.
Une requête est créée par la méthode [createSelectionQuery] de la session (Hibernate 6), qui reçoit le texte HQL et le type des résultats, et rend un objet [SelectionQuery<T>]. On peut ensuite lui donner la valeur de ses paramètres, puis l'exécuter avec [getResultList] (une liste), [getSingleResult] (un seul résultat, une exception s'il y en a zéro ou plusieurs), [getSingleResultOrNull] ([null] s'il n'y en a pas) ou [getResultStream] (un flux). La méthode [createQuery] de Jakarta Persistence existe aussi ; elle sert aux requêtes de lecture comme aux mises à jour.
11.6.2. Le programme [hql]
Le module [hql] donne plusieurs exemples de requêtes :
| package hql;
import articles.Article;
import articles.BaseArticles;
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.query.SemanticException;
public class Main {
// une projection : le nom d'un article et la valeur de son stock
record ValeurStock(String nom, BigDecimal valeur) {
}
public static void main(String[] args) {
try (SessionFactory sessionFactory = BaseArticles.creerSessionFactory()) {
// la table [articles] est recréée avec ses six articles
BaseArticles.initialiser(sessionFactory);
try (Session session = sessionFactory.openSession()) {
// 1 - filtrage et tri
IO.println("--- les articles à réapprovisionner, par ordre alphabétique");
String hql = "from Article where stockActuel < stockMinimum order by nom";
List<Article> aCommander = session.createSelectionQuery(hql, Article.class).getResultList();
aCommander.forEach(IO::println);
// 2 - projection : seules les colonnes utiles sont demandées
IO.println("--- valeur du stock de chaque article");
List<ValeurStock> valeurs = session
.createSelectionQuery("""
select nom, prix * stockActuel from Article
order by prix * stockActuel desc""", ValeurStock.class)
.getResultList();
for (ValeurStock v : valeurs) {
IO.println("%s : %.2f euros".formatted(v.nom(), v.valeur()));
}
// 3 - un seul résultat ; la valeur [prixMin] est passée par un paramètre nommé
IO.println("--- le premier article de plus de 200 euros");
BigDecimal prixMin = BigDecimal.valueOf(200);
Article article = session
.createSelectionQuery("""
select a from Article a
where a.prix > :prixMin order by a.prix""", Article.class)
.setParameter("prixMin", prixMin)
.setMaxResults(1)
.getSingleResultOrNull();
IO.println(article == null ? "aucun" : article);
// 4 - agrégats : le calcul est fait par le SGBD
IO.println("--- agrégats");
long nbArticles = session.createSelectionQuery("select count(*) from Article", Long.class)
.getSingleResult();
BigDecimal valeurStock = session
.createSelectionQuery("select sum(prix * stockActuel) from Article", BigDecimal.class)
.getSingleResult();
IO.println("%d articles, valeur du stock : %.2f euros".formatted(nbArticles, valeurStock));
// 5 - like et in ; une liste Java comme valeur d'un paramètre
IO.println("--- like et in");
List<String> noms = List.of("arc", "pompe", "raquette");
List<String> resultats = session
.createSelectionQuery("""
select nom from Article
where nom like 'b%' or nom in :noms""", String.class)
.setParameter("noms", noms)
.getResultList();
IO.println(String.join(", ", resultats));
// 6 - pagination : la troisième page de deux articles, par prix croissant
IO.println("--- pagination");
int taillePage = 2, numeroPage = 3;
List<Article> page = session.createSelectionQuery("from Article order by prix", Article.class)
.setFirstResult((numeroPage - 1) * taillePage)
.setMaxResults(taillePage)
.getResultList();
IO.println("page %d : %s".formatted(numeroPage, page.stream()
.map(a -> "%s (%.2f euros)".formatted(a.getNom(), a.getPrix()))
.collect(Collectors.joining(", "))));
// 7 - une requête incorrecte est refusée dès sa création, avant toute exécution
IO.println("--- requête incorrecte");
try {
session.createSelectionQuery("from Article where stock < stockMinimum", Article.class);
} catch (SemanticException e) {
IO.println(e.getClass().getSimpleName() + " : " + e.getMessage());
}
// 8 - une règle écrite en Java n'est pas traduisible en SQL : filtrage en mémoire
IO.println("--- filtrage en mémoire");
try (Stream<Article> flux = session
.createSelectionQuery("from Article", Article.class)
.getResultStream()) {
flux.filter(Regles::enRupture).forEach(IO::println);
}
}
}
}
// une règle métier écrite en Java
static final class Regles {
private Regles() {
}
// un article est en rupture si son stock est inférieur au stock minimum
static boolean enRupture(Article a) {
return a.getStockActuel() < a.getStockMinimum();
}
}
}
|
Résultats :
| > mvn compile exec:java
--- les articles à réapprovisionner, par ordre alphabétique
SQL> select a1_0.id, a1_0.nom, a1_0.prix, a1_0.stockActuel, a1_0.stockMinimum
SQL> from articles a1_0
SQL> where a1_0.stockActuel<a1_0.stockMinimum
SQL> order by a1_0.nom
[4, flèches - lot de 6, 100,00, 12, 20]
--- valeur du stock de chaque article
SQL> select a1_0.nom, (a1_0.prix*a1_0.stockActuel)
SQL> from articles a1_0
SQL> order by (a1_0.prix*a1_0.stockActuel) desc
vélo : 5000,00 euros
arc : 2400,00 euros
combinaison de plongée : 2400,00 euros
flèches - lot de 6 : 1200,00 euros
bouteilles d'oxygène : 1200,00 euros
pompe : 100,00 euros
--- le premier article de plus de 200 euros
SQL> select a1_0.id, a1_0.nom, a1_0.prix, a1_0.stockActuel, a1_0.stockMinimum
SQL> from articles a1_0
SQL> where a1_0.prix>?
SQL> order by a1_0.prix
SQL> limit ?
SQL> -- paramètre 1 = 200
SQL> -- paramètre 2 = 1
[5, combinaison de plongée, 300,00, 8, 2]
--- agrégats
SQL> select count(*)
SQL> from articles a1_0
SQL> select sum((a1_0.prix*a1_0.stockActuel))
SQL> from articles a1_0
6 articles, valeur du stock : 12300,00 euros
--- like et in
SQL> select a1_0.nom
SQL> from articles a1_0
SQL> where a1_0.nom like 'b%' escape '' or a1_0.nom in (?, ?, ?)
SQL> -- paramètre 1 = arc
SQL> -- paramètre 2 = pompe
SQL> -- paramètre 3 = raquette
arc, bouteilles d'oxygène, pompe
--- pagination
SQL> select a1_0.id, a1_0.nom, a1_0.prix, a1_0.stockActuel, a1_0.stockMinimum
SQL> from articles a1_0
SQL> order by a1_0.prix
SQL> limit ?, ?
SQL> -- paramètre 1 = 4
SQL> -- paramètre 2 = 2
page 3 : vélo (500,00 euros), arc (600,00 euros)
--- requête incorrecte
SemanticException : Could not interpret path expression 'stock'
--- filtrage en mémoire
SQL> select a1_0.id, a1_0.nom, a1_0.prix, a1_0.stockActuel, a1_0.stockMinimum
SQL> from articles a1_0
[4, flèches - lot de 6, 100,00, 12, 20]
|
Commentaires :
- lignes 26-27 : la clause [where] compare deux attributs de l'entité, la clause [order by] trie sur le nom : elles deviennent les clauses [WHERE] et [ORDER BY] de l'ordre SQL (lignes 5-6). La requête est exécutée par [getResultList] ;
- lignes 32-36 : une projection. La requête ne rend pas des entités, mais deux valeurs par article : le nom et la valeur du stock. Le SQL généré (ligne 9) ne demande que les colonnes utiles et fait le calcul lui-même. Le type des résultats est le record [ValeurStock] des lignes 16-17 : Hibernate crée un record par ligne, en passant à son constructeur les valeurs de la clause [select], dans l'ordre. Ces objets ne sont pas des entités : la session ne les suit pas. La requête est écrite dans un bloc de texte (Java 15, chapitre 3), plus lisible qu'une longue chaîne ;
- lignes 44-50 : une requête écrite comme en JPQL, avec un alias ([a]). [:prixMin] est un paramètre nommé, dont la valeur est donnée par [setParameter]. Il devient un paramètre de l'ordre SQL (ligne 21) : on n'a donc pas à se soucier des injections SQL, la valeur étant transmise séparément du texte de l'ordre. C'est la règle que le chapitre 10 appliquait avec les requêtes préparées. [setMaxResults(1)] limite le résultat à une ligne : la clause [LIMIT] de MySQL (ligne 23). Une valeur littérale écrite dans le texte de la requête (comme ['b%'] à la ligne 68 : ligne 36 des résultats) est au contraire recopiée dans le texte de l'ordre SQL : les valeurs qui viennent de l'extérieur du programme doivent toujours être passées en paramètres ;
- lignes 55-59 : les agrégats [count] et [sum] sont calculés par le SGBD (lignes 28-31) : seul le résultat est transféré, pas les lignes. HQL connaît aussi [avg], [min], [max], les clauses [group by] et [having] ;
- lignes 65-70 : l'opérateur [like] de SQL, et l'opérateur [in] dont l'opérande est un paramètre qui reçoit une liste Java : Hibernate le développe en autant de paramètres SQL que la liste a d'éléments (ligne 36). HQL connaît de nombreuses fonctions, sur les chaînes ([upper], [length], [substring]...), les dates, les nombres, qu'il traduit dans le dialecte du SGBD ;
- lignes 76-79 : la pagination, pour afficher une longue liste page par page. [setFirstResult] saute les articles des pages précédentes, [setMaxResults] garde ceux de la page demandée : la clause [LIMIT ?, ?] de MySQL (lignes 45-47). Une pagination n'a de sens que sur une liste triée ([order by]). Les lignes 80-82 construisent l'affichage de la page avec un flux et [Collectors.joining] (chapitre 5) ;
- lignes 86-90 : une requête qui utilise un attribut [stock] qui n'existe pas. L'erreur est détectée dès la création de la requête, avant toute exécution, car Hibernate analyse le texte HQL et le confronte au modèle : il lance une [SemanticException] (ligne 50 ; les messages d'Hibernate sont en anglais). Une erreur de syntaxe donnerait une [SyntaxException] ;
- lignes 94-98 : une requête ne peut pas utiliser une méthode Java, comme [Regles.enRupture] (lignes 110-112) : Hibernate n'en connaît pas le code, et même s'il le connaissait, il ne saurait pas forcément l'écrire en SQL. On peut lire toutes les entités et les filtrer en mémoire, avec l'API Stream : [getResultStream] rend un [Stream<Article>] (à fermer après usage, d'où l'instruction [try] avec ressources). Le résultat est correct (ligne 54), mais toute la table a été transférée (lignes 52-53) : sur une grande table, c'est à éviter. Il vaut mieux écrire la condition dans la requête, comme à la ligne 26.
11.6.3. Requêtes nommées et vérification à la compilation
Une requête écrite dans une chaîne n'est analysée qu'à l'exécution. Deux moyens permettent de détecter plus tôt les erreurs :
- les requêtes nommées : l'annotation [@NamedQuery(name = "Article.enRupture", query = "from Article where stockActuel < stockMinimum")], placée sur l'entité, déclare une requête qu'Hibernate analyse au démarrage, lors de la création de la [SessionFactory]. On l'exécute ensuite par son nom : [session.createNamedSelectionQuery("Article.enRupture", Article.class)] ;
- le processeur d'annotations d'Hibernate (« Hibernate Processor », bibliothèque [org.hibernate.orm:hibernate-processor]), déclaré dans la configuration du compilateur ([annotationProcessorPaths] de [maven-compiler-plugin]). Exécuté par [javac] pendant la compilation, il vérifie les requêtes des annotations et génère, pour chaque entité, une classe de « métamodèle » ([Article_] pour [Article]), que nous retrouverons avec l'API Criteria. Nous ne l'utilisons pas dans ce cours.
11.7. L'API Criteria
11.7.1. Des requêtes construites par du code
Une requête HQL est un texte, que l'on peut construire par concaténation : c'est peu sûr et peu lisible dès que la requête dépend de nombreux critères facultatifs (un formulaire de recherche, par exemple). Jakarta Persistence propose une autre façon d'écrire les requêtes : l'API Criteria, où la requête est un arbre d'objets construit par des appels de méthodes. Hibernate traduit cet arbre en SQL, exactement comme une requête HQL (il traduit d'ailleurs aussi le HQL en un tel arbre). Le module [criteria] reprend plusieurs requêtes du programme précédent :
| package criteria;
import articles.Article;
import articles.BaseArticles;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Expression;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.query.criteria.HibernateCriteriaBuilder;
public class Main {
// une projection : le nom d'un article et la valeur de son stock
record ValeurStock(String nom, BigDecimal valeur) {
}
public static void main(String[] args) {
try (SessionFactory sessionFactory = BaseArticles.creerSessionFactory()) {
// la table [articles] est recréée avec ses six articles
BaseArticles.initialiser(sessionFactory);
try (Session session = sessionFactory.openSession()) {
// la fabrique des éléments des requêtes
HibernateCriteriaBuilder cb = session.getCriteriaBuilder();
// 1 - filtrage et tri
IO.println("--- les articles à réapprovisionner, par ordre alphabétique");
CriteriaQuery<Article> requete = cb.createQuery(Article.class);
Root<Article> a = requete.from(Article.class);
requete.select(a)
.where(cb.lt(a.<Integer>get("stockActuel"), a.<Integer>get("stockMinimum")))
.orderBy(cb.asc(a.get("nom")));
session.createSelectionQuery(requete).getResultList().forEach(IO::println);
// 2 - projection vers un record
IO.println("--- valeur du stock de chaque article");
CriteriaQuery<ValeurStock> projection = cb.createQuery(ValeurStock.class);
Root<Article> p = projection.from(Article.class);
Expression<BigDecimal> valeur = cb
.prod(p.<BigDecimal>get("prix"), p.<Integer>get("stockActuel"))
.as(BigDecimal.class);
projection.select(cb.construct(ValeurStock.class, p.get("nom"), valeur))
.orderBy(cb.desc(valeur));
for (ValeurStock v : session.createSelectionQuery(projection).getResultList()) {
IO.println("%s : %.2f euros".formatted(v.nom(), v.valeur()));
}
// 3 - une requête construite selon les critères fournis
IO.println("--- recherche : prix maximal 150 euros");
rechercher(session, BigDecimal.valueOf(150), null).forEach(IO::println);
IO.println("--- recherche : prix maximal 150 euros et nom commençant par [b]");
rechercher(session, BigDecimal.valueOf(150), "b").forEach(IO::println);
// 4 - agrégats
IO.println("--- agrégats");
CriteriaQuery<Long> compte = cb.createQuery(Long.class);
compte.select(cb.count(compte.from(Article.class)));
long nbArticles = session.createSelectionQuery(compte).getSingleResult();
CriteriaQuery<BigDecimal> somme = cb.createQuery(BigDecimal.class);
Root<Article> s = somme.from(Article.class);
somme.select(cb.sum(cb
.prod(s.<BigDecimal>get("prix"), s.<Integer>get("stockActuel"))
.as(BigDecimal.class)));
BigDecimal valeurStock = session.createSelectionQuery(somme).getSingleResult();
IO.println("%d articles, valeur du stock : %.2f euros".formatted(nbArticles, valeurStock));
}
}
}
// les articles dont le prix ne dépasse pas [prixMax] et dont le nom commence par [debutNom],
// chaque critère n'étant utilisé que s'il n'est pas null
static List<Article> rechercher(Session session, BigDecimal prixMax, String debutNom) {
HibernateCriteriaBuilder cb = session.getCriteriaBuilder();
CriteriaQuery<Article> requete = cb.createQuery(Article.class);
Root<Article> a = requete.from(Article.class);
// les conditions, ajoutées une à une
List<Predicate> conditions = new ArrayList<>();
if (prixMax != null) {
conditions.add(cb.le(a.get("prix"), prixMax));
}
if (debutNom != null) {
conditions.add(cb.like(a.get("nom"), debutNom + "%"));
}
requete.select(a).where(conditions.toArray(Predicate[]::new)).orderBy(cb.asc(a.get("prix")));
return session.createSelectionQuery(requete).getResultList();
}
}
|
Résultats :
| > mvn compile exec:java
--- les articles à réapprovisionner, par ordre alphabétique
SQL> select a1_0.id, a1_0.nom, a1_0.prix, a1_0.stockActuel, a1_0.stockMinimum
SQL> from articles a1_0
SQL> where a1_0.stockActuel<a1_0.stockMinimum
SQL> order by a1_0.nom
[4, flèches - lot de 6, 100,00, 12, 20]
--- valeur du stock de chaque article
SQL> select a1_0.nom, (a1_0.prix*a1_0.stockActuel)
SQL> from articles a1_0
SQL> order by (a1_0.prix*a1_0.stockActuel) desc
vélo : 5000,00 euros
arc : 2400,00 euros
combinaison de plongée : 2400,00 euros
flèches - lot de 6 : 1200,00 euros
bouteilles d'oxygène : 1200,00 euros
pompe : 100,00 euros
--- recherche : prix maximal 150 euros
SQL> select a1_0.id, a1_0.nom, a1_0.prix, a1_0.stockActuel, a1_0.stockMinimum
SQL> from articles a1_0
SQL> where a1_0.prix<=?
SQL> order by a1_0.prix
SQL> -- paramètre 1 = 150
[2, pompe, 10,00, 10, 2]
[4, flèches - lot de 6, 100,00, 12, 20]
[6, bouteilles d'oxygène, 120,00, 10, 5]
--- recherche : prix maximal 150 euros et nom commençant par [b]
SQL> select a1_0.id, a1_0.nom, a1_0.prix, a1_0.stockActuel, a1_0.stockMinimum
SQL> from articles a1_0
SQL> where a1_0.prix<=? and a1_0.nom like ? escape ''
SQL> order by a1_0.prix
SQL> -- paramètre 1 = 150
SQL> -- paramètre 2 = b%
[6, bouteilles d'oxygène, 120,00, 10, 5]
--- agrégats
SQL> select count(a1_0.id)
SQL> from articles a1_0
SQL> select sum((a1_0.prix*a1_0.stockActuel))
SQL> from articles a1_0
6 articles, valeur du stock : 12300,00 euros
|
Commentaires :
- ligne 28 : la session fournit un [HibernateCriteriaBuilder], la version enrichie par Hibernate du [CriteriaBuilder] de Jakarta Persistence : c'est la fabrique de tous les éléments d'une requête (conditions, expressions, tris) ;
- lignes 32-36 : la requête du premier exemple HQL. [createQuery(Article.class)] crée une requête qui rend des articles ; [from(Article.class)] déclare l'entité interrogée et rend sa racine ([Root<Article>]), l'équivalent de l'alias [a] de [from Article a] ; [a.get("stockActuel")] désigne un attribut. La condition est construite par [cb.lt(x, y)] (« less than », [x < y]), le tri par [cb.asc(...)]. L'écriture [a.<Integer>get(...)] donne au compilateur le type de l'attribut, que la chaîne ["stockActuel"] ne lui permet pas de connaître ;
- ligne 37 : la requête est exécutée par la session, comme une requête HQL. Le SQL généré (lignes 3-6) est le même que celui de la requête HQL ;
- lignes 41-47 : une projection vers le record [ValeurStock]. [cb.prod(x, y)] est le produit de deux expressions ; [as(BigDecimal.class)] précise le type du résultat ; [cb.construct(ValeurStock.class, ...)] indique que chaque ligne du résultat doit être passée au constructeur du record ;
- lignes 76-90 : c'est là que l'API Criteria est la plus utile. La méthode [rechercher] construit une requête selon les critères qu'on lui donne : chaque condition n'est ajoutée à la liste des conditions que si le critère correspondant n'est pas [null], et la clause [where] reçoit toutes les conditions de la liste, réunies par [and]. Le premier appel (ligne 54) produit une requête avec une condition (ligne 21), le second (ligne 56) une requête avec deux conditions (ligne 30). On remarquera qu'avec l'API Criteria, Hibernate passe les valeurs ([150], [b%]) en paramètres de l'ordre SQL ;
- lignes 60-68 : les agrégats [count] et [sum].
La faiblesse de ces requêtes est la désignation des attributs par des chaînes ([a.get("stockActuel")]) : une faute de frappe ne sera détectée qu'à l'exécution, et le compilateur ignore le type de l'attribut. Jakarta Persistence prévoit pour y remédier un métamodèle statique : pour chaque entité [Article], une classe [Article_] qui contient un champ par attribut, décrivant son nom et son type. On écrit alors [cb.lt(a.get(Article_.stockActuel), a.get(Article_.stockMinimum))], que le compilateur vérifie entièrement. Ces classes ne s'écrivent pas à la main : elles sont générées pendant la compilation par le processeur d'annotations d'Hibernate cité plus haut. C'est la façon de faire recommandée dans un projet réel qui utilise l'API Criteria.
Pour une requête fixe, HQL est plus lisible que l'API Criteria ; pour une requête qui se construit selon les circonstances, l'API Criteria est plus sûre. Les deux se combinent : Hibernate permet de créer une requête Criteria à partir d'un texte HQL ([cb.createQuery(hql, Article.class)]), puis de la compléter par du code.
11.8. Les relations
11.8.1. Le modèle de la boutique
Les tables d'une base sont liées entre elles. Hibernate représente ces liens par des associations : des champs qui référencent une autre entité ou une collection d'entités. Le module [relations] utilise une base [hbboutique] qui décrit une petite boutique, avec six entités. Une catégorie regroupe des produits :
| // une catégorie regroupe plusieurs produits (relation un-à-plusieurs)
@Entity
@Table(name = "categories")
public class Categorie {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(nullable = false, length = 30)
private String nom;
// les produits de la catégorie : le côté inverse de la relation, décrite par Produit.categorie
@OneToMany(mappedBy = "categorie", cascade = CascadeType.PERSIST)
private List<Produit> produits = new ArrayList<>();
protected Categorie() {
}
public Categorie(String nom) {
this.nom = nom;
}
// ajout d'un produit : les deux côtés de la relation sont mis à jour
public void ajouter(Produit produit) {
produits.add(produit);
produit.setCategorie(this);
}
public Integer getId() {
return id;
}
public String getNom() {
return nom;
}
public List<Produit> getProduits() {
return produits;
}
}
|
Un produit appartient à une catégorie et porte des étiquettes :
| // un produit appartient à une catégorie et porte plusieurs étiquettes
@Entity
@Table(name = "produits")
public class Produit {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(nullable = false, length = 30)
private String nom;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal prix;
// relation plusieurs-à-un : la clé étrangère [categorie_id] de la table [produits]
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "categorie_id", foreignKey = @ForeignKey(name = "fk_produits_categorie"))
private Categorie categorie;
// relation plusieurs-à-plusieurs, enregistrée dans une table d'association
@ManyToMany(cascade = CascadeType.PERSIST)
@JoinTable(name = "produits_etiquettes",
joinColumns = @JoinColumn(name = "produit_id"),
inverseJoinColumns = @JoinColumn(name = "etiquette_id"),
foreignKey = @ForeignKey(name = "fk_produits_etiquettes_produit"),
inverseForeignKey = @ForeignKey(name = "fk_produits_etiquettes_etiquette"))
private Set<Etiquette> etiquettes = new HashSet<>();
protected Produit() {
}
public Produit(String nom, BigDecimal prix, Etiquette... etiquettes) {
this.nom = nom;
this.prix = prix;
// seul le côté propriétaire de la relation est renseigné : c'est lui qu'Hibernate enregistre
this.etiquettes.addAll(List.of(etiquettes));
}
|
Une étiquette est portée par plusieurs produits :
| // une étiquette (promo, nouveauté...) est portée par plusieurs produits
@Entity
@Table(name = "etiquettes")
public class Etiquette {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(nullable = false, length = 20)
private String libelle;
// le côté inverse de la relation décrite par Produit.etiquettes
@ManyToMany(mappedBy = "etiquettes")
private Set<Produit> produits = new HashSet<>();
protected Etiquette() {
}
public Etiquette(String libelle) {
this.libelle = libelle;
}
|
Une commande appartient à un client et contient des lignes :
| // une commande d'un client, faite de lignes
@Entity
@Table(name = "commandes")
public class Commande {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private LocalDate date;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "client_id")
private Client client;
@OneToMany(mappedBy = "commande", cascade = CascadeType.PERSIST)
private List<LigneCommande> lignes = new ArrayList<>();
protected Commande() {
}
public Commande(LocalDate date) {
this.date = date;
}
// ajout d'une ligne : les deux côtés de la relation sont mis à jour
public void ajouter(LigneCommande ligne) {
lignes.add(ligne);
ligne.setCommande(this);
}
|
Les entités [Client] et [LigneCommande] sont construites sur le même modèle. Un client a un identifiant, un nom et une liste de commandes ([@OneToMany(mappedBy = "client", cascade = CascadeType.PERSIST)]), avec une méthode [ajouter(Commande)] ; une ligne de commande a un identifiant, une quantité, et deux associations plusieurs-à-un, vers sa commande et vers son produit :
| // une ligne de commande : une quantité d'un produit
@Entity
@Table(name = "lignes_commande")
public class LigneCommande {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private int quantite;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "commande_id")
private Commande commande;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "produit_id")
private Produit produit;
protected LigneCommande() {
}
public LigneCommande(Produit produit, int quantite) {
this.produit = produit;
this.quantite = quantite;
}
|
On y trouve les trois sortes d'associations :
- une relation plusieurs-à-un ([@ManyToOne]) : un produit appartient à une catégorie (Produit, lignes 17-19). Dans la base, elle est représentée par une clé étrangère, la colonne [categorie_id] de la table [produits], dont [@JoinColumn] donne le nom et le nom de la contrainte ([fk_produits_categorie]). [optional = false] rend la relation obligatoire : la colonne sera [NOT NULL] ;
- la relation inverse, un-à-plusieurs ([@OneToMany]) : une catégorie contient plusieurs produits (Categorie, lignes 14-15). Elle ne crée rien dans la base : [mappedBy = "categorie"] indique qu'elle est décrite par le champ [categorie] de l'entité [Produit]. On dit que [Produit.categorie] est le côté propriétaire de la relation, et [Categorie.produits] son côté inverse : c'est la valeur du côté propriétaire qu'Hibernate enregistre. Il faut cependant garder les deux côtés cohérents en mémoire : c'est le rôle de la méthode [ajouter] (Categorie, lignes 25-28), qui ajoute le produit à la liste et renseigne sa catégorie ;
- une relation plusieurs-à-plusieurs ([@ManyToMany]) : un produit porte plusieurs étiquettes, une étiquette est portée par plusieurs produits. Elle est enregistrée dans une table d'association, [produits_etiquettes], dont [@JoinTable] donne le nom, le nom des deux colonnes et celui des deux clés étrangères (Produit, lignes 22-28). Le côté inverse est [Etiquette.produits] ([mappedBy = "etiquettes"]). Pour une relation plusieurs-à-plusieurs, on préfère un [Set] à une [List] : avec une liste, Hibernate supprime et réinsère toutes les lignes de la table d'association à chaque modification de la collection ;
- [fetch = FetchType.LAZY] demande un chargement paresseux de la catégorie d'un produit : elle ne sera lue que si l'on s'en sert. C'est le comportement par défaut des collections ([@OneToMany], [@ManyToMany]), mais pas celui des relations vers une seule entité ([@ManyToOne]), que la norme charge par défaut immédiatement ([EAGER]) : on leur ajoute systématiquement [fetch = FetchType.LAZY], comme le recommande la documentation d'Hibernate ;
- [cascade = CascadeType.PERSIST] propage l'opération [persist] : persister une catégorie persiste ses produits, persister un produit persiste ses étiquettes. Les autres valeurs ([MERGE], [REMOVE], [ALL]...) propagent les autres opérations ;
- les collections sont initialisées avec une collection vide, et le champ d'une relation vers une seule entité reste [null] tant qu'il n'a pas été renseigné. Les méthodes [setCategorie], [setClient] et [setCommande] ne sont pas publiques : seules les méthodes [ajouter] du même paquetage les appellent.
La classe [Boutique] configure Hibernate pour la base [hbboutique] et y crée un premier jeu de données :
| package relations;
import java.math.BigDecimal;
import java.time.LocalDate;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.AvailableSettings;
import org.hibernate.jpa.HibernatePersistenceConfiguration;
import outils.JournalSql;
// la base [hbboutique] : configuration d'Hibernate et premier jeu de données
public final class Boutique {
private Boutique() {
}
// une SessionFactory pour la base [hbboutique] et ses six entités
public static SessionFactory creerSessionFactory() {
return new HibernatePersistenceConfiguration("boutique")
.managedClasses(Categorie.class, Produit.class, Etiquette.class,
Client.class, Commande.class, LigneCommande.class)
.jdbcUrl("jdbc:mysql://localhost:3306/hbboutique?createDatabaseIfNotExist=true")
.jdbcCredentials("root", "")
.property(AvailableSettings.FORMAT_SQL, true)
.createEntityManagerFactory();
}
// (re)création des tables et d'un premier jeu de données, sans journal SQL
public static void initialiser(SessionFactory sessionFactory) {
JournalSql.sansJournal(() -> {
sessionFactory.getSchemaManager().drop(false);
sessionFactory.getSchemaManager().create(false);
sessionFactory.inTransaction(session -> {
// deux étiquettes
Etiquette promo = new Etiquette("promo");
Etiquette nouveaute = new Etiquette("nouveauté");
// trois catégories et leurs produits : un graphe d'objets
Categorie cycles = new Categorie("cycles");
cycles.ajouter(new Produit("vélo", BigDecimal.valueOf(500), promo));
Produit pompe = new Produit("pompe", BigDecimal.valueOf(10));
cycles.ajouter(pompe);
Categorie tirALArc = new Categorie("tir à l'arc");
Produit arc = new Produit("arc", BigDecimal.valueOf(600), nouveaute);
tirALArc.ajouter(arc);
tirALArc.ajouter(new Produit("flèches - lot de 6", BigDecimal.valueOf(100),
promo, nouveaute));
Categorie plongee = new Categorie("plongée");
// la persistance se propage aux produits et aux étiquettes (cascade)
session.persist(cycles);
session.persist(tirALArc);
session.persist(plongee);
// un client et sa commande de deux lignes
Commande commande = new Commande(LocalDate.of(2026, 9, 28));
commande.ajouter(new LigneCommande(pompe, 2));
commande.ajouter(new LigneCommande(arc, 1));
Client martin = new Client("Martin");
martin.ajouter(commande);
session.persist(martin);
});
});
}
}
|
- lignes 19-20 : les six entités, déclarées par [managedClasses] ;
- lignes 34-46 : un graphe d'objets : trois catégories, leurs produits, et les étiquettes de ceux-ci ;
- lignes 48-50 : il suffit de persister les catégories : grâce aux cascades, Hibernate parcourt les associations et persiste toutes les entités atteintes, dans le bon ordre (les catégories avant les produits qui y font référence), en renseignant les clés étrangères et la table d'association ;
- lignes 52-57 : un client, sa commande et ses deux lignes, persistés par une seule opération.
Les tables créées par Hibernate sont les suivantes :
| > mysql -uroot hbboutique -e "SHOW TABLES; SHOW CREATE TABLE produits\G SHOW CREATE TABLE produits_etiquettes\G"
Tables_in_hbboutique
categories
clients
commandes
etiquettes
lignes_commande
produits
produits_etiquettes
*************************** 1. row ***************************
Table: produits
Create Table: CREATE TABLE `produits` (
`id` int NOT NULL AUTO_INCREMENT,
`nom` varchar(30) NOT NULL,
`prix` decimal(10,2) NOT NULL,
`categorie_id` int NOT NULL,
PRIMARY KEY (`id`),
KEY `fk_produits_categorie` (`categorie_id`),
CONSTRAINT `fk_produits_categorie` FOREIGN KEY (`categorie_id`) REFERENCES `categories` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
*************************** 1. row ***************************
Table: produits_etiquettes
Create Table: CREATE TABLE `produits_etiquettes` (
`produit_id` int NOT NULL,
`etiquette_id` int NOT NULL,
PRIMARY KEY (`produit_id`,`etiquette_id`),
KEY `fk_produits_etiquettes_etiquette` (`etiquette_id`),
CONSTRAINT `fk_produits_etiquettes_etiquette` FOREIGN KEY (`etiquette_id`) REFERENCES `etiquettes` (`id`),
CONSTRAINT `fk_produits_etiquettes_produit` FOREIGN KEY (`produit_id`) REFERENCES `produits` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
|
- lignes 3-9 : une table par entité, et la table d'association [produits_etiquettes] ;
- lignes 16-19 : la clé étrangère [categorie_id] de la table [produits] référence la table [categories]. Elle est [NOT NULL] ([optional = false]). MySQL crée un index sur chaque clé étrangère (ligne 18). Contrairement à d'autres ORM, Hibernate ne demande pas la suppression en cascade dans la base ([ON DELETE CASCADE]) : on peut l'obtenir avec l'annotation [@OnDelete(action = OnDeleteAction.CASCADE)] ;
- lignes 23-30 : la table d'association a deux colonnes, clés étrangères vers les deux tables liées, qui forment ensemble sa clé primaire.
11.8.2. Le programme
| package relations;
import jakarta.persistence.EntityGraph;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.stream.Collectors;
import org.hibernate.LazyInitializationException;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
public class Main {
// les résultats des requêtes de projection
record ProduitCategorie(String produit, String categorie) {
}
record MontantCommande(Integer id, String client, BigDecimal montant) {
}
record StatistiquesCategorie(String categorie, Long nombre, Double prixMoyen) {
}
public static void main(String[] args) {
try (SessionFactory sessionFactory = Boutique.creerSessionFactory()) {
// 1 - création des tables et d'un premier jeu de données, sans journal
Boutique.initialiser(sessionFactory);
// 2 - un client, sa commande et ses lignes, ajoutés en une fois
IO.println("--- ajout d'un client, d'une commande et de ses deux lignes");
sessionFactory.inTransaction(session -> {
Produit velo = produit(session, "vélo");
Produit fleches = produit(session, "flèches - lot de 6");
Commande commande = new Commande(LocalDate.of(2026, 10, 1));
commande.ajouter(new LigneCommande(velo, 1));
commande.ajouter(new LigneCommande(fleches, 3));
Client client = new Client("Dupont");
client.ajouter(commande);
session.persist(client);
IO.println("client n° %d, commande n° %d".formatted(client.getId(), commande.getId()));
});
// 3 - chargement paresseux : les relations sont lues à la demande
IO.println("--- chargement paresseux");
sessionFactory.inSession(session -> {
List<Commande> commandes = session
.createSelectionQuery("from Commande order by id", Commande.class)
.getResultList();
for (Commande commande : commandes) {
IO.println("commande n° %d : client=%s, %d ligne(s)".formatted(commande.getId(),
commande.getClient().getNom(), commande.getLignes().size()));
}
});
// 4 - une relation non chargée n'est plus accessible une fois la session fermée
IO.println("--- après la fermeture de la session");
Commande commandeDetachee = sessionFactory.fromSession(s -> s.find(Commande.class, 1));
try {
IO.println(commandeDetachee.getLignes().size() + " ligne(s)");
} catch (LazyInitializationException e) {
IO.println(e.getClass().getSimpleName() + " : " + e.getMessage());
}
// 5 - join fetch : les commandes, leur client, leurs lignes et leurs produits en une requête
IO.println("--- join fetch");
sessionFactory.inSession(session -> {
List<Commande> commandes = session.createSelectionQuery("""
from Commande c
join fetch c.client
join fetch c.lignes l
join fetch l.produit
order by c.id""", Commande.class).getResultList();
for (Commande commande : commandes) {
IO.println("commande n° %d du %s pour %s".formatted(commande.getId(), commande.getDate(),
commande.getClient().getNom()));
for (LigneCommande ligne : commande.getLignes()) {
Produit produit = ligne.getProduit();
IO.println(" %d x %s à %.2f euros"
.formatted(ligne.getQuantite(), produit.getNom(), produit.getPrix()));
}
}
});
// 6 - un graphe d'entités : ce qu'il faut charger avec l'entité, décrit hors de la requête
IO.println("--- graphe d'entités");
Categorie categorie = sessionFactory.fromSession(session -> {
EntityGraph<Categorie> graphe = session.createEntityGraph(Categorie.class);
graphe.addAttributeNode("produits");
return session.find(graphe, 2);
});
// la session est fermée, mais les produits ont été chargés
IO.println("%s : %s".formatted(categorie.getNom(),
categorie.getProduits().stream().map(Produit::getNom).collect(Collectors.joining(", "))));
// 7 - la relation plusieurs-à-plusieurs dans une requête
IO.println("--- les produits en promotion");
sessionFactory.inSession(session -> session.createSelectionQuery("""
select p.nom, p.categorie.nom from Produit p join p.etiquettes e
where e.libelle = 'promo' order by p.nom""", ProduitCategorie.class)
.getResultList()
.forEach(p -> IO.println("%s (%s)".formatted(p.produit(), p.categorie()))));
// 8 - un calcul à travers les relations, fait par le SGBD
IO.println("--- montant des commandes");
sessionFactory.inSession(session -> session.createSelectionQuery("""
select c.id, c.client.nom, sum(l.quantite * l.produit.prix)
from Commande c join c.lignes l
group by c.id, c.client.nom order by c.id""", MontantCommande.class)
.getResultList()
.forEach(m -> IO.println("commande n° %d (%s) : %.2f euros"
.formatted(m.id(), m.client(), m.montant()))));
// 9 - un regroupement : nombre de produits et prix moyen par catégorie
IO.println("--- produits par catégorie");
sessionFactory.inSession(session -> session.createSelectionQuery("""
select p.categorie.nom, count(*), avg(p.prix) from Produit p
group by p.categorie.nom order by p.categorie.nom""", StatistiquesCategorie.class)
.getResultList()
.forEach(s -> IO.println("%s : %d produit(s), prix moyen %.2f euros".formatted(
s.categorie(), s.nombre(), s.prixMoyen()))));
}
}
// le produit de nom donné
private static Produit produit(Session session, String nom) {
return session.createSelectionQuery("from Produit where nom = :nom", Produit.class)
.setParameter("nom", nom)
.getSingleResult();
}
}
|
Résultats :
| > mvn compile exec:java
--- ajout d'un client, d'une commande et de ses deux lignes
SQL> -- début de transaction
SQL> select p1_0.id, p1_0.categorie_id, p1_0.nom, p1_0.prix
SQL> from produits p1_0
SQL> where p1_0.nom=?
SQL> -- paramètre 1 = vélo
SQL> select p1_0.id, p1_0.categorie_id, p1_0.nom, p1_0.prix
SQL> from produits p1_0
SQL> where p1_0.nom=?
SQL> -- paramètre 1 = flèches - lot de 6
SQL> insert into clients (nom)
SQL> values (?)
SQL> -- paramètre 1 = Dupont
SQL> insert into commandes (client_id, date)
SQL> values (?, ?)
SQL> -- paramètre 1 = 2
SQL> -- paramètre 2 = 2026-10-01
SQL> insert into lignes_commande (commande_id, produit_id, quantite)
SQL> values (?, ?, ?)
SQL> -- paramètre 1 = 2
SQL> -- paramètre 2 = 1
SQL> -- paramètre 3 = 1
SQL> insert into lignes_commande (commande_id, produit_id, quantite)
SQL> values (?, ?, ?)
SQL> -- paramètre 1 = 2
SQL> -- paramètre 2 = 4
SQL> -- paramètre 3 = 3
client n° 2, commande n° 2
SQL> -- transaction validée (COMMIT)
--- chargement paresseux
SQL> select c1_0.id, c1_0.client_id, c1_0.date
SQL> from commandes c1_0
SQL> order by c1_0.id
SQL> select c1_0.id, c1_0.nom
SQL> from clients c1_0
SQL> where c1_0.id=?
SQL> -- paramètre 1 = 1
SQL> select l1_0.commande_id, l1_0.id, l1_0.produit_id, l1_0.quantite
SQL> from lignes_commande l1_0
SQL> where l1_0.commande_id=?
SQL> -- paramètre 1 = 1
commande n° 1 : client=Martin, 2 ligne(s)
SQL> select c1_0.id, c1_0.nom
SQL> from clients c1_0
SQL> where c1_0.id=?
SQL> -- paramètre 1 = 2
SQL> select l1_0.commande_id, l1_0.id, l1_0.produit_id, l1_0.quantite
SQL> from lignes_commande l1_0
SQL> where l1_0.commande_id=?
SQL> -- paramètre 1 = 2
commande n° 2 : client=Dupont, 2 ligne(s)
--- après la fermeture de la session
SQL> select c1_0.id, c1_0.client_id, c1_0.date
SQL> from commandes c1_0
SQL> where c1_0.id=?
SQL> -- paramètre 1 = 1
LazyInitializationException : Cannot lazily initialize collection of role 'relations.Commande.lignes' with key '1' (no session)
--- join fetch
SQL> select
SQL> c1_0.id,
SQL> c1_0.client_id,
SQL> c2_0.id,
SQL> c2_0.nom,
SQL> c1_0.date,
SQL> l1_0.commande_id,
SQL> l1_0.id,
SQL> l1_0.produit_id,
SQL> p1_0.id,
SQL> p1_0.categorie_id,
SQL> p1_0.nom,
SQL> p1_0.prix,
SQL> l1_0.quantite
SQL> from commandes c1_0
SQL> join clients c2_0 on c2_0.id=c1_0.client_id
SQL> join lignes_commande l1_0 on c1_0.id=l1_0.commande_id
SQL> join produits p1_0 on p1_0.id=l1_0.produit_id
SQL> order by c1_0.id
commande n° 1 du 2026-09-28 pour Martin
2 x pompe à 10,00 euros
1 x arc à 600,00 euros
commande n° 2 du 2026-10-01 pour Dupont
1 x vélo à 500,00 euros
3 x flèches - lot de 6 à 100,00 euros
--- graphe d'entités
SQL> select c1_0.id, c1_0.nom, p1_0.categorie_id, p1_0.id, p1_0.nom, p1_0.prix
SQL> from categories c1_0
SQL> left join produits p1_0 on c1_0.id=p1_0.categorie_id
SQL> where c1_0.id=?
SQL> -- paramètre 1 = 2
tir à l'arc : arc, flèches - lot de 6
--- les produits en promotion
SQL> select p1_0.nom, c1_0.nom
SQL> from produits p1_0
SQL> join produits_etiquettes e1_0 on p1_0.id=e1_0.produit_id
SQL> join etiquettes e1_1 on e1_1.id=e1_0.etiquette_id
SQL> join categories c1_0 on c1_0.id=p1_0.categorie_id
SQL> where e1_1.libelle='promo'
SQL> order by p1_0.nom
flèches - lot de 6 (tir à l'arc)
vélo (cycles)
--- montant des commandes
SQL> select c1_0.id, c2_0.nom, sum((l1_0.quantite*p1_0.prix))
SQL> from commandes c1_0
SQL> join lignes_commande l1_0 on c1_0.id=l1_0.commande_id
SQL> join produits p1_0 on p1_0.id=l1_0.produit_id
SQL> join clients c2_0 on c2_0.id=c1_0.client_id
SQL> group by c1_0.id, c2_0.nom
SQL> order by c1_0.id
commande n° 1 (Martin) : 620,00 euros
commande n° 2 (Dupont) : 800,00 euros
--- produits par catégorie
SQL> select c1_0.nom, count(*), avg(p1_0.prix)
SQL> from produits p1_0
SQL> join categories c1_0 on c1_0.id=p1_0.categorie_id
SQL> group by c1_0.nom
SQL> order by c1_0.nom
cycles : 2 produit(s), prix moyen 255,00 euros
tir à l'arc : 2 produit(s), prix moyen 350,00 euros
|
Commentaires :
- lignes 15-22 : les records qui recevront les résultats des projections ;
- lignes 32-39 : le produit est lu par la méthode [produit] (lignes 125-129) ; un client, sa commande et ses deux lignes sont persistés par un seul appel à [persist]. Le SQL (lignes 12-28 des résultats) montre l'ordre des insertions : le client, puis la commande, dont la clé étrangère [client_id] reçoit la clé générée du client (2), puis les lignes, dont la clé étrangère [commande_id] reçoit celle de la commande ;
- lignes 46-52 : le chargement paresseux. La requête ne lit que les commandes (lignes 32-34) : le client et les lignes de chacune ne sont lus qu'au moment où le programme s'en sert, par des requêtes supplémentaires (lignes 35-42 pour la première commande, 44-51 pour la seconde). Pour le client, Hibernate a placé dans le champ [client] un mandataire (« proxy »), un objet d'une sous-classe de [Client] générée par Hibernate (avec Byte Buddy), qui ne contient que l'identifiant et lit le client lors du premier appel d'une méthode ([getNom()]). C'est pourquoi une entité ne doit pas être une classe [final]. Pour les lignes, la liste est une collection d'Hibernate, lue lors de son premier usage ([size()]) ;
- lignes 57-62 : la commande est lue par une session aussitôt fermée ([fromSession]). Ses lignes n'ont pas été chargées, et une fois la session fermée, elles ne peuvent plus l'être : l'accès à la collection lance une [LazyInitializationException] (ligne 58), l'une des exceptions les plus fréquentes des applications qui utilisent Hibernate. Il faut charger ce dont on aura besoin pendant que la session est ouverte ;
- lignes 67-72 : le chargement anticipé par une requête. [join fetch] demande à Hibernate de charger, en même temps que les commandes, leur client, leurs lignes et le produit de chaque ligne : une seule requête, avec des jointures (lignes 60-78). L'affichage montre les deux commandes et leurs lignes (lignes 79-84). La jointure avec les lignes produit une ligne SQL par ligne de commande ; Hibernate en tire une commande unique pour chaque identifiant : la liste rendue contient bien deux commandes ;
- lignes 86-90 : un graphe d'entités (« entity graph », Jakarta Persistence) décrit, en dehors de toute requête, les associations à charger avec une entité : ici, les produits de la catégorie. [session.find(graphe, 2)] (Jakarta Persistence 3.2) lit la catégorie 2 avec ses produits, en une requête (lignes 86-90 des résultats). Une fois la session fermée, les produits restent accessibles (lignes 92-93). Pour charger une association après coup, pendant que la session est ouverte, on écrit [Hibernate.initialize(categorie.getProduits())] ;
- lignes 97-101 : les associations s'utilisent dans les requêtes : [join p.etiquettes e] parcourt la relation plusieurs-à-plusieurs (deux jointures, avec la table d'association et la table des étiquettes, lignes 95-96), et [p.categorie.nom] est traduit en une jointure implicite avec la table des catégories (ligne 97) ;
- lignes 105-111 : un calcul à travers deux relations, le montant de chaque commande, entièrement fait par le SGBD : jointures, [sum] et [group by] (lignes 103-109) ;
- lignes 115-120 : un regroupement. [group by p.categorie.nom] regroupe les produits par nom de catégorie ; pour chaque groupe, le nombre de produits ([count]) et le prix moyen ([avg], de type [Double]) sont calculés par le SGBD (lignes 113-117). La catégorie [plongée], sans produit, n'apparaît pas : la jointure avec la table des catégories est une jointure interne ([join]).
11.8.3. Le problème des N+1 requêtes
Le chargement paresseux de l'exemple montre un problème qui guette tout utilisateur d'un ORM, dit des « N+1 requêtes » : une requête pour lire les N commandes, puis une requête par commande pour lire son client (et une autre pour ses lignes). Avec mille commandes, on fait mille et un allers-retours avec le serveur là où un seul suffisait. Le problème est d'autant plus sournois qu'il est invisible dans le code : [commande.getClient().getNom()] ne ressemble pas à une requête. Les remèdes sont ceux de l'exemple : [join fetch], un graphe d'entités, ou mieux encore, une projection qui ne demande que les données utiles. Hibernate offre aussi le chargement par lots ([@BatchSize(size = 20)] placée sur une collection, ou sur la classe de l'entité référencée, ici [Client]), qui lit par exemple les clients de vingt commandes en une seule requête. Le journal SQL permet de détecter le problème : une même requête répétée de nombreuses fois.
11.9. L'évolution du schéma de la base
11.9.1. Le problème
Jusqu'ici, les tables ont été créées par Hibernate, après avoir été supprimées. C'est impossible pour une application en service : sa base contient des données qu'il faut conserver quand le modèle évolue (une nouvelle colonne, une nouvelle table). Il faut pouvoir faire passer la base d'une version du schéma à la suivante, de façon sûre et reproductible, sur le poste du développeur comme sur le serveur de production. On parle de migrations du schéma.
Hibernate ne gère pas de migrations : il sait créer un schéma, le vérifier, et le compléter de façon limitée. L'usage, dans le monde Java, est de confier les migrations à des scripts SQL versionnés, appliqués par un outil dédié, le plus souvent Flyway ou Liquibase, et de demander à Hibernate de vérifier que le schéma obtenu correspond aux entités.
11.9.2. La gestion du schéma par Hibernate
La propriété [hibernate.hbm2ddl.auto] (ou la méthode [schemaToolingAction] de la configuration, qu'utilise le programme ci-dessous) indique ce qu'Hibernate doit faire du schéma lors de la création de la [SessionFactory] :
Valeur | Constante [Action] | Effet |
[none] | [NONE] | rien (valeur par défaut) |
[create-only] | [CREATE_ONLY] | création des tables |
[create] | [CREATE] | suppression puis création des tables |
[create-drop] | [CREATE_DROP] | comme [create], et suppression des tables à la fermeture de la [SessionFactory] : pour les tests |
[drop] | [DROP] | suppression des tables |
[validate] | [VALIDATE] | vérification que les tables et les colonnes des entités existent, avec des types compatibles ; une exception sinon |
[update] | [UPDATE] | création des tables et des colonnes manquantes |
Jakarta Persistence définit une propriété voisine, [jakarta.persistence.schema-generation.database.action], aux valeurs [none], [create], [drop-and-create] et [drop]. Les mêmes actions sont disponibles par programme, comme nous l'avons fait, avec le [SchemaManager] de la [SessionFactory] ([create], [drop], [validate], [truncate]).
La valeur [update] est tentante : Hibernate compare les entités au schéma de la base et ajoute ce qui manque. Elle a des limites qui la rendent impropre à une application en production :
- elle ne fait qu'ajouter : elle ne supprime jamais une colonne ou une table devenue inutile, ne modifie pas le type ou la longueur d'une colonne existante, et ne sait pas renommer (un champ renommé donne une nouvelle colonne vide, l'ancienne restant avec ses données) ;
- elle ne sait pas transformer les données (remplir une nouvelle colonne obligatoire, scinder une colonne en deux) ;
- ce qu'elle fait dépend de l'état de la base rencontrée : on ne sait pas à l'avance quels ordres seront exécutés, ni dans quel ordre, et aucune trace n'en est gardée.
On réserve donc [create], [create-drop] et [update] au développement et aux tests, et l'on utilise [validate] (ou [none]) en production, le schéma étant géré par des scripts.
11.9.3. Des scripts SQL versionnés
Le principe des migrations par scripts est simple :
- chaque évolution du schéma est décrite par un script SQL, numéroté : [V1__creation_clients.sql], [V2__ajout_telephone.sql]... Un script, une fois appliqué à une base, n'est plus jamais modifié : une correction fait l'objet d'un nouveau script ;
- la base contient une table d'historique qui indique les versions qui lui ont été appliquées ;
- au démarrage de l'application (ou lors du déploiement), un outil compare les scripts disponibles à l'historique et applique, dans l'ordre, ceux qui ne l'ont pas encore été.
Les scripts font partie du code source de l'application : on les conserve et on les gère avec le reste du code (dans un dépôt Git, par exemple). Pour en comprendre le fonctionnement, le module [clients] met en œuvre un petit exécuteur de scripts versionnés, sur le modèle de Flyway ; nous présenterons ensuite Flyway lui-même.
11.9.4. Le module [clients]
Le module [clients] utilise la base [hbclients]. L'entité est un client :
| // un client
@Entity
@Table(name = "clients")
public class Client {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(nullable = false, length = 50)
private String nom;
@Column(nullable = false, length = 100)
private String email;
@Column(length = 20)
private String telephone;
protected Client() {
}
public Client(String nom, String email, String telephone) {
this.nom = nom;
this.email = email;
this.telephone = telephone;
}
@Override
public String toString() {
String tel = telephone == null ? "pas de téléphone" : telephone;
return "[%d, %s, %s, %s]".formatted(id, nom, email, tel);
}
}
|
Dans la première version de l'application, le client n'a pas de téléphone : les lignes 16-17 n'existent pas encore. Nous les ajouterons dans la seconde version. Les scripts sont placés dans le dossier [src/main/resources/db/migration] du module, qui sera recopié dans le classpath. Le premier crée la table :
| -- version 1 : la table des clients
create table clients (
id integer not null auto_increment,
nom varchar(50) not null,
email varchar(100) not null,
primary key (id)
) engine=InnoDB;
|
Le second ajoute la colonne du téléphone :
| -- version 2 : le téléphone (facultatif) des clients
alter table clients
add column telephone varchar(20);
|
La classe [Migrations] applique les scripts avec JDBC (chapitre 10), indépendamment d'Hibernate :
| package clients;
import java.io.IOException;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
// un petit exécuteur de scripts SQL versionnés, sur le modèle de Flyway : les scripts
// [db/migration/V<version>__<description>.sql] sont appliqués une seule fois, dans l'ordre des versions,
// et la table [historique_migrations] de la base note ceux qui l'ont été
public class Migrations {
// un script de migration
public record Script(int version, String description, Path chemin) {
@Override
public String toString() {
return "V" + version + " (" + description + ")";
}
}
// le nom d'un script : V1__creation_clients.sql
private static final Pattern NOM = Pattern.compile("V(\\d+)__(\\w+)\\.sql");
// la table d'historique
private static final String CREATION_HISTORIQUE = """
create table if not exists historique_migrations (
version integer not null primary key,
description varchar(100) not null,
date_application datetime not null default current_timestamp
) engine=InnoDB""";
// la base de données
private final String url;
private final String utilisateur;
private final String motDePasse;
public Migrations(String url, String utilisateur, String motDePasse) {
this.url = url;
this.utilisateur = utilisateur;
this.motDePasse = motDePasse;
}
// les scripts du dossier [db/migration] du classpath, par ordre de version
public List<Script> scripts() throws IOException, URISyntaxException {
URL dossier = Migrations.class.getResource("/db/migration");
if (dossier == null) {
return List.of();
}
List<Script> scripts = new ArrayList<>();
try (Stream<Path> fichiers = Files.list(Path.of(dossier.toURI()))) {
for (Path fichier : fichiers.toList()) {
Matcher m = NOM.matcher(fichier.getFileName().toString());
if (m.matches()) {
int version = Integer.parseInt(m.group(1));
scripts.add(new Script(version, m.group(2).replace('_', ' '), fichier));
}
}
}
scripts.sort(Comparator.comparingInt(Script::version));
return scripts;
}
// les versions déjà appliquées à la base (la table d'historique est créée si besoin)
public Set<Integer> versionsAppliquees() throws SQLException {
try (Connection connexion = DriverManager.getConnection(url, utilisateur, motDePasse);
Statement ordre = connexion.createStatement()) {
ordre.execute(CREATION_HISTORIQUE);
Set<Integer> versions = new TreeSet<>();
try (ResultSet lignes = ordre.executeQuery("select version from historique_migrations")) {
while (lignes.next()) {
versions.add(lignes.getInt(1));
}
}
return versions;
}
}
// les scripts qui restent à appliquer
public List<Script> enAttente() throws SQLException, IOException, URISyntaxException {
Set<Integer> appliquees = versionsAppliquees();
return scripts().stream().filter(s -> !appliquees.contains(s.version())).toList();
}
// application des scripts en attente, jusqu'à la version [cible] comprise ; on rend les scripts appliqués
public List<Script> appliquer(int cible) throws SQLException, IOException, URISyntaxException {
List<Script> appliques = new ArrayList<>();
for (Script script : enAttente()) {
if (script.version() > cible) {
break;
}
appliquer(script);
appliques.add(script);
}
return appliques;
}
// application d'un script et ajout de sa version à l'historique, dans une transaction
private void appliquer(Script script) throws SQLException, IOException {
// les ordres du script, séparés par des points-virgules, sans les lignes de commentaire
String texte = Files.readString(script.chemin()).lines()
.filter(ligne -> !ligne.strip().startsWith("--"))
.reduce("", (a, b) -> a + "\n" + b);
try (Connection connexion = DriverManager.getConnection(url, utilisateur, motDePasse)) {
connexion.setAutoCommit(false);
try (Statement ordre = connexion.createStatement();
PreparedStatement historique = connexion.prepareStatement(
"insert into historique_migrations(version, description) values (?, ?)")) {
for (String sql : texte.split(";")) {
if (!sql.isBlank()) {
ordre.execute(sql);
}
}
historique.setInt(1, script.version());
historique.setString(2, script.description());
historique.executeUpdate();
connexion.commit();
} catch (SQLException e) {
// attention : sous MySQL, un ordre de modification du schéma n'est pas annulable
connexion.rollback();
throw e;
}
}
}
}
|
- lignes 29-34 : un script est décrit par un record : sa version, sa description et son chemin ;
- ligne 37 : le nom d'un script suit la convention de Flyway : [V], le numéro de version, deux caractères de soulignement, une description ;
- lignes 40-45 : la table d'historique : une ligne par version appliquée, avec sa description et la date de son application ;
- lignes 59-76 : la liste des scripts. [getResource("/db/migration")] rend l'URL du dossier dans le classpath, qu'on liste avec [Files.list] (chapitre 5). Ce code suppose que le dossier est un vrai dossier (c'est le cas quand le programme est exécuté par Maven, depuis le dossier [target/classes]) ; Flyway sait aussi lire les scripts dans une archive [.jar]. Les scripts sont triés par numéro de version : un tri alphabétique placerait [V10] avant [V2] ;
- lignes 79-91 : les versions déjà appliquées, lues dans la table d'historique, créée au besoin ([create table if not exists]) ;
- lignes 94-97 : les scripts qui restent à appliquer ;
- lignes 100-110 : l'application des scripts en attente, jusqu'à une version donnée ;
- lignes 113-138 : l'application d'un script : ses ordres (séparés par des points-virgules, les lignes de commentaire étant ignorées), puis l'ajout de sa version à la table d'historique, dans une transaction. Une réserve, signalée par le commentaire de la ligne 133 : sous MySQL, un ordre de modification du schéma ([CREATE TABLE], [ALTER TABLE]) valide implicitement la transaction en cours et ne peut être annulé. En cas d'erreur au milieu d'un script, la base peut rester dans un état intermédiaire. D'où l'intérêt de scripts courts, d'une seule évolution chacun.
Le programme principal applique les migrations, puis demande à Hibernate de vérifier le schéma :
| package clients;
import jakarta.persistence.PersistenceException;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.AvailableSettings;
import org.hibernate.jpa.HibernatePersistenceConfiguration;
import org.hibernate.tool.schema.Action;
public class Main {
// la base de données des clients ; MySQL la crée si elle n'existe pas
public static final String URL = "jdbc:mysql://localhost:3306/hbclients?createDatabaseIfNotExist=true";
public static void main(String[] args) throws Exception {
// la version visée : la dernière par défaut, ou celle passée en argument
int cible = args.length > 0 ? Integer.parseInt(args[0]) : Integer.MAX_VALUE;
// l'état des migrations
Migrations migrations = new Migrations(URL, "root", "");
IO.println("versions appliquées : " + migrations.versionsAppliquees());
IO.println("migrations en attente : " + migrations.enAttente());
// application des migrations en attente
for (Migrations.Script script : migrations.appliquer(cible)) {
IO.println("migration appliquée : " + script);
}
// Hibernate vérifie que le schéma de la base correspond aux entités (validate)
try (SessionFactory sessionFactory = new HibernatePersistenceConfiguration("clients")
.managedClass(Client.class)
.jdbcUrl(URL)
.jdbcCredentials("root", "")
.property(AvailableSettings.FORMAT_SQL, true)
.schemaToolingAction(Action.VALIDATE)
.createEntityManagerFactory()) {
IO.println("le schéma de la base est conforme aux entités");
// la nouvelle colonne est utilisable
sessionFactory.inTransaction(session ->
session.persist(new Client("Dupont", "dupont@example.com", "01 23 45 67 89")));
sessionFactory.inSession(session -> session
.createSelectionQuery("from Client", Client.class)
.getResultList()
.forEach(IO::println));
} catch (PersistenceException e) {
// l'erreur de validation est la cause de l'exception
Throwable cause = e.getCause() == null ? e : e.getCause();
IO.println(cause.getClass().getSimpleName() + " : " + cause.getMessage());
}
}
}
|
- ligne 16 : on peut indiquer, en argument, la version jusqu'à laquelle appliquer les migrations ; par défaut, toutes sont appliquées ;
- lignes 19-26 : l'état des migrations, puis leur application ;
- lignes 29-35 : la [SessionFactory] est créée avec l'action [VALIDATE] : Hibernate vérifie que le schéma de la base correspond à l'entité [Client]. Si ce n'est pas le cas, la création de la [SessionFactory] échoue avec une [PersistenceException], dont la cause est l'erreur de validation (lignes 44-48) ;
- lignes 38-43 : la nouvelle colonne est utilisée.
11.9.5. Générer les scripts avec Hibernate
Les scripts SQL s'écrivent à la main. Hibernate peut cependant aider à les écrire : il sait produire, sans l'exécuter, le script de création du schéma, ou, en comparant les entités à une base existante, le script de sa mise à jour. C'est ce que fait la classe [GenerationScript] :
| package clients;
import java.io.StringWriter;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.AvailableSettings;
import org.hibernate.jpa.HibernatePersistenceConfiguration;
// génération par Hibernate du script SQL qui crée (create) ou met à jour (update) le schéma de la base
public class GenerationScript {
public static void main(String[] args) {
// l'action : create (par défaut) ou update
String action = args.length > 0 ? args[0] : "create";
StringWriter script = new StringWriter();
// une SessionFactory qui écrit le script sans l'exécuter
try (SessionFactory _ = new HibernatePersistenceConfiguration("clients")
.managedClass(Client.class)
.jdbcUrl(Main.URL)
.jdbcCredentials("root", "")
.property(AvailableSettings.JAKARTA_HBM2DDL_SCRIPTS_ACTION, action)
.property(AvailableSettings.JAKARTA_HBM2DDL_SCRIPTS_CREATE_TARGET, script)
.property(AvailableSettings.HBM2DDL_DELIMITER, ";")
.property(AvailableSettings.FORMAT_SQL, true)
.createEntityManagerFactory()) {
// le script, sans le retrait de 4 espaces de ses lignes
script.toString().strip().lines()
.map(ligne -> ligne.startsWith(" ") ? ligne.substring(4) : ligne)
.forEach(ligne -> IO.println(ligne.stripTrailing()));
}
}
}
|
- ligne 13 : l'action, passée en argument : [create] ou [update] ;
- ligne 16 : la [SessionFactory] n'est créée que pour produire le script : la variable qui la reçoit n'est jamais utilisée. Java 22 permet de l'appeler [_] (variable anonyme) ;
- lignes 20-23 : les propriétés de génération de script, déjà rencontrées dans le module [modele]. Cette fois, Hibernate se connecte à la base : pour l'action [update], il a besoin de connaître son schéma.
Le script de création, produit à partir de la version actuelle de l'entité, sert à créer d'un coup une base neuve (pour des tests, par exemple) :
| > mvn compile exec:java -Dexec.mainClass=clients.GenerationScript
create table clients (
id integer not null auto_increment,
telephone varchar(20),
nom varchar(50) not null,
email varchar(100) not null,
primary key (id)
) engine=InnoDB;
|
Avec la première version de l'entité (sans téléphone), il aurait donné, à l'ordre des colonnes près, le script [V1__creation_clients.sql].
11.9.6. Appliquer les migrations
Supposons que la base n'existe pas encore. On applique la première migration seulement, en passant la version 1 en argument :
| > mvn compile exec:java -Dexec.args="1"
versions appliquées : []
migrations en attente : [V1 (creation clients), V2 (ajout telephone)]
migration appliquée : V1 (creation clients)
SchemaManagementException : Schema validation: missing column [telephone] in table [clients]
|
- lignes 2-3 : aucune version n'est appliquée, deux scripts sont en attente. La table d'historique a été créée par la méthode [versionsAppliquees], et la base [hbclients] par MySQL ([createDatabaseIfNotExist=true]) ;
- ligne 4 : le script [V1] est appliqué ;
- ligne 5 : Hibernate constate que la colonne [telephone] de l'entité n'existe pas dans la table : c'est normal, puisque la base est à la version 1 et l'entité à la version 2. Avec l'entité de la version 1, la validation aurait réussi.
L'application évolue : on ajoute le téléphone à l'entité. Pour écrire le script de la version 2, on demande à Hibernate de comparer l'entité à la base, restée à la version 1 :
| > mvn compile exec:java -Dexec.mainClass=clients.GenerationScript -Dexec.args="update"
alter table clients
add column telephone varchar(20);
|
C'est, à la mise en forme près, le script [V2__ajout_telephone.sql]. On applique maintenant toutes les migrations en attente :
| > mvn compile exec:java
versions appliquées : [1]
migrations en attente : [V2 (ajout telephone)]
migration appliquée : V2 (ajout telephone)
le schéma de la base est conforme aux entités
SQL> -- début de transaction
SQL> insert into clients (email, nom, telephone)
SQL> values (?, ?, ?)
SQL> -- paramètre 1 = dupont@example.com
SQL> -- paramètre 2 = Dupont
SQL> -- paramètre 3 = 01 23 45 67 89
SQL> -- transaction validée (COMMIT)
SQL> select c1_0.id, c1_0.email, c1_0.nom, c1_0.telephone
SQL> from clients c1_0
[1, Dupont, dupont@example.com, 01 23 45 67 89]
|
- lignes 2-4 : la version 1 est appliquée, la version 2 est en attente, et elle est appliquée ;
- ligne 5 : la validation réussit ;
- lignes 6-15 : la nouvelle colonne est utilisable.
La table d'historique contient maintenant les deux versions :
| > mysql -uroot -t hbclients -e "SELECT * FROM historique_migrations"
+---------+------------------+---------------------+
| version | description | date_application |
+---------+------------------+---------------------+
| 1 | creation clients | 2026-10-03 17:27:22 |
| 2 | ajout telephone | 2026-10-03 17:27:27 |
+---------+------------------+---------------------+
|
Une nouvelle exécution du programme n'applique plus rien : les deux versions figurent dans l'historique.
11.9.7. Flyway
Flyway est l'outil de migration le plus utilisé du monde Java (Spring Boot l'exécute automatiquement au démarrage d'une application s'il est présent dans le classpath). Il fonctionne exactement selon le principe de notre exécuteur, en plus complet : scripts [V<version>__<description>.sql] cherchés par défaut dans le dossier [db/migration] du classpath, table d'historique [flyway_schema_history], application des scripts en attente dans l'ordre des versions. Il ajoute notamment une somme de contrôle de chaque script, qui détecte la modification d'un script déjà appliqué, des scripts « répétables » ([R__description.sql], réappliqués à chaque modification, pour les vues par exemple), des migrations écrites en Java, un verrou qui empêche deux instances de l'application de migrer la base en même temps, et une extension Maven ([mvn flyway:migrate], [mvn flyway:info]).
Pour l'utiliser dans le module [clients], il suffirait d'ajouter les dépendances [org.flywaydb:flyway-core] et [org.flywaydb:flyway-mysql] (dans leur dernière version), de garder les deux scripts tels quels, puisqu'ils suivent déjà sa convention, et de remplacer l'usage de la classe [Migrations] par une ligne : [Flyway.configure().dataSource(URL, "root", "").load().migrate();]
Liquibase, l'autre outil courant, décrit les évolutions du schéma dans des fichiers XML, YAML ou SQL (les « changelogs »), et sait produire le SQL propre à chaque SGBD. Dans les deux cas, Hibernate est configuré avec [validate] ou [none], et ne touche plus au schéma.
11.10. Hibernate et les threads virtuels
Une requête à une base de données est une entrée-sortie : pendant que le serveur travaille, le thread qui attend ne fait rien. Le chapitre 9 a présenté deux façons de ne pas gaspiller de threads pendant ces attentes : la programmation asynchrone ([CompletableFuture]) et les threads virtuels (Java 21). L'API d'Hibernate, comme JDBC qu'il utilise, est bloquante : [getResultList] ne rend la main qu'une fois les lignes lues. Avec les threads virtuels, ce n'est plus un inconvénient : un thread virtuel bloqué sur une entrée-sortie libère le thread du système qui l'exécutait, et l'on peut en créer des milliers. On écrit donc un code bloquant ordinaire, exécuté par des threads virtuels.
Une règle doit être respectée : une session n'est pas thread-safe. On ne la partage jamais entre plusieurs threads : chaque thread (chaque tâche) ouvre sa propre session auprès de la [SessionFactory], qui, elle, est partagée. Hibernate ne détecte pas toujours un usage simultané d'une même session ; les erreurs qui en résultent sont imprévisibles.
Le module [asynchrone] applique cette règle :
| package asynchrone;
import articles.Article;
import articles.BaseArticles;
import jakarta.persistence.QueryTimeoutException;
import java.math.BigDecimal;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.stream.Stream;
import org.hibernate.SessionFactory;
public class Main {
public static void main(String[] args) throws InterruptedException, ExecutionException {
try (SessionFactory sessionFactory = BaseArticles.creerSessionFactory()) {
// la table [articles] est recréée avec ses six articles
BaseArticles.initialiser(sessionFactory);
// 1 - deux requêtes exécutées en même temps par deux threads virtuels, chacun avec sa session
IO.println("--- deux requêtes en parallèle");
try (ExecutorService executeur = Executors.newVirtualThreadPerTaskExecutor()) {
Future<List<Article>> petitsPrix = executeur.submit(() -> sessionFactory.fromSession(s -> s
.createSelectionQuery("from Article where prix < 200 order by prix", Article.class)
.getResultList()));
String hql = "select count(*) from Article where stockActuel < stockMinimum";
Future<Long> enRupture = executeur.submit(() -> sessionFactory.fromSession(s -> s
.createSelectionQuery(hql, Long.class)
.getSingleResult()));
// get() attend la fin de chaque tâche
petitsPrix.get().forEach(IO::println);
IO.println(enRupture.get() + " article(s) en rupture");
}
// 2 - une transaction exécutée par un thread virtuel, suivie d'un traitement de son résultat
IO.println("--- CompletableFuture");
try (ExecutorService executeur = Executors.newVirtualThreadPerTaskExecutor()) {
CompletableFuture<Void> traitement = CompletableFuture
.supplyAsync(() -> sessionFactory.fromTransaction(session -> {
Article arc = session
.createSelectionQuery("from Article where nom = 'arc'", Article.class)
.getSingleResult();
arc.setPrix(BigDecimal.valueOf(550));
return arc;
}), executeur)
.thenAccept(arc -> IO.println("nouveau prix de l'arc : " + arc.getPrix()));
traitement.join();
}
// 3 - un flux : les entités sont traitées au fur et à mesure de leur lecture
IO.println("--- getResultStream");
sessionFactory.inSession(session -> {
try (Stream<Article> articles = session
.createSelectionQuery("from Article where prix >= 300", Article.class)
.getResultStream()) {
articles.forEach(article -> IO.println("lu : " + article.getNom()));
}
});
// 4 - une requête trop longue est interrompue au bout du délai fixé (en secondes)
IO.println("--- délai dépassé");
try {
sessionFactory.inSession(session -> session
.createNativeQuery("select sleep(5)", Integer.class)
.setTimeout(1)
.getSingleResult());
} catch (QueryTimeoutException e) {
IO.println(e.getClass().getSimpleName() + " : " + e.getMessage());
}
}
}
}
|
Résultats :
| > mvn compile exec:java
--- deux requêtes en parallèle
SQL> select a1_0.id, a1_0.nom, a1_0.prix, a1_0.stockActuel, a1_0.stockMinimum
SQL> from articles a1_0
SQL> where a1_0.prix<200
SQL> order by a1_0.prix
SQL> select count(*)
SQL> from articles a1_0
SQL> where a1_0.stockActuel<a1_0.stockMinimum
[2, pompe, 10,00, 10, 2]
[4, flèches - lot de 6, 100,00, 12, 20]
[6, bouteilles d'oxygène, 120,00, 10, 5]
1 article(s) en rupture
--- CompletableFuture
SQL> -- début de transaction
SQL> select a1_0.id, a1_0.nom, a1_0.prix, a1_0.stockActuel, a1_0.stockMinimum
SQL> from articles a1_0
SQL> where a1_0.nom='arc'
SQL> update articles
SQL> set nom=?, prix=?, stockActuel=?, stockMinimum=?
SQL> where id=?
SQL> -- paramètre 1 = arc
SQL> -- paramètre 2 = 550
SQL> -- paramètre 3 = 4
SQL> -- paramètre 4 = 1
SQL> -- paramètre 5 = 3
SQL> -- transaction validée (COMMIT)
nouveau prix de l'arc : 550
--- getResultStream
SQL> select a1_0.id, a1_0.nom, a1_0.prix, a1_0.stockActuel, a1_0.stockMinimum
SQL> from articles a1_0
SQL> where a1_0.prix>=300
lu : vélo
lu : arc
lu : combinaison de plongée
--- délai dépassé
SQL> select sleep(5)
SQL> -- échec : Statement cancelled due to timeout or client request
QueryTimeoutException : JDBC exception executing SQL [Statement cancelled due to timeout or client request] [select sleep(5)]
|
Commentaires :
- ligne 25 : un exécuteur qui crée un thread virtuel par tâche (chapitre 9). L'instruction [try] avec ressources attend la fin de toutes les tâches avant de le fermer ;
- lignes 26-32 : deux tâches soumises à l'exécuteur. Chacune ouvre sa propre session ([fromSession]) et exécute une requête ; les deux requêtes s'exécutent en même temps, sur deux connexions du pool. Leur ordre dans le journal (lignes 3-9) peut donc varier d'une exécution à l'autre ;
- lignes 34-35 : [get()] attend la fin de chaque tâche et rend son résultat (lignes 10-13) ;
- lignes 41-50 : une [CompletableFuture] (chapitre 9) dont la première étape, une transaction complète, est exécutée par un thread virtuel ([supplyAsync(..., executeur)]), et la seconde affiche son résultat. L'article rendu par la transaction est détaché (sa session est fermée) : on peut lire ses champs, pas charger ses associations ;
- lignes 56-60 : [getResultStream] rend un flux (« stream ») : les entités sont traitées au fur et à mesure de leur lecture, sans constituer de liste (lignes 33-35). C'est utile pour traiter un grand nombre de lignes. Avec MySQL, il faut savoir que le pilote lit par défaut toutes les lignes du résultat avant de rendre la première ; pour une lecture réellement progressive, on lui demande d'utiliser un curseur (paramètre [useCursorFetch=true] de l'URL et taille de lot fixée par [setFetchSize]) ;
- lignes 66-69 : un délai maximal d'exécution, en secondes, fixé par [setTimeout]. La requête (une requête SQL native, que nous verrons plus loin) dure cinq secondes ; au bout d'une seconde, le pilote JDBC l'interrompt, et Hibernate lance une [QueryTimeoutException] (lignes 37-39). C'est l'équivalent, pour une requête, de l'annulation d'une tâche ([Future.cancel], chapitre 9), qui serait ici sans effet : interrompre un thread bloqué dans le pilote JDBC n'interrompt pas la requête en cours sur le serveur.
Pour les applications qui veulent une API réellement non bloquante, il existe Hibernate Reactive, une variante d'Hibernate qui s'appuie sur des pilotes réactifs (ceux de Vert.x) plutôt que sur JDBC, et dont les méthodes rendent des [CompletionStage] ou des objets de la bibliothèque Mutiny. Les threads virtuels rendent cette complexité inutile dans la plupart des cas : c'est le choix de ce cours.
11.11. Transactions et concurrence
11.11.1. Les transactions
Le chapitre 10 a présenté les transactions : un ensemble d'opérations qui réussissent toutes ou échouent toutes. Avec Hibernate :
- toute écriture se fait dans une transaction, ouverte par [session.beginTransaction()] et terminée par [commit] ou [rollback]. Les méthodes [inTransaction] et [fromTransaction] de la [SessionFactory] le font pour nous : elles valident la transaction si la lambda se termine normalement, et l'annulent si elle lance une exception, qui est alors relancée ;
- tous les ordres envoyés pendant la transaction ([INSERT] immédiats, synchronisations explicites par [flush], synchronisation du [commit]) sont validés ou annulés ensemble ;
- une erreur du SGBD est transmise sous la forme d'une exception d'Hibernate, dérivée de [JDBCException], qui encapsule la [SQLException] du pilote : [ConstraintViolationException] pour la violation d'une contrainte (clé primaire, unicité, clé étrangère, [NOT NULL]), [SQLGrammarException] pour une erreur dans l'ordre SQL, etc.
Le module [transactions] illustre les deux façons de gérer une transaction :
| package transactions;
import articles.Article;
import articles.BaseArticles;
import java.math.BigDecimal;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
import org.hibernate.exception.ConstraintViolationException;
public class Main {
public static void main(String[] args) {
try (SessionFactory sessionFactory = BaseArticles.creerSessionFactory()) {
// la table [articles] est recréée avec ses six articles
BaseArticles.initialiser(sessionFactory);
// 1 - inTransaction : validation si tout réussit, annulation en cas d'exception
IO.println("--- deux ajouts dont le second viole l'unicité du nom");
try {
sessionFactory.inTransaction(session -> {
session.persist(new Article("raquette", BigDecimal.valueOf(80), 5, 2));
session.persist(new Article("vélo", BigDecimal.valueOf(450), 3, 1));
});
} catch (ConstraintViolationException e) {
IO.println("%s : contrainte [%s] violée"
.formatted(e.getClass().getSimpleName(), e.getConstraintName()));
}
afficher(sessionFactory, "raquette");
// 2 - une transaction gérée par le code : plusieurs écritures, validées ou annulées ensemble
IO.println("--- transaction annulée");
try (Session session = sessionFactory.openSession()) {
Transaction transaction = session.beginTransaction();
try {
// vente de deux vélos : l'UPDATE est envoyé par flush
Article velo = session
.createSelectionQuery("from Article where nom = 'vélo'", Article.class)
.getSingleResult();
velo.setStockActuel(velo.getStockActuel() - 2);
session.flush();
// ajout d'un article dont le nom existe déjà : erreur
session.persist(new Article("pompe", BigDecimal.valueOf(12), 5, 1));
// validation de l'ensemble (on n'y arrive pas ici)
transaction.commit();
} catch (ConstraintViolationException e) {
IO.println(e.getClass().getSimpleName() + " : " + e.getSQLException().getMessage());
// annulation de toutes les opérations de la transaction
transaction.rollback();
}
}
// la vente des vélos a été annulée
afficher(sessionFactory, "vélo");
}
}
// affichage d'un article lu dans la base, avec une nouvelle session
private static void afficher(SessionFactory sessionFactory, String nom) {
Article article = sessionFactory.fromSession(session -> session
.createSelectionQuery("from Article where nom = :nom", Article.class)
.setParameter("nom", nom)
.getSingleResultOrNull());
IO.println("article [%s] dans la base : %s".formatted(nom, article == null ? "absent" : article));
}
}
|
Résultats :
| > mvn compile exec:java
--- deux ajouts dont le second viole l'unicité du nom
SQL> -- début de transaction
SQL> insert into articles (nom, prix, stockActuel, stockMinimum)
SQL> values (?, ?, ?, ?)
SQL> -- paramètre 1 = raquette
SQL> -- paramètre 2 = 80
SQL> -- paramètre 3 = 5
SQL> -- paramètre 4 = 2
SQL> insert into articles (nom, prix, stockActuel, stockMinimum)
SQL> values (?, ?, ?, ?)
SQL> -- paramètre 1 = vélo
SQL> -- paramètre 2 = 450
SQL> -- paramètre 3 = 3
SQL> -- paramètre 4 = 1
SQL> -- échec : Duplicate entry 'vélo' for key 'articles.uk_articles_nom'
SQL> -- transaction annulée (ROLLBACK)
ConstraintViolationException : contrainte [articles.uk_articles_nom] violée
SQL> select a1_0.id, a1_0.nom, a1_0.prix, a1_0.stockActuel, a1_0.stockMinimum
SQL> from articles a1_0
SQL> where a1_0.nom=?
SQL> -- paramètre 1 = raquette
article [raquette] dans la base : absent
--- transaction annulée
SQL> -- début de transaction
SQL> select a1_0.id, a1_0.nom, a1_0.prix, a1_0.stockActuel, a1_0.stockMinimum
SQL> from articles a1_0
SQL> where a1_0.nom='vélo'
SQL> update articles
SQL> set nom=?, prix=?, stockActuel=?, stockMinimum=?
SQL> where id=?
SQL> -- paramètre 1 = vélo
SQL> -- paramètre 2 = 500.00
SQL> -- paramètre 3 = 8
SQL> -- paramètre 4 = 5
SQL> -- paramètre 5 = 1
SQL> insert into articles (nom, prix, stockActuel, stockMinimum)
SQL> values (?, ?, ?, ?)
SQL> -- paramètre 1 = pompe
SQL> -- paramètre 2 = 12
SQL> -- paramètre 3 = 5
SQL> -- paramètre 4 = 1
SQL> -- échec : Duplicate entry 'pompe' for key 'articles.uk_articles_nom'
ConstraintViolationException : Duplicate entry 'pompe' for key 'articles.uk_articles_nom'
SQL> -- transaction annulée (ROLLBACK)
SQL> select a1_0.id, a1_0.nom, a1_0.prix, a1_0.stockActuel, a1_0.stockMinimum
SQL> from articles a1_0
SQL> where a1_0.nom=?
SQL> -- paramètre 1 = vélo
article [vélo] dans la base : [1, vélo, 500,00, 10, 5]
|
Commentaires :
- lignes 21-24 : on ajoute deux articles dans une transaction gérée par [inTransaction], le second ayant le nom d'un article existant. Les deux [INSERT] sont exécutés aussitôt (lignes 4-15) ; le second viole la contrainte d'unicité de la colonne [nom] : MySQL le refuse (ligne 16), [persist] lance une [ConstraintViolationException], [inTransaction] annule la transaction (ligne 17) et relance l'exception ;
- lignes 25-28 : l'exception est interceptée ; [getConstraintName] donne le nom de la contrainte violée (ligne 18). On mesure ici l'intérêt d'avoir nommé la contrainte ;
- ligne 29 : la raquette, dont l'insertion avait réussi, n'est pas dans la base (ligne 23) : l'annulation de la transaction l'a supprimée ;
- lignes 33-51 : une transaction gérée par le code. On vend deux vélos, et la modification est envoyée au SGBD par un [flush] explicite (lignes 29-36) ; puis on ajoute un article dont le nom existe déjà : l'insertion échoue (ligne 43) ;
- ligne 45 : la validation n'est jamais atteinte ;
- lignes 46-50 : dans le [catch], on annule la transaction (ligne 45 des résultats). [getSQLException] donne l'exception d'origine du pilote JDBC, et son message celui de MySQL (ligne 44) ;
- ligne 53 : la vente des vélos, pourtant envoyée au SGBD par le [flush], a été annulée : le stock du vélo est toujours de 10 (ligne 50) ;
- lignes 58-64 : une méthode qui relit un article avec une nouvelle session, pour voir l'état réel de la base et non les entités de la session de travail.
Après une exception, une session ne doit plus être utilisée : son contexte de persistance peut contenir des entités dans un état incohérent avec la base. On annule la transaction, on ferme la session, et l'on recommence au besoin avec une nouvelle session. C'est ce que fait [inTransaction], et ce que fait notre code en sortant du bloc [try] qui ferme la session.
Le niveau d'isolation des transactions est celui de la connexion JDBC : [REPEATABLE_READ], la valeur par défaut de MySQL, comme l'indiquait le premier exemple. On peut le changer pour toute la [SessionFactory] avec la méthode [jdbcTransactionIsolation] de la configuration.
11.11.2. La concurrence optimiste
Deux utilisateurs lisent le même article, dont le stock est de 10. Le premier vend un article et enregistre un stock de 9. Le second, qui a lu le stock avant cet enregistrement, vend trois articles et enregistre un stock de 7 : la vente du premier est perdue. C'est le problème des mises à jour perdues.
Une solution serait de verrouiller la ligne pendant toute la durée du travail de chaque utilisateur (concurrence « pessimiste ») : elle bloque les autres utilisateurs, parfois longtemps. Avec Hibernate, on préfère en général la concurrence optimiste : on ne verrouille rien, mais on vérifie, au moment de l'enregistrement, que la ligne n'a pas été modifiée depuis sa lecture. La façon standard de le faire est d'ajouter à l'entité un numéro de version, un champ annoté [@Version]. Hibernate l'incrémente à chaque modification de la ligne, et ajoute à la clause [WHERE] des ordres [UPDATE] et [DELETE] la condition « la version a toujours la valeur lue ». Si l'ordre ne modifie aucune ligne, c'est qu'un autre utilisateur est passé par là : Hibernate lance une [OptimisticLockException].
Le module [concurrence] utilise sa propre base, [hbconcurrence], et sa propre entité [Article], réduite à l'essentiel :
| // un article dont les modifications sont contrôlées par un numéro de version
@Entity
@Table(name = "articles")
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(nullable = false, length = 30)
private String nom;
private int stockActuel;
// le numéro de version : incrémenté par Hibernate à chaque modification de la ligne
@Version
private int version;
protected Article() {
}
public Article(String nom, int stockActuel) {
this.nom = nom;
this.stockActuel = stockActuel;
}
public int getStockActuel() {
return stockActuel;
}
public void setStockActuel(int stockActuel) {
this.stockActuel = stockActuel;
}
public int getVersion() {
return version;
}
}
|
- lignes 16-17 : le numéro de version, de type [int] (on peut aussi utiliser [long], ou une date de type [Instant]). L'application ne le modifie jamais elle-même.
Le programme simule les deux utilisateurs avec deux sessions :
| package concurrence;
import jakarta.persistence.OptimisticLockException;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
import org.hibernate.cfg.AvailableSettings;
import org.hibernate.jpa.HibernatePersistenceConfiguration;
import outils.JournalSql;
public class Main {
public static void main(String[] args) {
try (SessionFactory sessionFactory = new HibernatePersistenceConfiguration("concurrence")
.managedClass(Article.class)
.jdbcUrl("jdbc:mysql://localhost:3306/hbconcurrence?createDatabaseIfNotExist=true")
.jdbcCredentials("root", "")
.property(AvailableSettings.FORMAT_SQL, true)
.createEntityManagerFactory()) {
// la table [articles] est recréée avec un seul article, sans journal
JournalSql.sansJournal(() -> {
sessionFactory.getSchemaManager().drop(false);
sessionFactory.getSchemaManager().create(false);
sessionFactory.inTransaction(session -> session.persist(new Article("vélo", 10)));
});
// deux utilisateurs, deux sessions : chacun lit le même article
try (Session session1 = sessionFactory.openSession();
Session session2 = sessionFactory.openSession()) {
IO.println("--- les deux utilisateurs lisent le vélo");
Article velo1 = session1.find(Article.class, 1);
Article velo2 = session2.find(Article.class, 1);
// l'utilisateur 1 vend 1 vélo
IO.println("--- l'utilisateur 1 vend 1 vélo");
Transaction transaction1 = session1.beginTransaction();
velo1.setStockActuel(velo1.getStockActuel() - 1);
transaction1.commit();
IO.println("stock enregistré par l'utilisateur 1 : %d (version %d)"
.formatted(velo1.getStockActuel(), velo1.getVersion()));
// l'utilisateur 2 vend 3 vélos à partir d'une donnée périmée (stock = 10, version 0)
IO.println("--- l'utilisateur 2 vend 3 vélos");
Transaction transaction2 = session2.beginTransaction();
velo2.setStockActuel(velo2.getStockActuel() - 3);
try {
session2.flush();
transaction2.commit();
} catch (OptimisticLockException e) {
IO.println(e.getClass().getSimpleName() + " : " + e.getMessage());
transaction2.rollback();
}
}
// résolution : l'utilisateur 2 relit l'article, avec une nouvelle session, et recommence
IO.println("--- l'utilisateur 2 relit le vélo et recommence");
sessionFactory.inTransaction(session -> {
Article velo = session.find(Article.class, 1);
IO.println("stock relu : %d (version %d)"
.formatted(velo.getStockActuel(), velo.getVersion()));
velo.setStockActuel(velo.getStockActuel() - 3);
session.flush();
IO.println("stock enregistré par l'utilisateur 2 : %d (version %d)"
.formatted(velo.getStockActuel(), velo.getVersion()));
});
}
}
}
|
Résultats :
| > mvn compile exec:java
--- les deux utilisateurs lisent le vélo
SQL> select a1_0.id, a1_0.nom, a1_0.stockActuel, a1_0.version
SQL> from articles a1_0
SQL> where a1_0.id=?
SQL> -- paramètre 1 = 1
SQL> select a1_0.id, a1_0.nom, a1_0.stockActuel, a1_0.version
SQL> from articles a1_0
SQL> where a1_0.id=?
SQL> -- paramètre 1 = 1
--- l'utilisateur 1 vend 1 vélo
SQL> -- début de transaction
SQL> update articles
SQL> set nom=?, stockActuel=?, version=?
SQL> where id=? and version=?
SQL> -- paramètre 1 = vélo
SQL> -- paramètre 2 = 9
SQL> -- paramètre 3 = 1
SQL> -- paramètre 4 = 1
SQL> -- paramètre 5 = 0
SQL> -- transaction validée (COMMIT)
stock enregistré par l'utilisateur 1 : 9 (version 1)
--- l'utilisateur 2 vend 3 vélos
SQL> -- début de transaction
SQL> update articles
SQL> set nom=?, stockActuel=?, version=?
SQL> where id=? and version=?
SQL> -- paramètre 1 = vélo
SQL> -- paramètre 2 = 7
SQL> -- paramètre 3 = 1
SQL> -- paramètre 4 = 1
SQL> -- paramètre 5 = 0
OptimisticLockException : Unexpected row count (expected row count 1 but was 0) [update articles set nom=?,stockActuel=?,version=? where id=? and version=?] for entity [concurrence.Article with id '1']
SQL> -- transaction annulée (ROLLBACK)
--- l'utilisateur 2 relit le vélo et recommence
SQL> -- début de transaction
SQL> select a1_0.id, a1_0.nom, a1_0.stockActuel, a1_0.version
SQL> from articles a1_0
SQL> where a1_0.id=?
SQL> -- paramètre 1 = 1
stock relu : 9 (version 1)
SQL> update articles
SQL> set nom=?, stockActuel=?, version=?
SQL> where id=? and version=?
SQL> -- paramètre 1 = vélo
SQL> -- paramètre 2 = 6
SQL> -- paramètre 3 = 2
SQL> -- paramètre 4 = 1
SQL> -- paramètre 5 = 1
stock enregistré par l'utilisateur 2 : 6 (version 2)
SQL> -- transaction validée (COMMIT)
|
Commentaires :
- lignes 21-25 : la table est recréée avec un seul article, le vélo, de version 0 ;
- lignes 31-32 : chaque utilisateur lit le vélo dans sa propre session. La colonne [version] est lue avec les autres (lignes 3-10) ;
- lignes 36-38 : l'ordre [UPDATE] du premier utilisateur (lignes 13-20) écrit la nouvelle version (1) et contient la condition [version=?] avec la version lue (0). La ligne est modifiée ; le champ [version] de l'entité passe à 1 (ligne 22) ;
- lignes 44-48 : l'ordre du second utilisateur contient la même condition, avec la version qu'il a lue, 0, alors que la version de la ligne dans la base est maintenant 1. Aucune ligne n'est modifiée, et Hibernate lance une [OptimisticLockException] (ligne 33). On synchronise explicitement la session ([flush]) avant le [commit] pour obtenir cette exception telle quelle ; levée pendant le [commit], elle serait encapsulée dans une [RollbackException] ;
- lignes 57-65 : la résolution dépend de l'application : abandonner, prévenir l'utilisateur, ou, comme ici, relire l'article, avec une nouvelle session (la session qui a reçu l'exception ne doit plus servir), et refaire l'opération. Le stock relu est de 9, en version 1 (ligne 41), et la vente des trois vélos donne un stock de 6, en version 2 (ligne 50).
Deux variantes :
- sans colonne de version, Hibernate peut vérifier les anciennes valeurs des colonnes elles-mêmes : avec les annotations [@OptimisticLocking(type = OptimisticLockType.DIRTY)] et [@DynamicUpdate] sur l'entité, la clause [WHERE] de l'[UPDATE] contient la valeur lue de chaque colonne modifiée ([where id=? and stockActuel=?]). C'est utile pour une base existante qu'on ne peut pas modifier ;
- la concurrence pessimiste reste possible quand les conflits sont fréquents : [session.find(Article.class, 1, LockModeType.PESSIMISTIC_WRITE)] lit la ligne avec un ordre [select ... for update], qui la verrouille jusqu'à la fin de la transaction.
11.12. Le SQL natif
Hibernate génère le SQL à partir de HQL ou de l'API Criteria, mais il reste possible d'écrire soi-même le SQL d'une requête, pour utiliser une fonctionnalité propre au SGBD ou optimiser une requête difficile : on parle de requête native. Le module [sqlbrut] présente les méthodes disponibles :
| package sqlbrut;
import articles.Article;
import articles.BaseArticles;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.List;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
public class Main {
public static void main(String[] args) {
try (SessionFactory sessionFactory = BaseArticles.creerSessionFactory()) {
// la table [articles] est recréée avec ses six articles
BaseArticles.initialiser(sessionFactory);
try (Session session = sessionFactory.openSession()) {
// 1 - une requête SQL écrite à la main qui rend des entités
IO.println("--- createNativeQuery : des entités");
BigDecimal prixMin = BigDecimal.valueOf(200);
List<Article> chers = session
.createNativeQuery("""
select * from articles
where prix > :prixMin order by nom""", Article.class)
.setParameter("prixMin", prixMin)
.getResultList();
chers.forEach(IO::println);
// 2 - une valeur passée en paramètre : pas d'injection SQL possible
IO.println("--- tentative d'injection SQL avec un paramètre");
String nom = "x' or '1'='1";
long nb = session
.createNativeQuery("select count(*) from articles where nom = :nom", Long.class)
.setParameter("nom", nom)
.getSingleResult();
IO.println(nb + " article(s) trouvé(s)");
// la même requête construite par concaténation : à ne jamais faire
IO.println("--- la même tentative avec une concaténation");
String sql = "select count(*) from articles where nom = '" + nom + "'";
nb = session.createNativeQuery(sql, Long.class).getSingleResult();
IO.println(nb + " article(s) trouvé(s)");
// 3 - des résultats qui ne sont pas des entités
IO.println("--- createNativeQuery : des valeurs");
List<String> noms = session
.createNativeQuery("""
select nom from articles
where stockActuel < stockMinimum""", String.class)
.getResultList();
IO.println("en rupture : " + String.join(", ", noms));
BigDecimal valeur = session
.createNativeQuery("select sum(prix * stockActuel) from articles", BigDecimal.class)
.getSingleResult();
IO.println("valeur du stock : %.2f euros".formatted(valeur));
// 4 - un ordre de mise à jour
IO.println("--- createNativeMutationQuery");
int nbLignes = session.fromTransaction(transaction -> session
.createNativeMutationQuery("""
update articles set prix = prix * :coefficient
where nom = :nom""")
.setParameter("coefficient", new BigDecimal("0.9"))
.setParameter("nom", "arc")
.executeUpdate());
IO.println(nbLignes + " ligne(s) modifiée(s)");
// 5 - la connexion JDBC de la session, pour du code JDBC ordinaire
IO.println("--- doWork : la connexion JDBC");
session.doWork(connexion -> {
String requete = "select nom, prix from articles where nom = 'arc'";
try (Statement ordre = connexion.createStatement();
ResultSet lignes = ordre.executeQuery(requete)) {
while (lignes.next()) {
IO.println("%s : %.2f euros"
.formatted(lignes.getString("nom"), lignes.getBigDecimal("prix")));
}
}
});
}
}
}
}
|
Résultats :
| > mvn compile exec:java
--- createNativeQuery : des entités
SQL> select *
SQL> from articles
SQL> where prix > ?
SQL> order by nom
SQL> -- paramètre 1 = 200
[3, arc, 600,00, 4, 1]
[5, combinaison de plongée, 300,00, 8, 2]
[1, vélo, 500,00, 10, 5]
--- tentative d'injection SQL avec un paramètre
SQL> select count(*)
SQL> from articles
SQL> where nom = ?
SQL> -- paramètre 1 = x' or '1'='1
0 article(s) trouvé(s)
--- la même tentative avec une concaténation
SQL> select count(*)
SQL> from articles
SQL> where nom = 'x' or '1'='1'
6 article(s) trouvé(s)
--- createNativeQuery : des valeurs
SQL> select nom
SQL> from articles
SQL> where stockActuel < stockMinimum
en rupture : flèches - lot de 6
SQL> select sum(prix * stockActuel)
SQL> from articles
valeur du stock : 12300,00 euros
--- createNativeMutationQuery
SQL> -- début de transaction
SQL> update articles
SQL> set prix = prix * ?
SQL> where nom = ?
SQL> -- paramètre 1 = 0.9
SQL> -- paramètre 2 = arc
SQL> -- transaction validée (COMMIT)
1 ligne(s) modifiée(s)
--- doWork : la connexion JDBC
arc : 540,00 euros
|
Commentaires :
- lignes 22-27 : [createNativeQuery] exécute une requête SQL qui rend des lignes de la table d'une entité, et le second paramètre ([Article.class]) demande à Hibernate de les matérialiser en entités, gérées par la session. Les noms des colonnes du résultat doivent être ceux du modèle : [select *] convient ici. La requête est transmise telle quelle au SGBD (lignes 3-6), à ceci près que le paramètre nommé [:prixMin] devient un paramètre JDBC ([?]) ;
- lignes 32-36 : une tentative d'injection SQL. La valeur de [nom] est passée en paramètre : elle est comparée telle quelle à la colonne [nom], et aucun article ne correspond (ligne 16) ;
- lignes 40-41 : la même requête construite par concaténation. La valeur de [nom] devient du code SQL, la condition devient toujours vraie (ligne 20), et la requête compte les six articles (ligne 21) : c'est une injection SQL réussie. Comme au chapitre 10, une requête native ne doit contenir que du texte constant, les valeurs étant passées en paramètres ;
- lignes 46-54 : des requêtes qui rendent des valeurs simples ([String], [BigDecimal]) : le second paramètre de [createNativeQuery] est alors le type de la valeur. Pour plusieurs colonnes, on peut donner un record (ses composants reçoivent les colonnes dans l'ordre), ou [Object[].class] ;
- lignes 59-65 : [createNativeMutationQuery] prépare un ordre de mise à jour, exécuté par [executeUpdate], qui rend le nombre de lignes modifiées. Il s'exécute dans une transaction : la méthode [fromTransaction] de la session l'ouvre, la valide, et rend le résultat de la lambda ;
- lignes 70-79 : [doWork] donne accès à la connexion JDBC de la session ([java.sql.Connection]), pour du code JDBC ordinaire (chapitre 10) : appel d'une procédure stockée, usage d'une fonctionnalité du pilote... Le prix de l'arc a bien été diminué de 10 % (ligne 40).
Une requête native n'est pas portable d'un SGBD à l'autre, et Hibernate ne peut pas la vérifier avant de l'exécuter : on la réserve aux cas où HQL ne suffit pas. HQL couvre d'ailleurs une grande partie de SQL (jointures de toutes sortes, sous-requêtes, fonctions de fenêtrage, [union]...), et permet d'appeler une fonction du SGBD qu'il ne connaît pas.
11.13. Les mises à jour en masse, la lecture seule et les lots
Le suivi des modifications a un coût : pour augmenter le prix de cent articles, il faut lire les cent articles, les modifier un à un, et la synchronisation de la session enverra cent ordres [UPDATE]. Hibernate offre plusieurs moyens de traiter efficacement de grands nombres de lignes. Le module [majenmasse] les présente :
| package majenmasse;
import articles.Article;
import articles.BaseArticles;
import java.math.BigDecimal;
import java.util.List;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
public class Main {
public static void main(String[] args) {
try (SessionFactory sessionFactory = BaseArticles.creerSessionFactory()) {
// la table [articles] est recréée avec ses six articles
BaseArticles.initialiser(sessionFactory);
try (Session session = sessionFactory.openSession()) {
Transaction transaction = session.beginTransaction();
// 1 - update HQL : un seul ordre UPDATE, aucune entité chargée en mémoire
IO.println("--- réapprovisionnement des articles en rupture");
int nbLignes = session
.createMutationQuery("""
update Article set stockActuel = stockMinimum * 2
where stockActuel < stockMinimum""")
.executeUpdate();
IO.println(nbLignes + " article(s) modifié(s)");
IO.println("--- hausse de 10 % des prix inférieurs à 150 euros");
nbLignes = session
.createMutationQuery("update Article set prix = prix * 1.1 where prix < 150")
.executeUpdate();
IO.println(nbLignes + " article(s) modifié(s)");
// 2 - delete HQL : un seul ordre DELETE
IO.println("--- suppression des articles de plus de 550 euros");
nbLignes = session
.createMutationQuery("delete from Article where prix > 550")
.executeUpdate();
IO.println(nbLignes + " article(s) supprimé(s)");
transaction.commit();
// 3 - lecture seule : les entités lues ne sont pas surveillées
IO.println("--- lecture seule");
transaction = session.beginTransaction();
List<Article> articles = session
.createSelectionQuery("from Article order by id", Article.class)
.setReadOnly(true)
.getResultList();
articles.forEach(IO::println);
// la modification d'une entité en lecture seule est ignorée
articles.getFirst().setPrix(BigDecimal.ZERO);
IO.println("session modifiée : " + session.isDirty());
transaction.commit();
// 4 - un ordre update HQL ne modifie pas les entités déjà chargées par la session
IO.println("--- update HQL et entités chargées");
transaction = session.beginTransaction();
Article pompe = session.find(Article.class, 2);
session.createMutationQuery("update Article set prix = 9.5 where id = :id")
.setParameter("id", pompe.getId())
.executeUpdate();
IO.println("prix en mémoire : " + pompe.getPrix());
session.refresh(pompe);
IO.println("prix après refresh : " + pompe.getPrix());
transaction.commit();
}
// 5 - une session sans état (StatelessSession) et des ordres envoyés par lots
IO.println("--- StatelessSession et lots de 3 ordres");
sessionFactory.inStatelessTransaction(session -> {
session.setJdbcBatchSize(3);
List<Article> articles = session
.createSelectionQuery("from Article order by id", Article.class)
.getResultList();
for (Article article : articles) {
article.setStockActuel(article.getStockActuel() + 1);
// chaque modification est demandée explicitement : les entités ne sont pas surveillées
session.update(article);
}
});
}
}
}
|
Résultats (la fin, répétitive, est abrégée) :
| > mvn compile exec:java
SQL> -- début de transaction
--- réapprovisionnement des articles en rupture
SQL> update articles a1_0
SQL> set stockActuel=(a1_0.stockMinimum*2)
SQL> where a1_0.stockActuel<a1_0.stockMinimum
1 article(s) modifié(s)
--- hausse de 10 % des prix inférieurs à 150 euros
SQL> update articles a1_0
SQL> set prix=(a1_0.prix*1.1)
SQL> where a1_0.prix<150
3 article(s) modifié(s)
--- suppression des articles de plus de 550 euros
SQL> delete a1_0
SQL> from articles a1_0
SQL> where a1_0.prix>550
1 article(s) supprimé(s)
SQL> -- transaction validée (COMMIT)
--- lecture seule
SQL> -- début de transaction
SQL> select a1_0.id, a1_0.nom, a1_0.prix, a1_0.stockActuel, a1_0.stockMinimum
SQL> from articles a1_0
SQL> order by a1_0.id
[1, vélo, 500,00, 10, 5]
[2, pompe, 11,00, 10, 2]
[4, flèches - lot de 6, 110,00, 40, 20]
[5, combinaison de plongée, 300,00, 8, 2]
[6, bouteilles d'oxygène, 132,00, 10, 5]
session modifiée : false
SQL> -- transaction validée (COMMIT)
--- update HQL et entités chargées
SQL> -- début de transaction
SQL> update articles a1_0
SQL> set prix=9.5
SQL> where a1_0.id=?
SQL> -- paramètre 1 = 2
prix en mémoire : 11.00
SQL> select a1_0.id, a1_0.nom, a1_0.prix, a1_0.stockActuel, a1_0.stockMinimum
SQL> from articles a1_0
SQL> where a1_0.id=?
SQL> -- paramètre 1 = 2
prix après refresh : 9.50
SQL> -- transaction validée (COMMIT)
--- StatelessSession et lots de 3 ordres
SQL> -- début de transaction
SQL> select a1_0.id, a1_0.nom, a1_0.prix, a1_0.stockActuel, a1_0.stockMinimum
SQL> from articles a1_0
SQL> order by a1_0.id
SQL> update articles
SQL> set nom=?, prix=?, stockActuel=?, stockMinimum=?
SQL> where id=?
SQL> -- paramètre 1 = vélo
SQL> -- paramètre 2 = 500.00
SQL> -- paramètre 3 = 11
SQL> -- paramètre 4 = 5
SQL> -- paramètre 5 = 1
...
SQL> -- exécution d'un lot de 3 ordre(s)
SQL> update articles
SQL> set nom=?, prix=?, stockActuel=?, stockMinimum=?
SQL> where id=?
...
SQL> -- exécution d'un lot de 2 ordre(s)
SQL> -- transaction validée (COMMIT)
|
Commentaires :
- lignes 22-26 : un ordre [update] écrit en HQL, préparé par [createMutationQuery] et exécuté par [executeUpdate]. Il devient un unique ordre SQL [UPDATE] (lignes 4-6), exécuté par le SGBD sur toutes les lignes concernées, sans qu'aucune entité soit chargée en mémoire. La nouvelle valeur peut dépendre de la ligne elle-même ([stockMinimum * 2]). Un tel ordre s'exécute dans une transaction (ligne 18) ;
- lignes 30-32 : une hausse de 10 % des petits prix. Les constantes de la requête HQL ([1.1], [150]) sont écrites dans le texte même de l'ordre SQL (lignes 10-11) ;
- lignes 37-39 : un ordre [delete] HQL supprime l'arc, seul article de plus de 550 euros (lignes 14-16) ;
- lignes 46-49 : une lecture en lecture seule ([setReadOnly(true)]). Les entités lues sont gérées par la session, mais celle-ci n'en garde pas l'état d'origine et ne les surveille pas : c'est plus rapide et plus économe en mémoire, pour les données que l'on lit sans les modifier (affichage, calculs). La contrepartie est que la modification d'une telle entité est ignorée (ligne 52) : la session n'a rien à synchroniser (ligne 29) et le [commit] n'envoie aucun ordre. On peut aussi mettre toute une session en lecture seule ([session.setDefaultReadOnly(true)]) ;
- lignes 59-65 : un piège. Un ordre [update] HQL travaille directement sur la base : il ne modifie pas les entités déjà chargées par la session. La pompe gérée par la session garde son ancien prix (ligne 37) jusqu'à ce qu'on la relise avec [session.refresh(pompe)] (lignes 38-42) ;
- lignes 71-81 : une session sans état ([StatelessSession]), ouverte ici avec une transaction par [inStatelessTransaction]. Elle n'a pas de contexte de persistance : pas de cache de premier niveau, pas de suivi des modifications, pas de chargement paresseux, pas de cascades. Chaque opération est demandée explicitement ([insert], [update], [delete], [get]) et exécutée telle quelle. C'est l'outil des traitements de masse : importer un gros fichier, recalculer toutes les lignes d'une table ;
- ligne 72 : les ordres SQL seront envoyés au SGBD par lots (« batches », cf. chapitre 10) de trois : le pilote JDBC transmet trois ordres en un seul échange avec le serveur. Le journal montre les trois premiers [UPDATE], puis l'exécution du lot (ligne 58), et la fin, avec le dernier lot de deux ordres (ligne 63). Une application fixe la taille des lots pour toutes ses sessions avec la propriété [hibernate.jdbc.batch_size] (de 10 à 50 en général). Rappelons que les insertions dans une table dont la clé est générée par [IDENTITY] ne peuvent pas être regroupées en lots.
Pour un traitement de masse avec une session ordinaire, il faut éviter que son contexte de persistance n'accumule les entités : on synchronise et on vide la session régulièrement, par exemple tous les cinquante enregistrements ([session.flush(); session.clear();]).
11.14. Hibernate et l'injection de dépendances : Spring
11.14.1. La [SessionFactory] dans le conteneur
Les exemples précédents créaient leurs sessions eux-mêmes. Dans une application organisée en couches (chapitre 6), et dans toutes les applications Spring, la [SessionFactory] est un objet du conteneur d'injection de dépendances, comme les couches qui l'utilisent. Spring Framework fournit pour cela, dans sa bibliothèque [spring-orm] :
- la classe [LocalSessionFactoryBean], qui construit la [SessionFactory] à partir d'une source de données ([DataSource], chapitre 10) et de la liste des entités ;
- la classe [HibernateTransactionManager], un gestionnaire de transactions qui ouvre une session Hibernate pour chaque transaction, l'associe au thread courant, et la ferme à la fin de la transaction ;
- l'annotation [@Transactional] (bibliothèque [spring-tx]) : placée sur une méthode, ou sur une classe pour toutes ses méthodes publiques, elle demande à Spring d'exécuter la méthode dans une transaction, validée si la méthode se termine normalement, annulée si elle lance une exception non contrôlée. On parle de transactions déclaratives.
À l'intérieur d'une méthode transactionnelle, [sessionFactory.getCurrentSession()] rend la session de la transaction en cours. Une couche [dao] peut alors être un singleton qui reçoit la [SessionFactory] (thread-safe) et lui demande, dans chaque méthode, la session courante : la règle « une session par unité de travail, jamais partagée entre threads » est respectée sans effort.
11.14.2. Le module [injection]
Le module [injection] dépend, en plus du module [articles], de Spring et de HikariCP :
| <dependencies>
<!-- l'entité [Article] et la base [hbarticles] (Hibernate, MySQL et le journal SQL compris) -->
<dependency>
<groupId>exemples.hibernate</groupId>
<artifactId>articles</artifactId>
</dependency>
<!-- Spring : conteneur, transactions déclaratives, intégration d'Hibernate -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-orm</artifactId>
</dependency>
<!-- le pool de connexions HikariCP -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
</dependency>
</dependencies>
|
La configuration Spring est écrite en Java, avec les annotations [@Configuration] et [@Bean] du chapitre 6 :
| package injection;
import articles.Article;
import articles.BaseArticles;
import com.zaxxer.hikari.HikariDataSource;
import java.util.Properties;
import javax.sql.DataSource;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.AvailableSettings;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.orm.jpa.hibernate.HibernateTransactionManager;
import org.springframework.orm.jpa.hibernate.LocalSessionFactoryBean;
import org.springframework.transaction.annotation.EnableTransactionManagement;
// la configuration Spring : pool de connexions, SessionFactory, gestionnaire de transactions, couche [dao]
@Configuration
@EnableTransactionManagement
public class Config {
// le pool de connexions HikariCP (chapitre 10)
@Bean(destroyMethod = "close")
public HikariDataSource dataSource() {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(BaseArticles.URL);
dataSource.setUsername(BaseArticles.UTILISATEUR);
dataSource.setPassword(BaseArticles.MOT_DE_PASSE);
return dataSource;
}
// la SessionFactory, construite par Spring avec le pool de connexions
@Bean
public LocalSessionFactoryBean sessionFactory(DataSource dataSource) {
LocalSessionFactoryBean fabrique = new LocalSessionFactoryBean();
fabrique.setDataSource(dataSource);
fabrique.setAnnotatedClasses(Article.class);
Properties proprietes = new Properties();
proprietes.put(AvailableSettings.FORMAT_SQL, "true");
fabrique.setHibernateProperties(proprietes);
return fabrique;
}
// le gestionnaire des transactions : une session Hibernate par transaction
@Bean
public HibernateTransactionManager transactionManager(SessionFactory sessionFactory) {
return new HibernateTransactionManager(sessionFactory);
}
// la couche [dao] : un singleton qui reçoit la SessionFactory
@Bean
public ArticlesDao articlesDao(SessionFactory sessionFactory) {
return new ArticlesDao(sessionFactory);
}
}
|
- ligne 18 : [@EnableTransactionManagement] active la prise en compte de l'annotation [@Transactional] : Spring remplacera chaque objet dont une méthode porte cette annotation par un mandataire (« proxy ») qui ouvre et termine la transaction autour de l'appel ;
- lignes 22-29 : le pool de connexions HikariCP du chapitre 10. L'attribut [destroyMethod] indique la méthode à appeler à la fermeture du conteneur ;
- lignes 32-41 : la [SessionFactory]. Un [LocalSessionFactoryBean] est une fabrique de [SessionFactory] : Spring enregistre dans le conteneur l'objet qu'elle produit, et c'est une [SessionFactory] que l'on obtient en demandant ce bean. Elle reçoit le pool de connexions (qui remplace le pool intégré d'Hibernate), les entités et les propriétés d'Hibernate ;
- lignes 44-47 : le gestionnaire de transactions ;
- lignes 50-53 : la couche [dao], qui reçoit la [SessionFactory].
La couche [dao] :
| package injection;
import articles.Article;
import java.util.List;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.springframework.transaction.annotation.Transactional;
// la couche [dao] : chaque méthode publique s'exécute dans une transaction ouverte par Spring
@Transactional
public class ArticlesDao {
// la SessionFactory, partagée par tous les threads
private final SessionFactory sessionFactory;
public ArticlesDao(SessionFactory sessionFactory) {
this.sessionFactory = sessionFactory;
}
// deux demandes de la session courante, dans une même transaction, rendent-elles la même session ?
public boolean memeSession() {
return sessionFactory.getCurrentSession() == sessionFactory.getCurrentSession();
}
// la session de la transaction
public Session session() {
return sessionFactory.getCurrentSession();
}
// les articles à réapprovisionner ; une transaction en lecture seule
@Transactional(readOnly = true)
public List<String> articlesEnRupture() {
String hql = "select nom from Article where stockActuel < stockMinimum";
return sessionFactory.getCurrentSession().createSelectionQuery(hql, String.class).getResultList();
}
// l'article de nom donné
@Transactional(readOnly = true)
public Article article(String nom) {
return sessionFactory.getCurrentSession()
.createSelectionQuery("from Article where nom = :nom", Article.class)
.setParameter("nom", nom)
.getSingleResult();
}
// la vente d'une quantité d'un article : une exception annule la transaction
public void vendre(String nom, int quantite) {
Article article = article(nom);
article.setStockActuel(article.getStockActuel() - quantite);
if (article.getStockActuel() < 0) {
throw new IllegalStateException("stock insuffisant pour l'article [" + nom + "]");
}
}
}
|
- ligne 10 : toutes les méthodes publiques de la classe sont transactionnelles ;
- lignes 21-23 : pendant une même transaction, deux demandes de la session courante rendent-elles la même session ?
- lignes 26-28 : la session de la transaction, rendue à l'appelant (pour l'expérience qui suit : on ne le fait pas dans une application) ;
- lignes 31-35 : une transaction en lecture seule ([readOnly = true]) : Spring met la session en lecture seule et ne la synchronise pas ;
- lignes 47-53 : la vente d'une quantité d'un article. Si le stock devient négatif, la méthode lance une [IllegalStateException], une exception non contrôlée : Spring annule la transaction. L'appel de la méthode [article] (ligne 48) est un appel interne à l'objet, qui ne passe pas par le mandataire : il s'exécute dans la transaction de [vendre].
Le programme principal :
| package injection;
import articles.BaseArticles;
import org.hibernate.HibernateException;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class Main {
public static void main(String[] args) {
// le conteneur Spring, configuré par la classe [Config]
try (var contexte = new AnnotationConfigApplicationContext(Config.class)) {
// la table [articles] est recréée avec ses six articles
SessionFactory sessionFactory = contexte.getBean(SessionFactory.class);
BaseArticles.initialiser(sessionFactory);
ArticlesDao dao = contexte.getBean(ArticlesDao.class);
// 1 - pendant une transaction, la session courante est toujours la même
IO.println("--- sessions et transactions");
IO.println("une transaction : même session = " + dao.memeSession());
// 2 - deux transactions : deux sessions, fermées à la fin de leur transaction
Session session1 = dao.session();
Session session2 = dao.session();
IO.println("deux transactions : même session = %s, première session ouverte = %s"
.formatted(session1 == session2, session1.isOpen()));
// 3 - hors d'une transaction, il n'y a pas de session courante
try {
sessionFactory.getCurrentSession();
} catch (HibernateException e) {
IO.println(e.getClass().getSimpleName() + " : " + e.getMessage());
}
// 4 - des méthodes transactionnelles de la couche [dao]
IO.println("--- articles en rupture");
IO.println("en rupture : " + dao.articlesEnRupture());
IO.println("--- vente de 2 vélos");
dao.vendre("vélo", 2);
IO.println("--- vente de 50 pompes");
try {
dao.vendre("pompe", 50);
} catch (IllegalStateException e) {
IO.println(e.getClass().getSimpleName() + " : " + e.getMessage());
}
IO.println("--- état de la base");
IO.println(dao.article("vélo"));
IO.println(dao.article("pompe"));
}
}
}
|
Résultats :
| > mvn compile exec:java
--- sessions et transactions
SQL> -- début de transaction
SQL> -- transaction validée (COMMIT)
une transaction : même session = true
SQL> -- début de transaction
SQL> -- transaction validée (COMMIT)
SQL> -- début de transaction
SQL> -- transaction validée (COMMIT)
deux transactions : même session = false, première session ouverte = false
HibernateException : Could not obtain transaction-synchronized Session for current thread
--- articles en rupture
SQL> -- début de transaction
SQL> select a1_0.nom
SQL> from articles a1_0
SQL> where a1_0.stockActuel<a1_0.stockMinimum
SQL> -- transaction validée (COMMIT)
en rupture : [flèches - lot de 6]
--- vente de 2 vélos
SQL> -- début de transaction
SQL> select a1_0.id, a1_0.nom, a1_0.prix, a1_0.stockActuel, a1_0.stockMinimum
SQL> from articles a1_0
SQL> where a1_0.nom=?
SQL> -- paramètre 1 = vélo
SQL> update articles
SQL> set nom=?, prix=?, stockActuel=?, stockMinimum=?
SQL> where id=?
SQL> -- paramètre 1 = vélo
SQL> -- paramètre 2 = 500.00
SQL> -- paramètre 3 = 8
SQL> -- paramètre 4 = 5
SQL> -- paramètre 5 = 1
SQL> -- transaction validée (COMMIT)
--- vente de 50 pompes
SQL> -- début de transaction
SQL> select a1_0.id, a1_0.nom, a1_0.prix, a1_0.stockActuel, a1_0.stockMinimum
SQL> from articles a1_0
SQL> where a1_0.nom=?
SQL> -- paramètre 1 = pompe
SQL> -- transaction annulée (ROLLBACK)
IllegalStateException : stock insuffisant pour l'article [pompe]
--- état de la base
SQL> -- début de transaction
SQL> select a1_0.id, a1_0.nom, a1_0.prix, a1_0.stockActuel, a1_0.stockMinimum
SQL> from articles a1_0
SQL> where a1_0.nom=?
SQL> -- paramètre 1 = vélo
SQL> -- transaction validée (COMMIT)
[1, vélo, 500,00, 8, 5]
SQL> -- début de transaction
SQL> select a1_0.id, a1_0.nom, a1_0.prix, a1_0.stockActuel, a1_0.stockMinimum
SQL> from articles a1_0
SQL> where a1_0.nom=?
SQL> -- paramètre 1 = pompe
SQL> -- transaction validée (COMMIT)
[2, pompe, 10,00, 10, 2]
|
Commentaires :
- ligne 13 : le conteneur Spring, configuré par la classe [Config]. Il est fermé à la fin du bloc [try], ce qui ferme la [SessionFactory] et le pool de connexions ;
- ligne 15 : la [SessionFactory] construite par Spring ; on l'utilise pour recréer la table des articles ;
- ligne 21 : une transaction (lignes 3-4) : dans la même transaction, la session courante est toujours la même (ligne 5) ;
- lignes 23-26 : deux appels, deux transactions (lignes 6-9), deux sessions différentes ; la première est fermée, puisque sa transaction est terminée (ligne 10) ;
- lignes 28-32 : hors d'une transaction, il n'y a pas de session courante : [getCurrentSession] lance une [HibernateException] (ligne 11). C'est l'erreur classique d'une méthode qui a oublié l'annotation [@Transactional] ;
- ligne 36 : une transaction en lecture seule (lignes 13-17) ;
- ligne 38 : la vente de deux vélos. La transaction est validée, et la synchronisation de la session envoie l'[UPDATE] (lignes 20-33) ;
- lignes 40-44 : la vente de cinquante pompes lance une exception : Spring annule la transaction (ligne 40), et l'[UPDATE] n'est jamais envoyé, la session n'ayant pas été synchronisée ;
- lignes 46-47 : l'état de la base : le stock du vélo est passé à 8, celui de la pompe est resté à 10 (lignes 49 et 56).
Dans une application Spring Boot, tout cela est automatique : Spring Boot crée le pool de connexions, la fabrique (sous sa forme Jakarta Persistence, un [EntityManagerFactory]) et le gestionnaire de transactions à partir du fichier [application.properties]. On y utilise souvent, au-dessus d'Hibernate, Spring Data JPA, qui génère la couche [dao] à partir d'une simple interface : [interface ArticleRepository extends JpaRepository<Article, Integer>] fournit [findAll], [findById], [save], [delete]..., et une méthode déclarée [List<Article> findByPrixLessThan(BigDecimal prix)] est traduite automatiquement en requête.
11.15. Application exemple - version 7
11.15.1. L'architecture
La version 6 de l'application de calcul de l'impôt (chapitre 10) lisait les tranches d'impôt dans une base MySQL avec JDBC. La version 7 fait de même avec Hibernate. Comme pour la version 6, seule la couche [dao] change : les couches [metier] et [ui] de la version 5 (chapitre 6) sont réutilisées telles quelles, par des dépendances Maven.

Le projet Maven [ImpotsV7], dans le dossier [11-hibernate], comprend deux modules :
| ImpotsV7/
pom.xml le projet parent
impots-dao-hibernate/ bibliothèque : la couche [dao] Hibernate
pom.xml
src/main/java/impots/dao/
Tranche.java l'entité
HibernateImpot.java l'implémentation de IImpotDao
CreationBase.java la création de la base [hbimpots]
src/main/resources/
logback.xml
impots-ui-hibernate/ exécutable : intégration des couches
pom.xml
src/main/java/impots/ui/
MainHibernate.java
impots-hibernate.properties
impots-hibernate-erreur.properties
|
Le projet parent ([exemples.impots:impots-v7:7.0]) est construit comme celui de la version 5 : il déclare les deux modules et fixe les versions des bibliothèques, dont celles des modules de la version 5 ([impots-dao], [impots-metier], [impots-ui], en version 5.0), installés dans le dépôt Maven local par la commande [mvn install] tapée dans le dossier [06-architectures/ImpotsV5].
11.15.2. La couche [dao]
Le module [impots-dao-hibernate] dépend de la couche [dao] de la version 5, qui contient les entités de l'application ([TrancheImpot], [ImpotException]) et l'interface [IImpotDao] :
| <dependencies>
<!-- la couche [dao] de la version 5 : entités, interface IImpotDao, HardwiredImpot -->
<dependency>
<groupId>exemples.impots</groupId>
<artifactId>impots-dao</artifactId>
</dependency>
<!-- Hibernate, le pilote JDBC de MySQL et Logback -->
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<!-- mvn exec:java crée la base [hbimpots] -->
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<configuration>
<mainClass>impots.dao.CreationBase</mainClass>
</configuration>
</plugin>
</plugins>
</build>
|
- lignes 3-6 : la couche [dao] de la version 5 ;
- lignes 8-19 : Hibernate, le pilote JDBC de MySQL et Logback ;
- lignes 25-31 : la classe exécutée par [mvn exec:java] dans le dossier du module : elle crée la base, comme nous le verrons.
Le record [TrancheImpot] de la version 5 ne peut pas être une entité Hibernate : c'est un record, non modifiable, et il n'a pas d'identifiant. On ne le modifie pas, car il fait partie du contrat entre les couches, partagé par toutes les versions. On crée une entité, propre à la couche [dao], dont le rôle est de représenter une ligne de la table :
| // une tranche d'impôt telle qu'elle est enregistrée dans la base : une entité Hibernate
@Entity
@Table(name = "tranches")
public class Tranche {
// la clé primaire, qui n'existe pas dans le record TrancheImpot
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "limite", nullable = false, precision = 10, scale = 2)
private BigDecimal limite;
@Column(name = "coeffr", nullable = false, precision = 6, scale = 4)
private BigDecimal coeffR;
@Column(name = "coeffn", nullable = false, precision = 10, scale = 2)
private BigDecimal coeffN;
protected Tranche() {
}
public Tranche(BigDecimal limite, BigDecimal coeffR, BigDecimal coeffN) {
this.limite = limite;
this.coeffR = coeffR;
this.coeffN = coeffN;
}
}
|
- ligne 3 : la table [tranches] ;
- lignes 7-9 : la clé primaire, qui sert aussi à ordonner les tranches ;
- lignes 11-18 : les trois colonnes, avec des types [decimal] adaptés : la limite et le coefficient [coeffN] sont des montants à deux décimales, [coeffR] un taux à quatre décimales. Les noms des colonnes sont en minuscules ;
- lignes 23-27 : un constructeur, qui servira à remplir la table. L'entité n'a pas d'accesseurs : le reste de l'application ne la voit jamais.
La classe [HibernateImpot] implémente l'interface [IImpotDao] :
| package impots.dao;
import impots.entites.ImpotException;
import impots.entites.TrancheImpot;
import jakarta.persistence.PersistenceException;
import java.util.List;
import org.hibernate.SessionFactory;
import org.hibernate.jpa.HibernatePersistenceConfiguration;
// une couche [dao] qui lit les tranches d'impôt dans une base de données avec Hibernate
public class HibernateImpot implements IImpotDao {
// codes d'erreur propres à cette implémentation
public static final int ERREUR_ACCES = 1;
public static final int TABLE_VIDE = 2;
// tranches d'impôt
private final List<TrancheImpot> tranchesImpot;
// constructeur : la base de données est désignée par son URL JDBC
public HibernateImpot(String url, String utilisateur, String motDePasse) {
// une SessionFactory de courte durée, le temps de lire la table
try (SessionFactory sessionFactory = new HibernatePersistenceConfiguration("impots")
.managedClass(Tranche.class)
.jdbcUrl(url)
.jdbcCredentials(utilisateur, motDePasse)
.createEntityManagerFactory()) {
// les lignes de la table sont projetées vers le record TrancheImpot de l'application
String hql = "select limite, coeffR, coeffN from Tranche order by id";
tranchesImpot = sessionFactory.fromSession(session -> session
.createSelectionQuery(hql, TrancheImpot.class)
.getResultList());
} catch (PersistenceException e) {
// erreur d'Hibernate ou du SGBD : on l'encapsule dans une ImpotException
throw new ImpotException("Erreur lors de la lecture des tranches d'impôt dans la base de données",
e, ERREUR_ACCES);
}
// la couche [metier] a besoin d'au moins une tranche
if (tranchesImpot.isEmpty()) {
throw new ImpotException("La table des tranches d'impôt est vide", TABLE_VIDE);
}
}
@Override
public List<TrancheImpot> getTranchesImpot() {
return tranchesImpot;
}
}
|
- lignes 14-15 : les codes d'erreur de cette implémentation, comme pour [FileImpot] et [JsonImpot] ;
- ligne 21 : la couche [dao] reçoit l'URL de la base, l'utilisateur et son mot de passe. Elle lit les tranches une fois pour toutes, dans son constructeur : elle sera un singleton ;
- lignes 23-27 : une [SessionFactory] de courte durée, le temps de lire la table, fermée aussitôt. Comme la couche n'accède à la base qu'une fois, il n'y a pas lieu de la garder ; une couche [dao] qui accéderait à la base à chaque appel recevrait au contraire une [SessionFactory] créée une fois pour toutes, comme dans l'exemple précédent ;
- lignes 29-32 : la lecture de la table, dans l'ordre des clés primaires. La requête est une projection vers le record [TrancheImpot] : Hibernate ne demande au SGBD que les trois colonnes utiles et crée directement les objets de l'application, sans passer par des entités [Tranche] ;
- lignes 33-37 : une erreur d'Hibernate ou du SGBD (base inaccessible, table absente...) se manifeste par une [PersistenceException] (la classe de base des exceptions de Jakarta Persistence, dont dérivent celles d'Hibernate). Comme dans les autres implémentations, elle est encapsulée dans une [ImpotException]. L'exception peut naître dès la création de la [SessionFactory] (ligne 27), qui se connecte au serveur : la clause [catch] d'une instruction [try] avec ressources intercepte aussi les exceptions lancées lors de la création des ressources ;
- lignes 39-41 : une table vide est aussi une erreur : la couche [metier] a besoin d'au moins une tranche.
11.15.3. La base de données
La base [hbimpots] est créée par la classe [CreationBase]. Pour ne pas réécrire les tranches du barème, elle reprend celles de la classe [HardwiredImpot] de la version 5 :
| package impots.dao;
import impots.entites.TrancheImpot;
import org.hibernate.SessionFactory;
import org.hibernate.jpa.HibernatePersistenceConfiguration;
import org.hibernate.tool.schema.Action;
// création de la base [hbimpots] : la table [tranches], remplie avec les tranches de la classe HardwiredImpot
public class CreationBase {
public static void main(String[] args) {
try (SessionFactory sessionFactory = new HibernatePersistenceConfiguration("impots")
.managedClass(Tranche.class)
.jdbcUrl("jdbc:mysql://localhost:3306/hbimpots?createDatabaseIfNotExist=true")
.jdbcCredentials("root", "")
// affichage des ordres SQL, sans mise en forme
.showSql(true, false, false)
// la table est supprimée puis recréée à la création de la SessionFactory
.schemaToolingAction(Action.CREATE)
.createEntityManagerFactory()) {
// les tranches du barème, celles de la version 5, numérotées à partir de 1 par le SGBD
sessionFactory.inTransaction(session -> {
for (TrancheImpot tranche : new HardwiredImpot().getTranchesImpot()) {
session.persist(new Tranche(tranche.limite(), tranche.coeffR(), tranche.coeffN()));
}
});
}
}
}
|
- ligne 14 : la base [hbimpots], créée par MySQL si elle n'existe pas ;
- ligne 17 : affichage des ordres SQL, sans mise en forme ;
- ligne 19 : la table est supprimée puis recréée lors de la création de la [SessionFactory] ;
- lignes 22-26 : une entité [Tranche] par tranche de la classe [HardwiredImpot], persistée dans une transaction.
Le module contient aussi un fichier [logback.xml], utilisé par [CreationBase] et par l'application, qui ne laisse passer que les erreurs :
| <?xml version="1.0" encoding="UTF-8"?>
<!-- configuration de Logback : seules les erreurs d'Hibernate sont affichées -->
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%-5level %logger{0} - %msg%n</pattern>
</encoder>
</appender>
<!-- les erreurs du SGBD sont transmises à l'application par des exceptions : on ne les journalise pas -->
<logger name="org.hibernate.orm.jdbc.error" level="OFF"/>
<root level="ERROR">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
|
- ligne 10 : les erreurs du SGBD, qu'Hibernate journalise avant de lancer une exception, ne sont pas affichées : l'application les signale elle-même ;
- ligne 11 : les autres journaux n'écrivent que les erreurs.
Exécution :
| > mvn compile exec:java
Hibernate: drop table if exists tranches
Hibernate: create table tranches (coeffn decimal(10,2) not null, coeffr decimal(6,4) not null, id integer not null auto_increment, limite decimal(10,2) not null, primary key (id)) engine=InnoDB
Hibernate: insert into tranches (coeffn,coeffr,limite) values (?,?,?)
Hibernate: insert into tranches (coeffn,coeffr,limite) values (?,?,?)
Hibernate: insert into tranches (coeffn,coeffr,limite) values (?,?,?)
Hibernate: insert into tranches (coeffn,coeffr,limite) values (?,?,?)
Hibernate: insert into tranches (coeffn,coeffr,limite) values (?,?,?)
Hibernate: insert into tranches (coeffn,coeffr,limite) values (?,?,?)
Hibernate: insert into tranches (coeffn,coeffr,limite) values (?,?,?)
|
On vérifie le contenu de la table :
| > mysql -uroot -t hbimpots -e "SELECT id, limite, coeffr, coeffn FROM tranches"
+----+----------+--------+---------+
| id | limite | coeffr | coeffn |
+----+----------+--------+---------+
| 1 | 4962.00 | 0.0000 | 0.00 |
| 2 | 8382.00 | 0.0680 | 291.09 |
| 3 | 14753.00 | 0.1910 | 1322.92 |
| 4 | 23888.00 | 0.2830 | 2668.39 |
| 5 | 38868.00 | 0.3740 | 4846.98 |
| 6 | 47932.00 | 0.4260 | 6883.66 |
| 7 | 0.00 | 0.4810 | 9505.54 |
+----+----------+--------+---------+
|
On retrouve les tranches du barème, la dernière ayant une limite nulle (elle n'est pas utilisée par la couche [metier], qui prend la dernière tranche quand aucune autre ne convient).
Pour créer la base chez vous :
- démarrer MySQL (dans Laragon, cf. l'annexe ; utilisateur [root] sans mot de passe) ;
- installer, si ce n'est pas déjà fait, les modules de la version 5 dans le dépôt Maven local : [mvn install] dans le dossier [06-architectures/ImpotsV5] ;
- construire la version 7 : [mvn install] dans le dossier [11-hibernate/ImpotsV7] ;
- se placer dans le dossier [11-hibernate/ImpotsV7/impots-dao-hibernate] et taper la commande [mvn exec:java].
La base [hbimpots] est alors créée, avec sa table [tranches] remplie. Si la commande échoue, vérifiez que MySQL est démarré et que l'URL de la classe [CreationBase] correspond à votre serveur (port 3306, utilisateur [root], mot de passe vide). On peut aussi, si l'on préfère, créer la table et ses lignes avec un script SQL, comme au chapitre 10.
11.15.4. L'intégration des couches
Le module [impots-ui-hibernate] réunit les couches :
| <dependencies>
<!-- les couches [metier] et [ui] de la version 5, inchangées -->
<dependency>
<groupId>exemples.impots</groupId>
<artifactId>impots-metier</artifactId>
</dependency>
<dependency>
<groupId>exemples.impots</groupId>
<artifactId>impots-ui</artifactId>
</dependency>
<!-- la nouvelle couche [dao] -->
<dependency>
<groupId>exemples.impots</groupId>
<artifactId>impots-dao-hibernate</artifactId>
</dependency>
<!-- le conteneur d'injection de dépendances de Spring -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<!-- mvn exec:java exécute la classe principale -->
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<configuration>
<mainClass>impots.ui.MainHibernate</mainClass>
</configuration>
</plugin>
</plugins>
</build>
|
- lignes 3-10 : les couches [metier] et [ui] de la version 5. Le module [impots-ui] contient le programme principal de la version 5 ; on l'utilise ici comme une bibliothèque, pour sa classe [Dialogue] ;
- lignes 12-15 : la nouvelle couche [dao], qui amène Hibernate, le pilote JDBC et Logback ;
- lignes 17-20 : le conteneur Spring ;
- ligne 30 : la classe principale.
Le fichier de configuration [impots-hibernate.properties], placé dans le dossier du module, désigne la base :
| # configuration de l'application [Impots], version 7 : la base de données des tranches d'impôt
jdbc.url=jdbc:mysql://localhost:3306/hbimpots
jdbc.utilisateur=root
jdbc.motdepasse=
|
Le fichier [impots-hibernate-erreur.properties] désigne une base [hbimpotsabsente], qui n'existe pas :
| # une base de données qui n'existe pas
jdbc.url=jdbc:mysql://localhost:3306/hbimpotsabsente
jdbc.utilisateur=root
jdbc.motdepasse=
|
Le programme principal est celui de la version 5, dont seule la composition de la couche [dao] change :
| package impots.ui;
import impots.dao.HibernateImpot;
import impots.dao.IImpotDao;
import impots.entites.ImpotException;
import impots.metier.IImpotMetier;
import impots.metier.ImpotMetier;
import java.io.IOException;
import java.io.Reader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
import org.springframework.context.support.GenericApplicationContext;
public class MainHibernate {
public static void main(String[] args) {
// le fichier de configuration : impots-hibernate.properties par défaut, ou celui passé en argument
String fichierConfig = args.length > 0 ? args[0] : "impots-hibernate.properties";
// intégration des couches
IImpotMetier metier;
try {
// lecture de la configuration, dans le dossier courant
Properties config = new Properties();
try (Reader reader = Files.newBufferedReader(Path.of(fichierConfig))) {
config.load(reader);
}
String url = config.getProperty("jdbc.url");
if (url == null) {
throw new ImpotException("URL [jdbc.url] de la base absente du fichier " + fichierConfig);
}
String utilisateur = config.getProperty("jdbc.utilisateur", "root");
String motDePasse = config.getProperty("jdbc.motdepasse", "");
// le conteneur Spring : on y enregistre les couches
GenericApplicationContext contexte = new GenericApplicationContext();
// la couche [dao] : HibernateImpot, avec la base de la configuration
contexte.registerBean(IImpotDao.class, () -> new HibernateImpot(url, utilisateur, motDePasse));
// la couche [metier] de la version 5 : le conteneur lui passera la couche [dao]
contexte.registerBean(ImpotMetier.class);
// démarrage du conteneur : il crée la couche [dao], puis la couche [metier]
contexte.refresh();
metier = contexte.getBean(IImpotMetier.class);
} catch (IOException | RuntimeException e) {
// affichage de l'erreur et arrêt du programme
Dialogue.afficherErreur(e);
System.exit(1);
return;
}
// le dialogue avec l'utilisateur, celui de la version 5
Dialogue.run(metier);
}
}
|
- ligne 15 : la classe ne s'appelle pas [Main] : le module [impots-ui] de la version 5, dont nous dépendons, contient déjà une classe [impots.ui.Main] ;
- lignes 29-34 : l'URL de la base, obligatoire, l'utilisateur et le mot de passe, lus dans la configuration ;
- ligne 39 : la couche [dao] est une [HibernateImpot], créée par une lambda qui lui passe les paramètres de la base ;
- ligne 41 : la couche [metier] de la version 5 : le conteneur passera la couche [dao] à son constructeur ;
- ligne 54 : le dialogue de la version 5.
11.15.5. Exécutions
| > mvn compile exec:java
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : o 2 60000
Impôt=4282 euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : o 3 60000
Impôt=2959 euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter : n 0 60000
Impôt=11519 euros
Paramètres du calcul de l'impôt au format : Marié (o/n) NbEnfants Salaire ou rien pour arrêter :
|
On retrouve les valeurs de contrôle des versions précédentes : 4282 euros pour un couple marié avec deux enfants et 60 000 euros de salaire, 2959 euros avec trois enfants, 11519 euros pour une personne seule sans enfant.
Avec le fichier de configuration [impots-hibernate-erreur.properties], qui désigne une base inexistante :
| > mvn compile exec:java -Dexec.args="impots-hibernate-erreur.properties"
Chaîne des exceptions :
----------------------------------------
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'impots.dao.IImpotDao': Instantiation of supplied bean failed
----------------------------------------
impots.entites.ImpotException: Erreur lors de la lecture des tranches d'impôt dans la base de données
----------------------------------------
org.hibernate.service.spi.ServiceException: Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment] due to: Error calling Driver.connect() [Unknown database 'hbimpotsabsente']
----------------------------------------
org.hibernate.exception.SQLGrammarException: Error calling Driver.connect() [Unknown database 'hbimpotsabsente']
----------------------------------------
java.sql.SQLSyntaxErrorException: Unknown database 'hbimpotsabsente'
----------------------------------------
|
- ligne 12 : l'exception d'origine, lancée par le pilote JDBC ;
- lignes 8 et 10 : les exceptions d'Hibernate qui l'encapsulent : l'erreur s'est produite lors de la création de la [SessionFactory], quand Hibernate s'est connecté au serveur pour connaître sa version ;
- ligne 6 : la couche [dao] a encapsulé l'erreur dans une [ImpotException] ;
- ligne 4 : le conteneur Spring, qui créait la couche [dao], a lui-même encapsulé l'exception dans une [BeanCreationException], affichée par le programme principal.
Changer de SGBD revient à changer l'URL de la base et la dépendance du pilote JDBC : Hibernate choisit seul le dialecte SQL du SGBD rencontré, et le code de la classe [HibernateImpot], écrit en HQL, ne change pas. Seule la création de la base, qui dépend des types de colonnes, est à refaire, ce que la classe [CreationBase] fait sans autre modification que celle de son URL.
11.16. Conclusion : JDBC, JdbcClient, jOOQ ou Hibernate ?
Nous disposons maintenant de deux façons d'accéder à une base de données : JDBC (chapitre 10) et Hibernate (ce chapitre). Il en existe d'autres, intermédiaires, très utilisées :
- JdbcClient, de Spring Framework (bibliothèque [spring-jdbc]), ajoute à JDBC une API fluide qui exécute une requête SQL écrite à la main et transforme automatiquement les lignes du résultat en objets : [jdbcClient.sql("select * from articles where prix > :prix").param("prix", 200).query(Article.class).list()]. On écrit le SQL, Spring se charge des paramètres et de la recopie des colonnes dans les objets (records compris). C'est un « micro-ORM » ;
- jOOQ génère, à partir du schéma de la base, des classes qui permettent d'écrire en Java un SQL vérifié par le compilateur : [dsl.selectFrom(ARTICLES).where(ARTICLES.PRIX.gt(prix)).fetch()]. On garde le contrôle total du SQL, avec la sécurité du typage.
| | Points forts | Points faibles |
JDBC | contrôle total du SQL ; performances ; aucune dépendance | beaucoup de code répétitif (paramètres, lecture des colonnes) ; SQL dans des chaînes, non vérifié par le compilateur |
JdbcClient | SQL écrit à la main, sans code de recopie ; performances proches de JDBC | pas de suivi des modifications ni de génération des [INSERT]/[UPDATE] ; SQL non vérifié |
jOOQ | SQL vérifié par le compilateur ; toutes les possibilités du SGBD | génération de code à chaque évolution du schéma ; pas de suivi des modifications ; licence payante pour certains SGBD commerciaux |
Hibernate | modèle objet ; suivi des modifications ; relations ; HQL et API Criteria ; norme Jakarta Persistence ; indépendance vis-à-vis du SGBD | une couche de plus, à comprendre (sessions, états, chargement paresseux) ; SQL généré à surveiller ; moins adapté aux traitements de masse complexes |
On peut proposer les repères suivants :
- Hibernate (souvent au travers de Spring Data JPA) convient à la plupart des applications de gestion : un modèle d'entités liées entre elles, des opérations de lecture et de mise à jour d'entités, un schéma qui évolue avec l'application. C'est le choix par défaut des applications Spring Boot ;
- JDBC, JdbcClient ou jOOQ conviennent quand on veut maîtriser exactement le SQL exécuté : requêtes de statistiques complexes, procédures stockées, traitements de très gros volumes, base existante au schéma peu adapté à un modèle objet ;
- les approches se combinent : une application peut utiliser Hibernate pour l'essentiel et du SQL écrit à la main, en requêtes natives, avec [JdbcClient] ou avec JDBC ([doWork]), pour quelques requêtes critiques.
Dans tous les cas, un ORM ne dispense pas de connaître SQL et le fonctionnement du SGBD : le journal des ordres SQL exécutés, que nous avons affiché tout au long de ce chapitre, est le premier outil pour vérifier ce que fait réellement Hibernate.
Pour aller plus loin, la documentation d'Hibernate est complète, et son guide d'introduction en est la meilleure entrée : Hibernate ORM - documentation. La spécification de Jakarta Persistence est publiée par la fondation Eclipse : Jakarta Persistence.