Aller au contenu

Formation : JPQL & Spring Data JPA avec PostgreSQL

Public cible : Développeur.euse.s Java ayant des bases en JPA/Hibernate
Prérequis : Java 17+, Spring Boot, notions SQL, mapping JPA/Hibernate


Table des matières

  1. Rappels JPA/Hibernate et mapping d’entités
  2. Introduction à JPQL
  3. Les requêtes JPQL de base
  4. Paramètres et requêtes nommées
  5. Jointures en JPQL
  6. Fonctions et agrégations
  7. Sous-requêtes et expressions avancées
  8. Introduction à Spring Data JPA
  9. Repository et méthodes dérivées
  10. @Query : JPQL et SQL natif dans Spring Data
  11. Projections et DTOs
  12. Pagination et tri
  13. Criteria API introduction
  14. Bonnes pratiques et performances
  15. TP EcoTrack

1. Rappels JPA/Hibernate et mapping d’entités

1.1 Qu’est-ce que JPA ?

JPA (Java Persistence API) est une spécification Java (aujourd’hui Jakarta EE) qui définit comment mapper des objets Java vers des tables de base de données relationnelle. C’est un standard : il ne s’exécute pas tout seul, il a besoin d’une implémentation.

Hibernate est l’implémentation la plus populaire de JPA. Quand vous utilisez Spring Boot avec spring-boot-starter-data-jpa, Hibernate est inclus automatiquement.

Analogie : JPA est comme une interface Java : elle définit le contrat. Hibernate est l’implémentation concrète de ce contrat, comme une classe qui implements cette interface.

1.2 L’architecture en couches

[ Application Java ]
        ↕
[ JPA (API standard) ]       ← vous écrivez votre code contre JPA
        ↕
[ Hibernate (implémentation) ] ← traduit vos appels JPA en SQL
        ↕
[ JDBC Driver PostgreSQL ]
        ↕
[ Base de données PostgreSQL ]

1.3 Configuration Spring Boot + PostgreSQL

Dans pom.xml :

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

Dans application.properties :

# Connexion PostgreSQL
spring.datasource.url=jdbc:postgresql://localhost:5432/ma_base
spring.datasource.username=postgres
spring.datasource.password=secret
spring.datasource.driver-class-name=org.postgresql.Driver

# Hibernate
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

ddl-auto=update est pratique en développement mais dangereux en production. En production, utilisez validate ou none et gérez les migrations avec Flyway/Liquibase.

1.4 Les annotations de mapping essentielles

L’annotation @Entity

Transforme une classe Java en entité JPA, c’est-à-dire en objet qui sera persisté en base.

@Entity
@Table(name = "produits")   // optionnel, si le nom de table diffère du nom de classe
public class Produit {
    // ...
}

Les annotations de clé primaire

@Entity
public class Produit {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    // IDENTITY : délègue la génération à PostgreSQL (SERIAL / BIGSERIAL)
    // SEQUENCE : utilise une séquence SQL (recommandé avec PostgreSQL)
    // AUTO     : Hibernate choisit automatiquement
    private Long id;

    @Column(name = "nom_produit", nullable = false, length = 100)
    private String nom;

    @Column(precision = 10, scale = 2)
    private BigDecimal prix;

    @Column(name = "date_creation")
    private LocalDate dateCreation;

    // getters et setters...
}

Exemple d’entités que nous allons utiliser tout au long du cours

Voici le modèle de données que nous allons utiliser pour tous nos exemples. Il représente une librairie en ligne :

// --- Entité Auteur ---
@Entity
@Table(name = "auteurs")
public class Auteur {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String nom;

    @Column(nullable = false)
    private String prenom;

    private String nationalite;

    @OneToMany(mappedBy = "auteur", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<Livre> livres = new ArrayList<>();

    // constructeurs, getters, setters...
}

// --- Entité Categorie ---
@Entity
@Table(name = "categories")
public class Categorie {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String libelle;

    @OneToMany(mappedBy = "categorie")
    private List<Livre> livres = new ArrayList<>();
}

// --- Entité Livre ---
@Entity
@Table(name = "livres")
public class Livre {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String titre;

    @Column(precision = 8, scale = 2)
    private BigDecimal prix;

    private Integer anneePublication;

    private Integer stock;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "auteur_id")
    private Auteur auteur;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "categorie_id")
    private Categorie categorie;

    @ManyToMany
    @JoinTable(
        name = "livre_commande",
        joinColumns = @JoinColumn(name = "livre_id"),
        inverseJoinColumns = @JoinColumn(name = "commande_id")
    )
    private List<Commande> commandes = new ArrayList<>();
}

// --- Entité Client ---
@Entity
@Table(name = "clients")
public class Client {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String nom;
    private String email;
    private String ville;

    @OneToMany(mappedBy = "client", cascade = CascadeType.ALL)
    private List<Commande> commandes = new ArrayList<>();
}

// --- Entité Commande ---
@Entity
@Table(name = "commandes")
public class Commande {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private LocalDate dateCommande;

    @Enumerated(EnumType.STRING)
    private StatutCommande statut;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "client_id")
    private Client client;

    @ManyToMany(mappedBy = "commandes")
    private List<Livre> livres = new ArrayList<>();
}

// --- Enum ---
public enum StatutCommande {
    EN_ATTENTE, VALIDEE, EXPEDIEE, LIVREE, ANNULEE
}

1.5 FetchType : LAZY vs EAGER

C’est un concept crucial pour comprendre les performances JPA.

// Avec LAZY : quand on charge un Livre, l'Auteur n'est PAS chargé
// Il ne sera chargé que quand on appellera livre.getAuteur()
@ManyToOne(fetch = FetchType.LAZY)
private Auteur auteur;

// Avec EAGER : quand on charge un Livre, l'Auteur est chargé EN MÊME TEMPS
@ManyToOne(fetch = FetchType.EAGER)
private Auteur auteur;

Bonne pratique : Préférez toujours LAZY par défaut. EAGER peut provoquer des cascades de requêtes non désirées (le fameux problème N+1 que nous verrons plus tard).


2. Introduction à JPQL

2.1 Qu’est-ce que JPQL ?

JPQL (Java Persistence Query Language) est un langage de requêtes orienté objets défini par la spécification JPA. Il ressemble à SQL, mais avec une différence fondamentale : vous travaillez avec des entités et leurs champs Java, pas avec des tables et colonnes SQL.

SQL (travaille sur les tables) JPQL (travaille sur les entités)
SELECT * FROM livres SELECT l FROM Livre l
SELECT nom FROM auteurs SELECT a.nom FROM Auteur a
WHERE auteur_id = 5 WHERE l.auteur.id = 5
JOIN auteurs ON livres.auteur_id = auteurs.id JOIN l.auteur a

Point clé : En JPQL, vous écrivez Livre (le nom de la classe Java) et non livres (le nom de la table SQL). Les noms sont sensibles à la casse pour les entités et leurs champs.

2.2 Pourquoi JPQL plutôt que SQL natif ?

  1. Indépendance de la base de données : JPQL est traduit en SQL par Hibernate selon le dialecte configuré. Le même code JPQL fonctionne sur PostgreSQL, MySQL, Oracle…
  2. Typage objet : On navigue dans le graphe d’objets (l.auteur.nom au lieu de jointures complexes).
  3. Sécurité : Les paramètres nommés préviennent nativement les injections SQL.
  4. Lisibilité : Pour un développeur Java, raisonner en termes d’objets est plus naturel.

