Base de données (JPA)¶
Ce chapitre branche todo-api sur une vraie base : une entité, un repository sans une ligne d'implémentation, des transactions et des migrations versionnées.
Dans ce chapitre
- Qui fait quoi : JDBC, JPA, Hibernate, Spring Data JPA (et leurs équivalents Rust et Python)
- L'entité
Todoet le repositoryTodoRepository, avec ses requêtes dérivées du nom des méthodes - Le contexte de persistance : pourquoi
updaten'appelle jamaissave @Transactional: commit, rollback, lecture seule- Flyway,
ddl-auto=validate, H2 en dev et PostgreSQL en prod - Les pièges : chargement paresseux, N+1,
equals/hashCode, et comment voir le SQL
Qui fait quoi¶
| Couche | Rôle | Chez toi |
|---|---|---|
| Spring Data JPA | génère les repositories à partir d'interfaces | rien d'équivalent |
JPA 3.2 (jakarta.persistence) |
spécification : annotations et interfaces, sans code | un trait sans implémentation |
| Hibernate 7.4 | implémentation de JPA : l'ORM qui génère le SQL | SQLAlchemy ORM, Diesel |
JDBC (java.sql) + HikariCP |
API bas niveau du JDK (un pilote par base) et pool de connexions | DB-API de Python, PgPool de sqlx |
Les vieux tutoriels importent javax.persistence : c'est l'ancien nom de JPA (Spring Boot 2). Voici la même opération (lister les tâches ouvertes, puis terminer la première) avec tes outils :
#[derive(sqlx::FromRow, Debug)]
struct Todo { id: i64, title: String, done: bool }
// SQL écrit à la main ; seul le mapping ligne -> struct est dérivé
let open: Vec<Todo> = sqlx::query_as(
"SELECT id, title, done FROM todo WHERE done = $1 ORDER BY created_at DESC")
.bind(false).fetch_all(&pool).await?;
// Aucun suivi des modifications : chaque UPDATE est explicite
let mut tx = pool.begin().await?;
sqlx::query("UPDATE todo SET done = TRUE WHERE id = $1")
.bind(open[0].id).execute(&mut *tx).await?;
tx.commit().await?;
sqlx est explicite de bout en bout. SQLAlchemy est le plus proche de JPA : son unit of work repère l'objet modifié et écrit l'UPDATE au commit, exactement comme Hibernate (section dirty checking plus bas). Spring Data va plus loin : même le SELECT est généré à partir d'un nom de méthode.
L'entité Todo¶
Une entité est une classe dont chaque instance représente une ligne d'une table.
@Entity // (1)!
@Table(name = "todo")
public class Todo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // (2)!
private Long id; // (3)!
@Column(nullable = false, length = 200)
private String title;
@Column(nullable = false)
private boolean done;
@Column(name = "created_at", nullable = false, updatable = false) // (4)!
private Instant createdAt;
protected Todo() { // (5)!
}
public Todo(String title) {
this.title = title;
this.done = false;
this.createdAt = Instant.now().truncatedTo(ChronoUnit.MICROS);
}
// getters, setTitle, setDone ; pas de setId ni de setCreatedAt
}
- « Cette classe est mappée sur une table. » Hibernate la découvre au démarrage.
- La base attribue l'id à l'
INSERT(colonneGENERATED BY DEFAULT AS IDENTITY). Longet paslong:nullsignifie « pas encore enregistrée », là où unlongvaudrait0.updatable = false: la colonne n'apparaît jamais dans lesUPDATE. Lenameest facultatif (Spring Boot convertitcreatedAtencreated_at), mais il documente le schéma.- Exigé par JPA.
protected: Hibernate peut l'appeler, ton code non.
Pour relire une ligne, Hibernate appelle le constructeur sans argument par réflexion, puis remplit les champs (voir Annotations et réflexion). Il doit aussi pouvoir modifier ces champs, et créer des sous-classes de l'entité pour le chargement paresseux. D'où la règle : une classe ordinaire, non final, aux champs non final.
Piège : pas de record pour une entité
Un record est final, ses champs aussi, et il n'a pas de constructeur sans argument. Annoté @Entity, il passe le démarrage et même l'INSERT, puis la première lecture échoue : org.hibernate.InstantiationException: No default constructor for entity 'xyz.dunkan.todo.todo.Tag'. Les records servent aux DTO comme TodoResponse (voir API REST).
Le repository¶
public interface TodoRepository extends JpaRepository<Todo, Long> { // entité, type de l'id
List<Todo> findByDoneOrderByCreatedAtDesc(boolean done);
long countByDoneFalse();
}
Aucune classe n'implémente cette interface. Au démarrage, Spring Data la trouve (Found 1 JPA repository interface.) et fabrique un objet qui l'implémente. JpaRepository apporte déjà save, saveAll, findById, findAll (avec ou sans Sort), existsById, count, deleteById, deleteAll... findById renvoie un Optional<Todo>, l'équivalent d'Option<T> (voir Exceptions et Optional), dont le service fait une exception métier : repository.findById(id).orElseThrow(() -> new TodoNotFoundException(id)).
Sous le capot
Le bean injecté est un proxy dynamique du JDK qui implémente TodoRepository (voir Injection de dépendances). Il délègue les méthodes héritées à SimpleJpaRepository, une classe de Spring Data, et les tiennes à des requêtes préparées au démarrage.
Les requêtes dérivées du nom de méthode¶
Spring Data analyse le nom : un préfixe (find, count, exists, delete), By, des conditions sur les attributs, un tri éventuel. Le SQL réellement généré pour les deux méthodes du projet :
findByDoneOrderByCreatedAtDesc(false)
select t1_0.id,t1_0.created_at,t1_0.done,t1_0.title from todo t1_0 where t1_0.done=? order by t1_0.created_at desc
countByDoneFalse()
select count(t1_0.id) from todo t1_0 where t1_0.done=false
D'autres mots-clés : findByTitleContainingIgnoreCase(String), findByCreatedAtAfter(Instant), findByDoneAndTitle(boolean, String), findFirst10ByOrderByCreatedAtDesc(), existsByTitle(String). Une faute de frappe échappe au compilateur mais pas au démarrage : findByTitel(String) fait échouer le contexte avec No property 'titel' found for type 'Todo'; Did you mean 'title'.
@Query et JPQL¶
Quand le nom devient illisible, écris la requête en JPQL : du SQL qui parle d'entités et d'attributs Java, pas de tables ni de colonnes.
@Query("select t from Todo t where t.done = false and t.createdAt < :before order by t.createdAt")
List<Todo> findOpenCreatedBefore(Instant before); // :before = le paramètre du même nom
Elle est validée au démarrage, elle aussi (Query validation failed for 'select t from Todo t where t.titel ...'). Pour du SQL propre à PostgreSQL : @Query(value = "...", nativeQuery = true).
Le contexte de persistance et le dirty checking¶
La mise à jour partielle du service ne contient aucun appel à save :
public TodoResponse update(long id, UpdateTodoRequest request) {
Todo todo = findOrThrow(id);
if (request.title() != null) {
todo.setTitle(request.title());
}
// ... même chose pour done, avec la vérification de la limite
return TodoResponse.from(todo); // pas de save : l'UPDATE partira au commit
}
Pendant une transaction, Hibernate tient un contexte de persistance : une table « id → objet » des entités chargées, avec une copie de leur état initial. Ces entités sont gérées (managed). Au commit, il compare chacune à sa copie et envoie un UPDATE pour celles qui ont changé : c'est le dirty checking. Deux PATCH {"done": true} successifs sur la même tâche :
-- 1er PATCH : done passe de false à true
select t1_0.id,t1_0.created_at,t1_0.done,t1_0.title from todo t1_0 where t1_0.id=?
update todo set done=?,title=? where id=?
-- 2e PATCH identique : rien n'a changé, pas d'UPDATE
select t1_0.id,t1_0.created_at,t1_0.done,t1_0.title from todo t1_0 where t1_0.id=?
Le contexte garantit aussi qu'une ligne n'a qu'un seul objet par transaction. Après la transaction, les entités sont détachées : les modifier ne change plus rien en base.
Modifier beaucoup de lignes
Terminer 1 000 tâches par setDone(true) envoie 1 000 UPDATE. Une requête de masse, @Modifying @Query("update Todo t set t.done = true where t.done = false") sur une méthode du repository, n'en envoie qu'un. Elle contourne le contexte de persistance : une entité déjà chargée garderait done=false en mémoire, sauf avec @Modifying(clearAutomatically = true).
Les transactions : @Transactional¶
TodoService porte @Transactional sur la classe : chaque méthode publique s'exécute dans une transaction (create : le comptage et l'INSERT réussissent ou échouent ensemble). Les méthodes de lecture précisent @Transactional(readOnly = true), qui l'emporte sur l'annotation de la classe.
Le proxy placé devant le bean (la sous-classe TodoService$$SpringCGLIB$$0, voir Injection de dépendances) ouvre la transaction, valide (commit) si la méthode se termine normalement et annule (rollback) si elle lève une exception. Le mécanisme et le piège de l'auto-appel sont détaillés dans Annotations et réflexion.
Le rollback sert vraiment. Avec todo.max-open=1, une tâche terminée et une ouverte, ce PATCH change le titre puis veut rouvrir la tâche :
$ curl -X PATCH http://localhost:8080/api/todos/1 -H "Content-Type: application/json" -d '{"title": "Acheter du pain complet", "done": false}'
{"detail":"Too many open todos (max 1): finish or delete some first","instance":"/api/todos/1","status":409,"title":"Conflict","maxOpen":1}
$ curl http://localhost:8080/api/todos/1
{"id":1,"title":"Acheter du pain","done":true,"createdAt":"2026-10-21T16:26:02.340888Z"}
Le titre n'a pas changé. Pourtant les logs SQL montrent update todo set done=?,title=? where id=? avant select count(t1_0.id) ... : Hibernate écrit les modifications en attente avant une requête qui pourrait les voir (auto-flush). L'exception a déclenché le rollback, qui a annulé cet UPDATE.
Piège : pas de rollback sur une exception checked
Par défaut, le rollback n'a lieu que sur une exception unchecked. Vérifié avec une méthode qui fait un save puis lève une exception :
| La méthode lève... | Résultat |
|---|---|
IllegalStateException (unchecked) |
rollback |
Exception (checked) |
commit : la ligne est enregistrée |
Exception avec @Transactional(rollbackFor = Exception.class) |
rollback |
Une raison de plus pour que tes exceptions métier héritent de RuntimeException.
readOnly = true dit à Hibernate de ne pas surveiller les entités : moins de travail, et une modification faite par erreur est ignorée (aucun UPDATE, vérifié). Mets @Transactional sur les services, qui définissent une opération métier complète ; chaque méthode de repository a déjà sa propre petite transaction.
Flyway : le schéma versionné¶
Le schéma n'est pas créé par Hibernate mais par des scripts SQL appliqués par Flyway :
CREATE TABLE todo (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
title VARCHAR(200) NOT NULL,
done BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL
);
Le nom suit une convention stricte : V + version + deux soulignés + description. Au démarrage, avant Hibernate, Flyway crée sa table de suivi et applique les scripts pas encore passés. Premier démarrage sur PostgreSQL :
Creating Schema History table "public"."flyway_schema_history" ...
Migrating schema "public" to version "1 - create todo"
Successfully applied 1 migration to schema "public", now at version v1 (execution time 00:00.009s)
La table flyway_schema_history contient alors une ligne : version 1, script V1__create_todo.sql, checksum 1103056973, success true. Au démarrage suivant : Schema "public" is up to date. No migration necessary. Même principe qu'Alembic (table alembic_version) ou sqlx migrate (table _sqlx_migrations). L'avertissement No migrations found du premier lancement disparaît dès que V1 existe.
Piège : ne modifie jamais une migration déjà appliquée
VARCHAR(200) changé en VARCHAR(255) dans V1 après coup : la somme de contrôle ne correspond plus et l'appli refuse de démarrer.
Migration checksum mismatch for migration version 1
-> Applied to database : 1103056973
-> Resolved locally : -2005928203
Either revert the changes to the migration, or run repair to update the schema history.
Toute évolution passe par un nouveau script V2__...sql (exercice 1 ci-dessous, ou la priorité du chapitre API REST).
ddl-auto : valider, pas modifier¶
spring.jpa.hibernate.ddl-auto dit à Hibernate quoi faire du schéma au démarrage. Avec validate, il compare entités et tables et refuse de démarrer en cas d'écart : un champ priority ajouté à Todo sans migration donne Schema validation: missing column [priority] in table [todo].
| Valeur | Effet | Usage |
|---|---|---|
none |
rien (défaut quand Flyway est là) | Flyway seul |
validate |
vérifie, ne modifie rien | avec Flyway |
update |
ajoute tables et colonnes manquantes | jamais en prod |
create-drop |
crée tout, supprime à l'arrêt | prototype jetable |
Piège : ddl-auto=update en production
Même champ priority non nul, avec update, contre un PostgreSQL qui contient déjà une tâche : Hibernate tente alter table if exists todo add column priority varchar(10) not null ..., PostgreSQL refuse (column "priority" of relation "todo" contains null values), Hibernate se contente d'un WARN, l'appli démarre... et la première requête répond 500 (column t1_0.priority does not exist). En plus, update ne supprime ni ne renomme rien, ne migre pas les données et ne laisse aucun historique. Les migrations sont du code : versionnées, relues, testées.
H2 en dev, PostgreSQL en prod¶
En développement, spring.datasource.url=jdbc:h2:mem:todo : une base H2 en mémoire, vide à chaque démarrage, remplie par Flyway. La console H2 (http://localhost:8080/h2-console, activée par spring.h2.console.enabled=true ; JDBC URL jdbc:h2:mem:todo, utilisateur sa, mot de passe vide) permet d'explorer les tables pendant que l'appli tourne. En production, le profil prod passe à PostgreSQL et coupe la console (voir Configuration et Déployer). La même V1 passe sur les deux bases parce qu'elle est en SQL standard. Mais H2 n'est pas PostgreSQL : une requête native ou une migration propre à PostgreSQL peut passer en dev et échouer en prod. Pour tester contre un vrai PostgreSQL, voir Testcontainers dans Tests.
Les pièges¶
Voir le SQL¶
spring.jpa.show-sql=true imprime chaque requête sur la sortie standard (Hibernate: select ...). Mieux : passer par les logs, avec les valeurs des paramètres.
logging.level.org.hibernate.SQL=debug
logging.level.org.hibernate.orm.jdbc.bind=trace # -> binding parameter (3:VARCHAR) <- [Acheter du pain]
Relations et chargement paresseux¶
todo-api n'a qu'une table, mais les relations sont le cœur de JPA. Avec des commentaires sur les tâches (table comment, clé étrangère todo_id) :
// dans Comment : plusieurs commentaires -> une tâche
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "todo_id")
private Todo todo;
// dans Todo : le côté inverse (la clé étrangère est dans comment)
@OneToMany(mappedBy = "todo")
private List<Comment> comments = new ArrayList<>();
@OneToMany est paresseux (lazy) : Hibernate met une collection vide qui lancera la requête au premier accès. @ManyToOne est chargé immédiatement par défaut, d'où le FetchType.LAZY explicite, conseillé partout. Si le premier accès a lieu après la transaction :
org.hibernate.LazyInitializationException: Cannot lazily initialize collection of role 'xyz.dunkan.todo.todo.Todo.comments' with key '1' (no session)
L'entité est détachée. C'est pourquoi TodoService convertit ses entités en DTO dans la transaction, et pourquoi le projet met spring.jpa.open-in-view=false. Activé par défaut (d'où l'avertissement du premier lancement), open-in-view garde le contexte ouvert jusqu'à la fin de la requête HTTP : l'exception disparaît, mais une connexion reste prise pendant toute la requête et le SQL part de la couche web sans qu'on le voie venir.
Le problème N+1¶
Parcourir les tâches puis leurs commentaires : 1 requête pour la liste, puis 1 par tâche (ici 2 tâches).
select t1_0.id,t1_0.created_at,t1_0.done,t1_0.title from todo t1_0
select c1_0.todo_id,c1_0.id,c1_0.text from comment c1_0 where c1_0.todo_id=?
select c1_0.todo_id,c1_0.id,c1_0.text from comment c1_0 where c1_0.todo_id=?
Avec 1 000 tâches, 1 001 requêtes. La solution : demander la jointure sur la méthode du repository.
@EntityGraph(attributePaths = "comments") // ou @Query("select distinct t from Todo t left join fetch t.comments")
List<Todo> findWithCommentsByDone(boolean done);
// -> select ... from todo t1_0 left join comment c1_0 on t1_0.id=c1_0.todo_id where t1_0.done=?
equals et hashCode¶
Todo garde l'equals d'Object (même objet), ce qui suffit dans une transaction. Le réflexe « égalité sur l'id » est un piège : avec Objects.hash(id), une tâche mise dans un HashSet avant save change de hashCode quand la base lui donne un id, et set.contains(todo) renvoie false (vérifié). Si tu en as besoin :
@Override
public boolean equals(Object o) {
if (this == o) return true;
// même id = même ligne ; sans id (pas encore enregistrée), seulement le même objet
return o instanceof Todo other && id != null && id.equals(other.getId());
}
@Override
public int hashCode() {
return Todo.class.hashCode(); // constant : ne change pas quand l'id est attribué
}
Pour la même raison, pas de @Data ni d'@EqualsAndHashCode de Lombok sur une entité.
Exercices¶
1. Une échéance. Ajoute aux tâches une date d'échéance facultative (colonne due_date) et une requête qui renvoie les tâches en retard : non terminées, échéance passée, la plus ancienne d'abord. Teste-la avec @DataJpaTest, puis démarre l'appli sur une base qui contient déjà des tâches.
Solution
La migration src/main/resources/db/migration/V2__add_due_date.sql (ou V3__... si tu as fait l'exercice « priorité » du chapitre API REST) tient en une ligne : ALTER TABLE todo ADD COLUMN due_date DATE;. Les lignes existantes reçoivent NULL.
private LocalDate dueDate; // colonne due_date, DATE, nullable
// + getDueDate() et setDueDate(LocalDate)
// Tâches en retard : ouvertes, échéance passée, la plus ancienne d'abord
List<Todo> findByDoneFalseAndDueDateBeforeOrderByDueDate(LocalDate date);
@Test
void findOverdueTodos() {
LocalDate today = LocalDate.of(2026, 10, 21);
Todo late = new Todo("En retard");
late.setDueDate(today.minusDays(1));
Todo upcoming = new Todo("À venir");
upcoming.setDueDate(today.plusDays(1));
Todo lateButDone = new Todo("En retard mais terminée");
lateButDone.setDueDate(today.minusDays(2));
lateButDone.setDone(true);
repository.saveAll(List.of(late, upcoming, lateButDone, new Todo("Sans échéance")));
assertThat(repository.findByDoneFalseAndDueDateBeforeOrderByDueDate(today))
.extracting(Todo::getTitle)
.containsExactly("En retard");
}
SQL généré : ... where t1_0.done=false and t1_0.due_date<? order by t1_0.due_date. En SQL, NULL < date n'est jamais vrai : la tâche sans échéance n'apparaît pas. LocalDate (une date sans heure ni fuseau) correspond au type DATE. Sur le PostgreSQL du profil prod, en version 1 avec une tâche, Flyway affiche Migrating schema "public" to version "2 - add due date", et la tâche existante reçoit due_date = NULL. Une colonne NOT NULL aurait exigé un DEFAULT pour les lignes existantes.
2. Chercher par titre. Ajoute GET /api/todos/search?q=pain, insensible à la casse, les plus récentes d'abord. Écris la méthode du repository en requête dérivée, puis avec @Query.
Solution
List<Todo> findByTitleContainingIgnoreCaseOrderByCreatedAtDesc(String text);
@Query("""
select t from Todo t
where lower(t.title) like lower(concat('%', :text, '%'))
order by t.createdAt desc
""")
List<Todo> search(String text);
// TodoService
@Transactional(readOnly = true)
public List<TodoResponse> search(String text) {
return repository.findByTitleContainingIgnoreCaseOrderByCreatedAtDesc(text)
.stream().map(TodoResponse::from).toList();
}
// TodoController
@GetMapping("/search") // un chemin fixe l'emporte sur /{id}
public List<TodoResponse> search(@RequestParam String q) {
return service.search(q);
}
$ curl "http://localhost:8080/api/todos/search?q=pain"
[{"id":3,"title":"PAIN au chocolat","done":false,"createdAt":"2026-10-21T16:07:14.900952Z"},{"id":1,"title":"Acheter du pain","done":false,"createdAt":"2026-10-21T16:07:14.730331Z"}]
La version dérivée génère where upper(t1_0.title) like upper(?) escape '\' avec %pain%, et échappe les % et _ tapés par l'utilisateur. La version @Query ne le fait pas : q=% y renvoie toutes les tâches.
À retenir¶
- JPA est la spécification (
jakarta.persistence), Hibernate l'implémentation, Spring Data JPA génère les repositories à partir d'interfaces. - Une entité est une classe non
finalavec un constructeur sans argument protégé : jamais un record. - Les requêtes dérivées naissent du nom des méthodes,
@Query(JPQL) couvre le reste ; les erreurs apparaissent au démarrage. - Une entité chargée dans une transaction est gérée : ses modifications partent en
UPDATEau commit, sanssave. @Transactional(un proxy) valide ou annule ; rollback par défaut sur les seules exceptions unchecked. À placer sur les services.- Flyway applique chaque
V<n>__*.sqlune fois ; on ne modifie jamais un script appliqué, et Hibernate se contente devalidate. - Lis le SQL (
logging.level.org.hibernate.SQL=debug) : N+1 etLazyInitializationExceptions'y repèrent.