Aller au contenu

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é Todo et le repository TodoRepository, avec ses requêtes dérivées du nom des méthodes
  • Le contexte de persistance : pourquoi update n'appelle jamais save
  • @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?;
with Session(engine) as session, session.begin():   # BEGIN ... COMMIT
    todos = session.scalars(
        select(Todo).where(Todo.done == False).order_by(Todo.created_at.desc())
    ).all()
    todos[0].done = True                            # UPDATE envoyé au commit

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.

src/main/java/xyz/dunkan/todo/todo/Todo.java (extrait)
@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
}
  1. « Cette classe est mappée sur une table. » Hibernate la découvre au démarrage.
  2. La base attribue l'id à l'INSERT (colonne GENERATED BY DEFAULT AS IDENTITY).
  3. Long et pas long : null signifie « pas encore enregistrée », là où un long vaudrait 0.
  4. updatable = false : la colonne n'apparaît jamais dans les UPDATE. Le name est facultatif (Spring Boot convertit createdAt en created_at), mais il documente le schéma.
  5. 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

src/main/java/xyz/dunkan/todo/todo/TodoRepository.java (extrait)
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 :

TodoService.java (extrait)
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 :

src/main/resources/db/migration/V1__create_todo.sql (extrait)
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.

Todo.java et TodoRepository.java (ajouts)
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);
TodoRepositoryTest.java (ajout)
@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
TodoRepository, TodoService, TodoController (ajouts)
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 final avec 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 UPDATE au commit, sans save.
  • @Transactional (un proxy) valide ou annule ; rollback par défaut sur les seules exceptions unchecked. À placer sur les services.
  • Flyway applique chaque V<n>__*.sql une fois ; on ne modifie jamais un script appliqué, et Hibernate se contente de validate.
  • Lis le SQL (logging.level.org.hibernate.SQL=debug) : N+1 et LazyInitializationException s'y repèrent.