2.3 Comment exécuter du JPQL ?

On utilise l’EntityManager, qui est le cœur de JPA : c’est lui qui gère le cycle de vie des entités et exécute les requêtes.

@Repository
public class LivreRepository {

    @PersistenceContext
    private EntityManager em;  // Injecté par Spring/JPA

    public List<Livre> findAll() {
        // createQuery() prend la requête JPQL et le type de retour
        TypedQuery<Livre> query = em.createQuery(
            "SELECT l FROM Livre l", Livre.class
        );
        return query.getResultList();
    }
}

Note : Avec Spring Data JPA (que nous verrons au chapitre 8), on n’a généralement pas besoin d’utiliser directement l’EntityManager. Mais le comprendre est essentiel pour maîtriser ce qui se passe en coulisses.


3. Les requêtes JPQL de base

3.1 La clause SELECT

Sélectionner toutes les entités

// Récupérer tous les livres
TypedQuery<Livre> query = em.createQuery(
    "SELECT l FROM Livre l", 
    Livre.class
);
List<Livre> livres = query.getResultList();

Le mot l après Livre est un alias (comme en SQL). C’est une variable qui représente chaque instance de Livre. On peut choisir n’importe quel nom, mais la convention est d’utiliser l’initiale de l’entité.

Sélectionner un champ spécifique

// Récupérer uniquement les titres (retourne une List<String>)
TypedQuery<String> query = em.createQuery(
    "SELECT l.titre FROM Livre l", 
    String.class
);
List<String> titres = query.getResultList();

Sélectionner plusieurs champs (Object[])

// Récupérer titre et prix (retourne une List<Object[]>)
TypedQuery<Object[]> query = em.createQuery(
    "SELECT l.titre, l.prix FROM Livre l", 
    Object[].class
);
List<Object[]> resultats = query.getResultList();

for (Object[] row : resultats) {
    String titre = (String) row[0];
    BigDecimal prix = (BigDecimal) row[1];
    System.out.println(titre + " - " + prix + " €");
}

La manipulation de Object[] est peu pratique et source d’erreurs. Nous verrons comment utiliser des DTOs au chapitre 11 pour une approche plus propre.

3.2 La clause WHERE

Conditions simples avec opérateurs de comparaison

// Livres coûtant moins de 20 €
em.createQuery("SELECT l FROM Livre l WHERE l.prix < 20", Livre.class)
  .getResultList();

// Livres d'une année précise
em.createQuery("SELECT l FROM Livre l WHERE l.anneePublication = 2023", Livre.class)
  .getResultList();

// Livres dont le titre est exactement "Dune"
em.createQuery("SELECT l FROM Livre l WHERE l.titre = 'Dune'", Livre.class)
  .getResultList();

Attention : Dans ces exemples, les valeurs sont en dur dans la requête. C’est une mauvaise pratique (risque d’injection, pas réutilisable). Nous allons voir les paramètres juste après.

Opérateur LIKE pour les recherches textuelles

LIKE fonctionne comme en SQL :

// Livres dont le titre commence par "Le"
em.createQuery("SELECT l FROM Livre l WHERE l.titre LIKE 'Le%'", Livre.class)
  .getResultList();

// Livres dont le titre contient "Java"
em.createQuery("SELECT l FROM Livre l WHERE l.titre LIKE '%Java%'", Livre.class)
  .getResultList();

Opérateur BETWEEN

// Livres publiés entre 2000 et 2010
em.createQuery(
    "SELECT l FROM Livre l WHERE l.anneePublication BETWEEN 2000 AND 2010", 
    Livre.class
).getResultList();

Opérateur IN

// Livres publiés en 2020, 2021 ou 2022
em.createQuery(
    "SELECT l FROM Livre l WHERE l.anneePublication IN (2020, 2021, 2022)", 
    Livre.class
).getResultList();

Opérateur IS NULL / IS NOT NULL

// Livres sans catégorie assignée
em.createQuery(
    "SELECT l FROM Livre l WHERE l.categorie IS NULL", 
    Livre.class
).getResultList();

// Livres avec catégorie assignée
em.createQuery(
    "SELECT l FROM Livre l WHERE l.categorie IS NOT NULL", 
    Livre.class
).getResultList();

Combinaison avec AND, OR, NOT

// Livres de science-fiction coûtant moins de 15 €
em.createQuery(
    "SELECT l FROM Livre l " +
    "WHERE l.categorie.libelle = 'Science-Fiction' " +
    "AND l.prix < 15", 
    Livre.class
).getResultList();

// Livres très chers OU épuisés
em.createQuery(
    "SELECT l FROM Livre l " +
    "WHERE l.prix > 50 OR l.stock = 0", 
    Livre.class
).getResultList();

3.3 ORDER BY

// Livres triés par prix croissant
em.createQuery(
    "SELECT l FROM Livre l ORDER BY l.prix ASC", 
    Livre.class
).getResultList();

// Livres triés par année décroissante, puis par titre croissant
em.createQuery(
    "SELECT l FROM Livre l ORDER BY l.anneePublication DESC, l.titre ASC", 
    Livre.class
).getResultList();

3.4 DISTINCT

DISTINCT élimine les doublons dans les résultats.

// Toutes les années de publication distinctes
em.createQuery(
    "SELECT DISTINCT l.anneePublication FROM Livre l ORDER BY l.anneePublication",
    Integer.class
).getResultList();

4. Paramètres et requêtes nommées

4.1 Pourquoi des paramètres ?

Écrire des valeurs directement dans les requêtes est une très mauvaise pratique :

Toujours utiliser des paramètres pour injecter les valeurs dynamiques.

4.2 Paramètres nommés (recommandés)

Les paramètres nommés utilisent la syntaxe :nomDuParametre.

// Recherche par titre exact
public List<Livre> findByTitre(String titre) {
    return em.createQuery(
        "SELECT l FROM Livre l WHERE l.titre = :titre", 
        Livre.class
    )
    .setParameter("titre", titre)  // on lie le paramètre ici
    .getResultList();
}

// Recherche dans une plage de prix
public List<Livre> findByPrixEntre(BigDecimal min, BigDecimal max) {
    return em.createQuery(
        "SELECT l FROM Livre l WHERE l.prix BETWEEN :prixMin AND :prixMax",
        Livre.class
    )
    .setParameter("prixMin", min)
    .setParameter("prixMax", max)
    .getResultList();
}

// Recherche partielle (LIKE avec paramètre)
public List<Livre> findByTitreContenant(String motCle) {
    return em.createQuery(
        "SELECT l FROM Livre l WHERE l.titre LIKE :motCle",
        Livre.class
    )
    // On concatène le % en Java, pas dans la requête JPQL
    .setParameter("motCle", "%" + motCle + "%")
    .getResultList();
}

4.3 Paramètres positionnels (déconseillés)

Ils utilisent ?1, ?2, etc. Moins lisibles, mais vous pouvez les rencontrer dans du code existant.

// Déconseillé : ordre des paramètres fragile
em.createQuery(
    "SELECT l FROM Livre l WHERE l.titre = ?1 AND l.prix < ?2",
    Livre.class
)
.setParameter(1, "Dune")
.setParameter(2, new BigDecimal("20.00"))
.getResultList();

4.4 Requête unique avec getSingleResult()

Quand on attend exactement un seul résultat :

public Livre findById(Long id) {
    try {
        return em.createQuery(
            "SELECT l FROM Livre l WHERE l.id = :id",
            Livre.class
        )
        .setParameter("id", id)
        .getSingleResult();  // lève une exception si 0 ou 2+ résultats !
    } catch (NoResultException e) {
        return null;  // ou lancer votre propre exception métier
    }
}

getSingleResult() lève NoResultException si aucun résultat, et NonUniqueResultException si plusieurs. Gérez toujours ces cas.

4.5 Requêtes nommées (@NamedQuery)

On peut pré-définir des requêtes JPQL directement sur l’entité avec @NamedQuery. Elles sont compilées au démarrage de l’application (détection d’erreurs plus tôt) et mises en cache.

@Entity
@Table(name = "livres")
@NamedQuery(
    name = "Livre.findByCategorie",
    query = "SELECT l FROM Livre l WHERE l.categorie.libelle = :libelle"
)
@NamedQuery(
    name = "Livre.findCheap",
    query = "SELECT l FROM Livre l WHERE l.prix < 15 ORDER BY l.prix ASC"
)
public class Livre {
    // ...
}

Utilisation :

public List<Livre> findByCategorie(String libelle) {
    return em.createNamedQuery("Livre.findByCategorie", Livre.class)
             .setParameter("libelle", libelle)
             .getResultList();
}

Avec Spring Data JPA, on utilise plutôt @Query directement sur les méthodes du Repository. Mais @NamedQuery reste utile pour centraliser les requêtes avec l’entité.


5. Jointures en JPQL

5.1 Comprendre les jointures en JPQL

En SQL, on écrit des jointures entre tables en précisant les colonnes de liaison :

SELECT l.titre, a.nom 
FROM livres l 
JOIN auteurs a ON l.auteur_id = a.id

En JPQL, on navigue dans le graphe d’objets via les relations déjà mappées. Pas besoin de préciser la condition de jointure : JPA la connaît déjà grâce aux annotations (@ManyToOne, @OneToMany, etc.).

SELECT l.titre, a.nom 
FROM Livre l 
JOIN l.auteur a

C’est beaucoup plus simple et naturel !

5.2 INNER JOIN (JOIN)

Un JOIN (ou INNER JOIN) ne retourne que les entités pour lesquelles la relation existe. Si un livre n’a pas d’auteur, il n’apparaîtra pas dans les résultats.

// Récupérer les livres avec leurs auteurs (seulement les livres qui ont un auteur)
List<Object[]> resultats = em.createQuery(
    "SELECT l.titre, a.nom, a.prenom " +
    "FROM Livre l " +
    "JOIN l.auteur a",
    Object[].class
).getResultList();

for (Object[] row : resultats) {
    System.out.println(row[0] + " par " + row[2] + " " + row[1]);
}
// Récupérer les auteurs ayant au moins un livre
List<Auteur> auteursAvecLivres = em.createQuery(
    "SELECT DISTINCT a FROM Auteur a JOIN a.livres l",
    Auteur.class
).getResultList();

5.3 LEFT JOIN

Un LEFT JOIN retourne toutes les entités du côté gauche, même si la relation n’existe pas (l’entité liée sera null).

// Récupérer TOUS les auteurs, avec ou sans livres
List<Object[]> resultats = em.createQuery(
    "SELECT a.nom, a.prenom, l.titre " +
    "FROM Auteur a " +
    "LEFT JOIN a.livres l",
    Object[].class
).getResultList();
// Si un auteur n'a pas de livres, l.titre sera null dans les résultats

5.4 JOIN FETCH : la solution au problème N+1

Le problème N+1

C’est le problème de performance le plus courant en JPA. Il survient quand on charge une liste d’entités avec des relations LAZY, puis qu’on accède à ces relations dans une boucle.

// Le dit PROBLÈME N+1 : 1 requête pour charger les auteurs
// + N requêtes (une par auteur) pour charger leurs livres
List<Auteur> auteurs = em.createQuery(
    "SELECT a FROM Auteur a", Auteur.class
).getResultList();

for (Auteur a : auteurs) {
    // Cette ligne déclenche une requête SQL à chaque itération !
    System.out.println(a.getNom() + " a " + a.getLivres().size() + " livres");
}

Si on a 100 auteurs, on exécute 101 requêtes SQL au lieu d’1 !

La solution : JOIN FETCH

JOIN FETCH dit à JPA de charger l’entité ET sa relation en une seule requête SQL (avec un JOIN).

// SOLUTION : 1 seule requête SQL qui charge auteurs ET livres
List<Auteur> auteurs = em.createQuery(
    "SELECT DISTINCT a FROM Auteur a JOIN FETCH a.livres",
    Auteur.class
).getResultList();

// Maintenant cette boucle n'exécute aucune requête supplémentaire
for (Auteur a : auteurs) {
    System.out.println(a.getNom() + " a " + a.getLivres().size() + " livres");
}

Le DISTINCT est important avec JOIN FETCH sur une collection @OneToMany : sans lui, Hibernate duplique les entités parentes dans les résultats (une ligne par livre pour chaque auteur).

// JOIN FETCH avec condition WHERE
List<Auteur> auteursFrancais = em.createQuery(
    "SELECT DISTINCT a FROM Auteur a " +
    "JOIN FETCH a.livres l " +
    "WHERE a.nationalite = :nationalite " +
    "AND l.anneePublication > :annee",
    Auteur.class
)
.setParameter("nationalite", "Française")
.setParameter("annee", 2000)
.getResultList();

5.5 Jointures multiples

On peut enchaîner plusieurs jointures :

// Récupérer les commandes avec les clients ET les livres associés
List<Commande> commandes = em.createQuery(
    "SELECT DISTINCT c FROM Commande c " +
    "JOIN FETCH c.client cl " +
    "JOIN FETCH c.livres l " +
    "WHERE cl.ville = :ville",
    Commande.class
)
.setParameter("ville", "Paris")
.getResultList();

5.6 Naviguer dans les relations sans JOIN explicite

En JPQL, on peut naviguer dans les relations directement dans les conditions WHERE sans écrire de JOIN explicite. JPA génère automatiquement le JOIN SQL nécessaire.

// Pas besoin de JOIN explicite pour naviguer vers une relation ManyToOne
List<Livre> livres = em.createQuery(
    "SELECT l FROM Livre l WHERE l.auteur.nationalite = 'Française'",
    Livre.class
).getResultList();

// Ici, Hibernate génère automatiquement un JOIN avec la table auteurs

C’est pratique pour les conditions simples, mais pour les performances (chargement de l’entité liée), préférez JOIN FETCH explicite.


6. Fonctions et agrégations

6.1 Fonctions d’agrégation

Les fonctions d’agrégation calculent une valeur sur un ensemble de lignes :

Fonction Description
COUNT(x) Nombre d’éléments
SUM(x) Somme des valeurs
AVG(x) Moyenne des valeurs
MIN(x) Valeur minimale
MAX(x) Valeur maximale
// Nombre total de livres
Long nbLivres = em.createQuery(
    "SELECT COUNT(l) FROM Livre l", Long.class
).getSingleResult();

// Prix moyen des livres
Double prixMoyen = em.createQuery(
    "SELECT AVG(l.prix) FROM Livre l", Double.class
).getSingleResult();

// Livre le plus cher et le moins cher
Object[] minMax = em.createQuery(
    "SELECT MIN(l.prix), MAX(l.prix) FROM Livre l", Object[].class
).getSingleResult();
System.out.println("Min: " + minMax[0] + " | Max: " + minMax[1]);

// Nombre de livres d'un auteur spécifique
Long nbLivresAuteur = em.createQuery(
    "SELECT COUNT(l) FROM Livre l WHERE l.auteur.id = :auteurId", Long.class
)
.setParameter("auteurId", 1L)
.getSingleResult();

6.2 GROUP BY

GROUP BY regroupe les résultats par une ou plusieurs colonnes, souvent utilisé avec les fonctions d’agrégation.

// Nombre de livres par catégorie
List<Object[]> parCategorie = em.createQuery(
    "SELECT l.categorie.libelle, COUNT(l) " +
    "FROM Livre l " +
    "GROUP BY l.categorie.libelle " +
    "ORDER BY COUNT(l) DESC",
    Object[].class
).getResultList();

for (Object[] row : parCategorie) {
    System.out.println("Catégorie : " + row[0] + " | Nb livres : " + row[1]);
}

// Prix moyen par auteur
List<Object[]> prixParAuteur = em.createQuery(
    "SELECT a.nom, a.prenom, AVG(l.prix), COUNT(l) " +
    "FROM Livre l JOIN l.auteur a " +
    "GROUP BY a.id, a.nom, a.prenom " +
    "ORDER BY a.nom",
    Object[].class
).getResultList();

6.3 HAVING

HAVING filtre les groupes formés par GROUP BY (comme un WHERE mais appliqué après l’agrégation).

// Catégories avec plus de 5 livres
List<Object[]> grandesCategories = em.createQuery(
    "SELECT l.categorie.libelle, COUNT(l) " +
    "FROM Livre l " +
    "GROUP BY l.categorie.libelle " +
    "HAVING COUNT(l) > 5 " +
    "ORDER BY COUNT(l) DESC",
    Object[].class
).getResultList();

// Auteurs dont la moyenne de prix de leurs livres dépasse 25 €
List<Object[]> auteursCher = em.createQuery(
    "SELECT a.nom, AVG(l.prix) " +
    "FROM Livre l JOIN l.auteur a " +
    "GROUP BY a.id, a.nom " +
    "HAVING AVG(l.prix) > 25",
    Object[].class
).getResultList();

6.4 Fonctions sur les chaînes

// Livres dont le titre en majuscules contient "JAVA"
em.createQuery(
    "SELECT l FROM Livre l WHERE UPPER(l.titre) LIKE '%JAVA%'",
    Livre.class
).getResultList();

// Auteurs avec leur nom complet en majuscules
em.createQuery(
    "SELECT CONCAT(UPPER(a.prenom), ' ', UPPER(a.nom)) FROM Auteur a",
    String.class
).getResultList();

// Longueur des titres
em.createQuery(
    "SELECT l.titre, LENGTH(l.titre) FROM Livre l ORDER BY LENGTH(l.titre) DESC",
    Object[].class
).getResultList();

// Trim : supprime les espaces en début/fin
em.createQuery(
    "SELECT l FROM Livre l WHERE TRIM(l.titre) != ''",
    Livre.class
).getResultList();

6.5 Fonctions sur les dates

// Commandes passées aujourd'hui
em.createQuery(
    "SELECT c FROM Commande c WHERE c.dateCommande = CURRENT_DATE",
    Commande.class
).getResultList();

// Commandes des 30 derniers jours
// Note: avec PostgreSQL, on peut aussi passer par SQL natif pour des fonctions avancées
em.createQuery(
    "SELECT c FROM Commande c WHERE c.dateCommande >= :dateDebut",
    Commande.class
)
.setParameter("dateDebut", LocalDate.now().minusDays(30))
.getResultList();

7. Sous-requêtes et expressions avancées

7.1 Sous-requêtes

Une sous-requête est une requête JPQL imbriquée dans une autre. Elle s’écrit entre parenthèses dans la clause WHERE ou HAVING.

// Livres dont le prix est supérieur à la moyenne de tous les livres
List<Livre> livresChers = em.createQuery(
    "SELECT l FROM Livre l " +
    "WHERE l.prix > (SELECT AVG(l2.prix) FROM Livre l2)",
    Livre.class
).getResultList();

// Auteurs qui ont au moins un livre publié après 2020
List<Auteur> auteursRecents = em.createQuery(
    "SELECT a FROM Auteur a " +
    "WHERE EXISTS (" +
    "    SELECT l FROM Livre l " +
    "    WHERE l.auteur = a AND l.anneePublication > 2020" +
    ")",
    Auteur.class
).getResultList();

// Auteurs qui n'ont AUCUN livre
List<Auteur> auteursSansLivre = em.createQuery(
    "SELECT a FROM Auteur a " +
    "WHERE NOT EXISTS (" +
    "    SELECT l FROM Livre l WHERE l.auteur = a" +
    ")",
    Auteur.class
).getResultList();

7.2 Opérateurs ALL, ANY, SOME

Ces opérateurs travaillent avec des sous-requêtes qui retournent plusieurs valeurs.

// Livres plus chers que TOUS les livres de la catégorie "Poche"
List<Livre> livres = em.createQuery(
    "SELECT l FROM Livre l " +
    "WHERE l.prix > ALL (" +
    "    SELECT l2.prix FROM Livre l2 " +
    "    WHERE l2.categorie.libelle = 'Poche'" +
    ")",
    Livre.class
).getResultList();

// Livres plus chers qu'au moins UN livre de la catégorie "Poche"
List<Livre> livres2 = em.createQuery(
    "SELECT l FROM Livre l " +
    "WHERE l.prix > ANY (" +
    "    SELECT l2.prix FROM Livre l2 " +
    "    WHERE l2.categorie.libelle = 'Poche'" +
    ")",
    Livre.class
).getResultList();

7.3 Expressions CASE

CASE permet d’ajouter de la logique conditionnelle dans les requêtes.

// Classifier les livres par gamme de prix
List<Object[]> classification = em.createQuery(
    "SELECT l.titre, l.prix, " +
    "CASE " +
    "    WHEN l.prix < 10 THEN 'Économique' " +
    "    WHEN l.prix < 25 THEN 'Standard' " +
    "    WHEN l.prix < 50 THEN 'Premium' " +
    "    ELSE 'Luxe' " +
    "END " +
    "FROM Livre l",
    Object[].class
).getResultList();

for (Object[] row : classification) {
    System.out.println(row[0] + " (" + row[1] + " €) → " + row[2]);
}

7.4 UPDATE et DELETE en JPQL

JPQL supporte aussi les mises à jour et suppressions en masse (bulk operations). Ces opérations agissent directement en base sans charger les entités en mémoire.

// Augmenter tous les prix de 5%
int nbModifies = em.createQuery(
    "UPDATE Livre l SET l.prix = l.prix * 1.05"
).executeUpdate();

// Remettre à zéro le stock des livres épuisés d'avant 2010
int nbModifies2 = em.createQuery(
    "UPDATE Livre l SET l.stock = 100 " +
    "WHERE l.stock = 0 AND l.anneePublication < 2010"
).executeUpdate();

// Supprimer les commandes annulées de plus de 2 ans
int nbSupprimes = em.createQuery(
    "DELETE FROM Commande c " +
    "WHERE c.statut = :statut AND c.dateCommande < :dateLimit"
)
.setParameter("statut", StatutCommande.ANNULEE)
.setParameter("dateLimit", LocalDate.now().minusYears(2))
.executeUpdate();

Important : Les bulk operations UPDATE et DELETE contournent le cache de premier niveau d’Hibernate. Après les avoir exécutées, rechargez les entités concernées ou appelez em.clear() pour éviter les incohérences.


8. Introduction à Spring Data JPA

Documentation sur Spring Data JPA

8.1 Qu’est-ce que Spring Data JPA ?

Nous l’avons déjà vu, Spring Data JPA est un projet du framework Spring qui simplifie considérablement l’utilisation de JPA. Il prend en charge la couche de persistance de manière déclarative, en vous libérant de l’écriture de code répétitif (le fameux CRUD).

Avant Spring Data JPA, une couche DAO complète ressemblait à ceci (comme ce que nous avons fait au début sans la gestion de l’entityManager) :

// AVANT : code verbeux et répétitif
@Repository
public class LivreDao {
    @PersistenceContext
    private EntityManager em;

    public Livre save(Livre livre) {
        em.persist(livre);
        return livre;
    }

    public Optional<Livre> findById(Long id) {
        return Optional.ofNullable(em.find(Livre.class, id));
    }

    public List<Livre> findAll() {
        return em.createQuery("SELECT l FROM Livre l", Livre.class).getResultList();
    }

    public void delete(Livre livre) {
        em.remove(em.merge(livre));
    }
    // ... etc.
}

Avec Spring Data JPA, forcément, c’est beaucoup plus simple :

// APRÈS : une interface suffit !
public interface LivreRepository extends JpaRepository<Livre, Long> {
    // Spring Data JPA génère automatiquement toutes les méthodes CRUD !
}

Et Spring Boot génère automatiquement l’implémentation de cette interface au démarrage.

8.2 La hiérarchie des Repository

Spring Data JPA définit une hiérarchie d’interfaces.

Repository<T, ID>               interface marqueur de base avec le Type de la classe et le type de l'ID
    └── CrudRepository<T, ID>   opérations CRUD de base (allez voir l'interface)
        └── PagingAndSortingRepository<T, ID>  pagination et tri en précisant la classe et le type de l'ID
            └── JpaRepository<T, ID>           fonctionnalités JPA avancées avec les combinaisons d'attributs,...

JpaRepository<T, ID> est celle que nous avons utilisée. Juste un rappel des méthodes fournies :

// injection dans un service
@Service
public class LivreService {

    private final LivreRepository livreRepository;

    // Injection par constructeur (recommandé)
    public LivreService(LivreRepository livreRepository) {
        this.livreRepository = livreRepository;
    }

    public Livre creerLivre(Livre livre) {
        return livreRepository.save(livre);  // INSERT en base
    }

    public Optional<Livre> trouverParId(Long id) {
        return livreRepository.findById(id);  // SELECT WHERE id = ?
    }

    public List<Livre> tousLesLivres() {
        return livreRepository.findAll();  // SELECT * FROM livres
    }

    public void supprimer(Long id) {
        livreRepository.deleteById(id);  // DELETE WHERE id = ?
    }
}

9. Repository et méthodes dérivées

9.1 Le principe des méthodes dérivées

C’est la fonctionnalité la plus spectaculaire de Spring Data JPA. Il suffit d’écrire la signature d’une méthode en respectant une convention de nommage, et Spring Data JPA génère automatiquement la requête JPQL correspondante au démarrage. Elle n’est toujours optimisée mais elle fonctionne !

Documentation officielle de JPA

La méthode findByTitre(String titre) sera traduite en :

SELECT l FROM Livre l WHERE l.titre = :titre

Sans écrire une seule ligne de JPQL !

9.2 Préfixes des méthodes dérivées

Préfixe Description
findBy... SELECT avec condition
getBy... Identique à findBy
readBy... Identique à findBy
countBy... Compte les occurrences
existsBy... Retourne un boolean
deleteBy... Supprime des entités

9.3 Exemples de méthodes dérivées

public interface LivreRepository extends JpaRepository<Livre, Long> {

    // --- Égalité simple ---
    // WHERE l.titre = :titre
    List<Livre> findByTitre(String titre);

    // --- LIKE ---
    // WHERE l.titre LIKE %:motCle%
    List<Livre> findByTitreContaining(String motCle);

    // WHERE l.titre LIKE :prefix%
    List<Livre> findByTitreStartingWith(String prefix);

    // WHERE l.titre LIKE %:suffix
    List<Livre> findByTitreEndingWith(String suffix);

    // --- Comparaisons numériques ---
    // WHERE l.prix < :prix
    List<Livre> findByPrixLessThan(BigDecimal prix);

    // WHERE l.prix <= :prix
    List<Livre> findByPrixLessThanEqual(BigDecimal prix);

    // WHERE l.prix > :prix
    List<Livre> findByPrixGreaterThan(BigDecimal prix);

    // WHERE l.prix BETWEEN :min AND :max
    List<Livre> findByPrixBetween(BigDecimal min, BigDecimal max);

    // --- NULL ---
    // WHERE l.categorie IS NULL
    List<Livre> findByCategorieIsNull();

    // WHERE l.categorie IS NOT NULL
    List<Livre> findByCategorieIsNotNull();

    // --- IN ---
    // WHERE l.anneePublication IN :annees
    List<Livre> findByAnneePublicationIn(List<Integer> annees);

    // --- AND / OR ---
    // WHERE l.categorie.id = :catId AND l.prix < :prix
    List<Livre> findByCategorieIdAndPrixLessThan(Long catId, BigDecimal prix);

    // WHERE a.nom = :nom OR a.prenom = :prenom
    List<Auteur> findByNomOrPrenom(String nom, String prenom);

    // --- Navigation dans les relations ---
    // WHERE l.auteur.nom = :nom (génère automatiquement le JOIN)
    List<Livre> findByAuteurNom(String nom);

    // WHERE l.auteur.nationalite = :nationalite
    List<Livre> findByAuteurNationalite(String nationalite);

    // --- Comptage ---
    // SELECT COUNT(l) WHERE l.categorie.libelle = :libelle
    Long countByCategorieLibelle(String libelle);

    // --- Existence ---
    // SELECT CASE WHEN COUNT(l) > 0 THEN TRUE ELSE FALSE END WHERE l.titre = :titre
    boolean existsByTitre(String titre);

    // --- Tri ---
    // ORDER BY l.titre ASC
    List<Livre> findByAuteurIdOrderByTitreAsc(Long auteurId);

    // ORDER BY l.prix DESC
    List<Livre> findByCategorieLibelleOrderByPrixDesc(String libelle);

    // --- Limite de résultats ---
    // SELECT TOP 1 ... (ou LIMIT 1 en PostgreSQL)
    Optional<Livre> findFirstByOrderByPrixDesc();

    // Les 5 livres les moins chers
    List<Livre> findTop5ByOrderByPrixAsc();

    // --- Distinct ---
    List<Livre> findDistinctByAuteurNationalite(String nationalite);
}

9.4 Tableau récapitulatif des mots-clés

Mot-clé Spring Data Opérateur JPQL
And AND
Or OR
Is, Equals =
Between BETWEEN
LessThan <
LessThanEqual <=
GreaterThan >
GreaterThanEqual >=
IsNull IS NULL
IsNotNull, NotNull IS NOT NULL
Like LIKE
NotLike NOT LIKE
StartingWith LIKE 'x%'
EndingWith LIKE '%x'
Containing LIKE '%x%'
OrderBy ORDER BY
Not !=
In IN
NotIn NOT IN
True = TRUE
False = FALSE
IgnoreCase UPPER(x) = UPPER(y)

10. @Query : JPQL et SQL natif dans Spring Data

10.1 Quand utiliser @Query ?

Les méthodes dérivées sont très pratiques, mais elles ont leurs limites :

Dans ces cas, on utilise @Query.

10.2 @Query avec JPQL

public interface LivreRepository extends JpaRepository<Livre, Long> {

    // Requête JPQL simple
    @Query("SELECT l FROM Livre l WHERE l.titre LIKE %:motCle%")
    List<Livre> rechercherParMotCle(@Param("motCle") String motCle);

    // JOIN FETCH pour éviter N+1
    @Query("SELECT DISTINCT l FROM Livre l JOIN FETCH l.auteur JOIN FETCH l.categorie")
    List<Livre> findAllAvecDetails();

    // Requête avec agrégation
    @Query("SELECT l.categorie.libelle, COUNT(l), AVG(l.prix) " +
           "FROM Livre l " +
           "GROUP BY l.categorie.libelle " +
           "HAVING COUNT(l) > :minLivres " +
           "ORDER BY COUNT(l) DESC")
    List<Object[]> statistiquesParCategorie(@Param("minLivres") Long minLivres);

    // Sous-requête
    @Query("SELECT l FROM Livre l WHERE l.prix > " +
           "(SELECT AVG(l2.prix) FROM Livre l2 WHERE l2.categorie = l.categorie)")
    List<Livre> findLivresPlusChersQueMoyenneCategorie();

    // Paramètre de type entité
    @Query("SELECT l FROM Livre l WHERE l.auteur = :auteur ORDER BY l.anneePublication DESC")
    List<Livre> findByAuteurOrderByDate(@Param("auteur") Auteur auteur);

    // Expression CASE
    @Query("SELECT l.titre, " +
           "CASE WHEN l.stock = 0 THEN 'Épuisé' " +
           "     WHEN l.stock < 5 THEN 'Faible stock' " +
           "     ELSE 'Disponible' END " +
           "FROM Livre l")
    List<Object[]> findLivresAvecStatutStock();
}

10.3 @Query avec SQL natif (nativeQuery = true)

Parfois, on a besoin de fonctions spécifiques à PostgreSQL que JPQL ne supporte pas.

public interface LivreRepository extends JpaRepository<Livre, Long> {

    // Utilisation de ILIKE (insensible à la casse, spécifique PostgreSQL)
    @Query(value = "SELECT * FROM livres WHERE titre ILIKE %:motCle%", 
           nativeQuery = true)
    List<Livre> rechercherInsensibleCasse(@Param("motCle") String motCle);

    // Fonction window PostgreSQL (non disponible en JPQL)
    @Query(value = "SELECT *, RANK() OVER (PARTITION BY categorie_id ORDER BY prix DESC) AS rang " +
                   "FROM livres", 
           nativeQuery = true)
    List<Object[]> findLivresAvecRangParCategorie();

    // Full-text search PostgreSQL
    @Query(value = "SELECT * FROM livres " +
                   "WHERE to_tsvector('french', titre) @@ plainto_tsquery('french', :recherche)",
           nativeQuery = true)
    List<Livre> fullTextSearch(@Param("recherche") String recherche);
}

Avec nativeQuery = true, on perd la portabilité entre bases de données. Utilisez-le seulement lorsque c’est vraiment nécessaire !

10.4 @Modifying : requêtes de mise à jour

Pour les UPDATE et DELETE, on ajoute @Modifying et on exécute dans une transaction.

public interface LivreRepository extends JpaRepository<Livre, Long> {

    // Mise à jour du stock
    @Modifying
    @Transactional
    @Query("UPDATE Livre l SET l.stock = :stock WHERE l.id = :id")
    int updateStock(@Param("id") Long id, @Param("stock") Integer stock);

    // Suppression par catégorie
    @Modifying
    @Transactional
    @Query("DELETE FROM Livre l WHERE l.categorie.id = :categorieId AND l.stock = 0")
    int deleteEpuisesParCategorie(@Param("categorieId") Long categorieId);

    // Augmentation de prix par catégorie
    @Modifying
    @Transactional
    @Query("UPDATE Livre l SET l.prix = l.prix * :facteur " +
           "WHERE l.categorie.libelle = :categorie")
    int augmenterPrixParCategorie(
        @Param("facteur") BigDecimal facteur, 
        @Param("categorie") String categorie
    );
}

@Modifying indique à Spring Data que c’est une requête de modification. @Transactional est nécessaire sur la méthode du repository (ou du service qui l’appelle).


11. Projections et DTOs

11.1 Pourquoi les projections (select) ?

Charger une entité complète quand on n’a besoin que de quelques champs est inefficace :

Les projections permettent de ne récupérer que les champs nécessaires. C’est une bonne pratique de ne récupérer que ce dont nous avons besoin.

11.2 Interface-based Projections (recommandé)

On définit une interface dont les méthodes correspondent aux champs qu’on veut récupérer. Spring Data JPA crée automatiquement une implémentation à la volée.

// Projection simple : seulement titre et prix
public interface LivreResume {
    String getTitre();
    BigDecimal getPrix();
}

// Projection avec navigation dans les relations (Open Projection)
public interface LivreAvecAuteur {
    String getTitre();
    BigDecimal getPrix();
    
    // Accès imbriqué : Spring Data crée automatiquement le JOIN
    AuteurResume getAuteur();
    
    interface AuteurResume {
        String getNom();
        String getPrenom();
    }
}

Utilisation dans le Repository

public interface LivreRepository extends JpaRepository<Livre, Long> {

    // Spring Data sait que le retour est une projection
    List<LivreResume> findByCategoriLibelle(String libelle);

    // Projection avec relation
    List<LivreAvecAuteur> findByCategorieId(Long categorieId);

    // Méthode générique avec projection (très puissant !)
    <T> List<T> findByPrixLessThan(BigDecimal prix, Class<T> type);
}

La méthode générique s’utilise ainsi :

// On décide du type de projection au moment de l'appel
List<LivreResume> resumes = livreRepository.findByPrixLessThan(
    new BigDecimal("20"), LivreResume.class);

List<Livre> entites = livreRepository.findByPrixLessThan(
    new BigDecimal("20"), Livre.class);

11.3 DTO (Data Transfer Object) avec constructeur JPQL

Une autre approche est le constructeur JPQL : on crée un DTO avec un constructeur et on l’instancie directement dans la requête.

// Le DTO : classe simple avec un constructeur
public class LivreDTO {

    private final String titre;
    private final BigDecimal prix;
    private final String nomAuteur;

    // Ce constructeur DOIT correspondre exactement aux arguments du NEW dans JPQL
    public LivreDTO(String titre, BigDecimal prix, String nomAuteur) {
        this.titre = titre;
        this.prix = prix;
        this.nomAuteur = nomAuteur;
    }

    // getters...
}

Requête JPQL avec constructeur :

// avec EntityManager directement
List<LivreDTO> dtos = em.createQuery(
    "SELECT NEW com.exemple.dto.LivreDTO(l.titre, l.prix, CONCAT(a.prenom, ' ', a.nom)) " +
    "FROM Livre l JOIN l.auteur a " +
    "WHERE l.categorie.libelle = :categorie",
    LivreDTO.class
)
.setParameter("categorie", "Roman")
.getResultList();

// Avec Spring Data JPA (@Query)
public interface LivreRepository extends JpaRepository<Livre, Long> {

    @Query("SELECT NEW com.exemple.dto.LivreDTO(l.titre, l.prix, CONCAT(a.prenom, ' ', a.nom)) " +
           "FROM Livre l JOIN l.auteur a WHERE l.categorie.libelle = :categorie")
    List<LivreDTO> findLivreDTOByCategorie(@Param("categorie") String categorie);
}

Conseil : Depuis Java 14, utilisez des records comme DTOs : syntaxe beaucoup plus concise !

// Avec un record Java (Java 14+)
public record LivreDTO(String titre, BigDecimal prix, String nomAuteur) {}

11.4 @Value : projections calculées

Les interfaces de projection peuvent contenir des expressions SpEL pour calculer des valeurs à partir des données de l’entité.

public interface LivreProjectionAvancee {
    String getTitre();
    BigDecimal getPrix();

    // Expression SpEL : concaténation côté Java (pas en JPQL)
    @Value("#{target.auteur.prenom + ' ' + target.auteur.nom}")
    String getNomCompletAuteur();

    // Calcul SpEL
    @Value("#{target.prix * 1.20}")  // Prix TTC avec TVA 20%
    BigDecimal getPrixTTC();
}

Les @Value SpEL sont calculés côté Java, donc l’entité complète (avec ses relations) doit être chargée. Préférez les colonnes calculées directement en JPQL pour les performances.


12. Pagination et tri

12.1 Pourquoi paginer ?

Quand une table contient des milliers de lignes, retourner tout d’un coup est catastrophique pour les performances (mémoire, réseau, temps de réponse). La pagination permet de récupérer les données par pages en déterminant le nombre des éléments.

12.2 Pageable et PageRequest

Spring Data JPA fournit une interface Pageable et son implémentation PageRequest. Comme vous le savez maintenant, on aime bien les interfaces en Java !

// Créer un objet Pageable : page 0 (première page), 10 éléments par page
Pageable premierePage = PageRequest.of(0, 10);

// Page 2 (troisième page), 20 éléments, triés par titre
Pageable pageTitre = PageRequest.of(2, 20, Sort.by("titre"));

// Tri multi-colonnes
Pageable pageComplexe = PageRequest.of(0, 15, Sort.by(Sort.Direction.DESC, "prix")
                                    .and(Sort.by(Sort.Direction.ASC, "titre")));

12.3 Repository avec Pageable

public interface LivreRepository extends JpaRepository<Livre, Long> {

    // Retourne une Page<T> (avec métadonnées de pagination)
    Page<Livre> findByCategorieLibelle(String libelle, Pageable pageable);

    // Retourne une Slice<T> (sait s'il y a une page suivante, plus léger)
    Slice<Livre> findByAuteurNationalite(String nationalite, Pageable pageable);

    // Retourne une List<T> simple (pas de count automatique)
    List<Livre> findByPrixLessThan(BigDecimal prix, Pageable pageable);

    // Avec @Query
    @Query("SELECT l FROM Livre l WHERE l.stock > 0 ORDER BY l.prix ASC")
    Page<Livre> findLivresDisponibles(Pageable pageable);
}

12.4 Utilisation de Page

L’objet Page<T> contient les données ET les métadonnées de pagination :

@Service
public class LivreService {

    public void afficherPageLivres(String categorie, int pageNum, int pageSize) {
        Pageable pageable = PageRequest.of(pageNum, pageSize, Sort.by("prix"));

        Page<Livre> page = livreRepository.findByCategorieLibelle(categorie, pageable);

        // Les données de la page courante
        List<Livre> livres = page.getContent();

        // Métadonnées
        System.out.println("Page " + page.getNumber() + " sur " + page.getTotalPages());
        System.out.println("Livres sur cette page : " + page.getNumberOfElements());
        System.out.println("Total de livres : " + page.getTotalElements());
        System.out.println("Première page ? " + page.isFirst());
        System.out.println("Dernière page ? " + page.isLast());
        System.out.println("Page suivante disponible ? " + page.hasNext());
    }
}

12.5 Sort dynamique

public interface LivreRepository extends JpaRepository<Livre, Long> {

    // La méthode accepte un Sort en plus des paramètres habituels
    List<Livre> findByCategorie(Categorie categorie, Sort sort);
}

// Utilisation
Sort parPrix = Sort.by(Sort.Direction.ASC, "prix");
Sort parTitrePuisPrix = Sort.by("titre").and(Sort.by("prix"));
Sort ignoreCase = Sort.by(Sort.Order.by("titre").ignoreCase());

List<Livre> livres = livreRepository.findByCategorie(categorie, parPrix);

12.6 Pagination avec @Query

public interface LivreRepository extends JpaRepository<Livre, Long> {

    // Spring Data injecte automatiquement la pagination dans la requête JPQL
    @Query("SELECT l FROM Livre l WHERE l.prix BETWEEN :min AND :max")
    Page<Livre> findByPrixEntre(
        @Param("min") BigDecimal min, 
        @Param("max") BigDecimal max, 
        Pageable pageable
    );

    // Pour les requêtes COUNT complexes, on peut spécifier une countQuery séparée
    @Query(
        value = "SELECT l FROM Livre l JOIN FETCH l.auteur WHERE l.stock > 0",
        countQuery = "SELECT COUNT(l) FROM Livre l WHERE l.stock > 0"
    )
    Page<Livre> findDisponiblesAvecAuteur(Pageable pageable);
}

La countQuery séparée est importante quand la requête principale contient un JOIN FETCH : Hibernate ne peut pas faire un COUNT sur une requête avec FETCH JOIN.


13. Criteria API introduction

13.1 Qu’est-ce que la Criteria API ?

La Criteria API est une façon de construire des requêtes JPA de manière programmatique (en Java pur), sans écrire de chaînes de caractères JPQL. Elle est particulièrement utile pour construire des requêtes dynamiques dont les conditions varient selon les paramètres.

13.2 Quand utiliser la Criteria API ?

// Exemple : recherche avec filtres optionnels
public class LivreSearchCriteria {
    private String titre;          // optionnel
    private String categorieNom;   // optionnel
    private BigDecimal prixMin;    // optionnel
    private BigDecimal prixMax;    // optionnel
    private Integer anneeMin;      // optionnel
}

13.3 Exemple avec Criteria API

@Repository
public class LivreSearchRepository {

    @PersistenceContext
    private EntityManager em;

    public List<Livre> rechercher(LivreSearchCriteria criteres) {
        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<Livre> cq = cb.createQuery(Livre.class);
        Root<Livre> livre = cq.from(Livre.class);

        // On construit la liste des conditions dynamiquement
        List<Predicate> predicats = new ArrayList<>();

        if (criteres.getTitre() != null && !criteres.getTitre().isBlank()) {
            predicats.add(cb.like(
                cb.lower(livre.get("titre")),
                "%" + criteres.getTitre().toLowerCase() + "%"
            ));
        }

        if (criteres.getCategorieNom() != null) {
            Join<Livre, Categorie> categorieJoin = livre.join("categorie", JoinType.LEFT);
            predicats.add(cb.equal(
                categorieJoin.get("libelle"), criteres.getCategorieNom()
            ));
        }

        if (criteres.getPrixMin() != null) {
            predicats.add(cb.greaterThanOrEqualTo(livre.get("prix"), criteres.getPrixMin()));
        }

        if (criteres.getPrixMax() != null) {
            predicats.add(cb.lessThanOrEqualTo(livre.get("prix"), criteres.getPrixMax()));
        }

        // Combiner toutes les conditions avec AND
        cq.where(cb.and(predicats.toArray(new Predicate[0])));
        cq.orderBy(cb.asc(livre.get("titre")));

        return em.createQuery(cq).getResultList();
    }
}

13.4 Spring Data JPA Specifications

Spring Data JPA encapsule la Criteria API dans des Specification<T>, qui sont plus lisibles et composables.

// Activer les Specifications dans le repository
public interface LivreRepository extends JpaRepository<Livre, Long>, 
                                           JpaSpecificationExecutor<Livre> {
    // Pas besoin d'ajouter de méthodes ici
}

// Définir les Specifications (des prédicats réutilisables)
public class LivreSpecifications {

    public static Specification<Livre> titreLike(String motCle) {
        return (root, query, cb) -> 
            motCle == null ? null :
            cb.like(cb.lower(root.get("titre")), "%" + motCle.toLowerCase() + "%");
    }

    public static Specification<Livre> prixMax(BigDecimal max) {
        return (root, query, cb) ->
            max == null ? null :
            cb.lessThanOrEqualTo(root.get("prix"), max);
    }

    public static Specification<Livre> categorieEgale(String categorie) {
        return (root, query, cb) ->
            categorie == null ? null :
            cb.equal(root.join("categorie").get("libelle"), categorie);
    }
}

// Utilisation : combinaison dynamique des Specifications
@Service
public class LivreService {

    public List<Livre> rechercher(String motCle, BigDecimal prixMax, String categorie) {
        Specification<Livre> spec = Specification
            .where(LivreSpecifications.titreLike(motCle))
            .and(LivreSpecifications.prixMax(prixMax))
            .and(LivreSpecifications.categorieEgale(categorie));

        return livreRepository.findAll(spec);
    }
}

14. Bonnes pratiques et performances

14.1 Éviter le problème N+1

Lorsque l’on retourne du JSON, on a souvent des boucles du fait des relations bidirectionnelles entre certaines entités y compris pour des requêtes JPQL.

Récapitulatif des solutions :

// 1. JOIN FETCH dans une @Query
@Query("SELECT DISTINCT l FROM Livre l JOIN FETCH l.auteur")
List<Livre> findAllAvecAuteur();

// 2. @EntityGraph : déclarer les associations à charger "eagerly" sur une requête
@EntityGraph(attributePaths = {"auteur", "categorie"})
List<Livre> findByCategorieId(Long id);

// 3. @BatchSize sur l'entité (charge les relations en lots, pas une par une)
@OneToMany(mappedBy = "auteur", fetch = FetchType.LAZY)
@BatchSize(size = 25)  // charge 25 collections à la fois au lieu d'1, on choisit !
private List<Livre> livres;

14.2 Activer les logs SQL pour diagnostiquer

# application.properties
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# Encore plus détaillé (avec les valeurs des paramètres)
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE

14.3 Utiliser @Transactional correctement

@Service
@Transactional(readOnly = true)  // Par défaut : lecture seule (optimisation)
public class LivreService {

    public List<Livre> tousLesLivres() {
        return livreRepository.findAll();  // Pas de gestion de transaction spéciale
    }

    @Transactional  // Override : cette méthode peut écrire
    public Livre creer(Livre livre) {
        return livreRepository.save(livre);
    }

    @Transactional  // Override
    public void supprimer(Long id) {
        livreRepository.deleteById(id);
    }
}

14.4 Indexer les colonnes fréquemment interrogées

@Entity
@Table(name = "livres", indexes = {
    @Index(name = "idx_livres_titre", columnList = "titre"),
    @Index(name = "idx_livres_auteur_id", columnList = "auteur_id"),
    @Index(name = "idx_livres_prix", columnList = "prix")
})
public class Livre {
    // ...
}

14.5 Les pièges courants

// PIÈGE 1 : Charger des entités pour juste les supprimer
livreRepository.findAll().forEach(l -> livreRepository.delete(l));  // TRÈS MAUVAIS

// SOLUTION : deleteAll() ou bulk delete
livreRepository.deleteAll();  // ou deleteAllInBatch() pour aller encore plus vite

// PIÈGE 2 : Oublier @Transactional sur les opérations d'écriture
public Livre mettreAJour(Livre livre) {
    return livreRepository.save(livre);  // Fonctionne hors transaction mais dangereux
}

// SOLUTION
@Transactional
public Livre mettreAJour(Livre livre) {
    return livreRepository.save(livre);
}

// PIÈGE 3 : LazyInitializationException
// Accéder à une collection LAZY en dehors d'une session Hibernate
public List<Livre> getLivresAuteur(Long auteurId) {
    Auteur auteur = auteurRepository.findById(auteurId).get();
    return auteur.getLivres();  // Gén-re une Exception si la session est fermée !
}

// SOLUTION : JOIN FETCH ou appel dans une méthode @Transactional
@Transactional(readOnly = true)
public List<Livre> getLivresAuteur(Long auteurId) {
    Auteur auteur = auteurRepository.findById(auteurId).get();
    return auteur.getLivres();  // OK : session encore ouverte dans @Transactional
}

15. TP EcoTrack

L’énoncé complet du TP est fourni.

Lien du TP complet

Voici un avant-goût des objectifs :

Vous allez construire EcoTrack, une application de suivi des actions écologiques des utilisateurs. L’application permettra de :

Technologies : Spring Boot 3 ou 4, Spring Data JPA, Hibernate, PostgreSQL, Maven