Java EE / Jakarta EE

JPQL, API Criteria et requêtes natives avec Jakarta Persistence

Intermédiaire9 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

JPQL est le langage de requête orienté objet de Jakarta Persistence : il ressemble à SQL, mais manipule des entités et leurs attributs plutôt que des tables et des colonnes. Ce guide couvre le langage, du simple select aux fetch joins, agrégats, sous-requêtes et projections en DTO, puis présente l'API Criteria pour les requêtes dynamiques, les entity graphs pour maîtriser le chargement, les mises à jour en masse et le SQL natif, ainsi que les pièges de performance et de cohérence propres à chacun : N+1, pagination avec fetch join, contexte de persistance périmé.

Ce que vous allez apprendre

  • Écrire des selects JPQL avec jointures, fetch joins, conditions, tri, regroupement et sous-requêtes
  • Renvoyer des DTO avec des expressions constructeur et paginer les résultats sans risque
  • Construire des requêtes dynamiques et typées avec l'API Criteria et le métamodèle statique
  • Maîtriser le chargement avec les entity graphs et éviter les selects N+1
  • Utiliser mises à jour en masse, requêtes natives et mappings de result set sans corrompre le contexte de persistance

Le mapping des entités fait entrer et sortir les données une ligne à la fois ; les requêtes servent à en obtenir des ensembles. Jakarta Persistence propose trois façons d'interroger la base : JPQL, un langage textuel qui porte sur le modèle objet ; l'API Criteria, le même langage construit à partir d'objets Java ; et le SQL natif pour les cas que l'abstraction ne couvre pas. Tous trois renvoient des entités managed ou de simples valeurs via les mêmes interfaces Query, et, dans les trois cas, il est facile de se tromper sur la façon dont ils interagissent avec le contexte de persistance. Les exemples reprennent les entités Order, OrderLine, Customer et Product du guide sur le mapping.

JPQL en une page

JPQL se lit comme du SQL, mais chaque identifiant est un nom Java : noms d'entités (Order, et non orders), chemins d'attributs (o.customer.name) et littéraux Java. Les alias sont obligatoires en pratique, les mots-clés sont insensibles à la casse mais les identifiants y sont sensibles, et le résultat d'un select est ce que vous nommez dans la clause SELECT : entités, attributs, agrégats ou objets construits.

TypedQuery<Order> q = em.createQuery("""
        select o
        from Order o
        join o.customer c
        where c.country = :country
          and o.status in :statuses
          and o.placedAt >= :since
        order by o.placedAt desc""", Order.class);

List<Order> recent = q.setParameter("country", "FR")
                      .setParameter("statuses", EnumSet.of(OrderStatus.NEW, OrderStatus.PAID))
                      .setParameter("since", Instant.now().minus(30, ChronoUnit.DAYS))
                      .setMaxResults(50)
                      .getResultList();

TypedQuery<T> est la variante à privilégier : elle rend le type du résultat explicite et évite les casts. Les paramètres sont nommés (:country) ou positionnels (?1) ; les paramètres nommés sont plus lisibles et réutilisables au sein de la requête. Un paramètre de type collection se déplie en liste IN. Ne construisez jamais la chaîne de requête à partir d'une saisie utilisateur : outre l'injection, chaque chaîne distincte est analysée et mise en cache séparément.

La clause WHERE accepte les opérateurs SQL attendus (=, <>, <, BETWEEN, LIKE avec ESCAPE, IS NULL, IN, EXISTS), des fonctions sur les chaînes, les nombres et les dates (CONCAT, SUBSTRING, LOWER, LENGTH, SIZE, CURRENT_TIMESTAMP), ainsi que des opérateurs propres au modèle objet : IS EMPTY pour les collections, MEMBER OF pour l'appartenance, TYPE(o) pour les hiérarchies d'héritage, TREAT(o AS RushOrder) pour le downcast. Persistence 3.1 a ajouté LOCAL DATE, LOCAL TIME et LOCAL DATETIME pour les valeurs java.time, EXTRACT(YEAR FROM o.placedAt), et les fonctions numériques CEILING, FLOOR, EXP, LN, POWER, ROUND et SIGN.

Jointures et navigation par chemin

Un chemin qui traverse une association monovaluée s'utilise directement (o.customer.name) et implique une jointure interne. Les associations de type collection doivent être jointes explicitement :

select distinct o
from Order o
join o.lines l
join l.product p
where p.category = 'BOOKS'

Le distinct est nécessaire parce que la jointure produit une ligne de résultat par ligne de commande correspondante ; sans lui, JPQL, comme SQL, renverrait plusieurs fois la même commande. left join conserve les commandes sans lignes. join … on (JPA 2.1) ajoute des conditions à la jointure elle-même. Joindre une entité sans association mappée (join Product p on p.sku = l.sku) ne figure pas dans la spécification 3.1, mais Hibernate et EclipseLink le supportent tous deux en extension, ce qui rend service avec des schémas hérités.

Fetch joins : l'outil contre le N+1

La boucle suivante émet une requête pour les commandes, puis une par commande pour ses lignes : c'est le problème N+1 :

for (Order o : em.createQuery("select o from Order o", Order.class).getResultList()) {
    total += o.getLines().size();     // lazy collection: SELECT ... WHERE order_id = ? each time
}

Un fetch join charge l'association dans la même instruction et l'initialise sur les entités renvoyées :

select distinct o
from Order o
left join fetch o.lines
left join fetch o.customer
where o.status = :status

En JPQL portable, un fetch join ne prend pas d'alias et ne peut pas être filtré (un fetch filtré laisserait la collection partiellement chargée, donc fausse). Charger deux collections à la fois produit un produit cartésien (dix lignes fois trois paiements font trente lignes par commande) et Hibernate refuse de charger ensemble plusieurs bags en levant une MultipleBagFetchException. Chargez une seule collection par requête, ou utilisez des collections Set en acceptant le produit cartésien pour de petits volumes.

Agrégats, regroupement et projections

Les agrégats (COUNT, SUM, AVG, MIN, MAX) fonctionnent avec GROUP BY et HAVING exactement comme en SQL. Les résultats qui ne sont pas des entités reviennent sous forme d'Object[], sauf si vous nommez un type. Les expressions constructeur construisent directement un DTO :

record CustomerRevenue(String name, long orders, BigDecimal revenue) { }

List<CustomerRevenue> top = em.createQuery("""
        select new shop.report.CustomerRevenue(c.name, count(o), sum(o.total.amount))
        from Order o join o.customer c
        where o.status = shop.model.OrderStatus.PAID
        group by c.name
        having sum(o.total.amount) > :minimum
        order by sum(o.total.amount) desc""", CustomerRevenue.class)
    .setParameter("minimum", new BigDecimal("10000"))
    .setMaxResults(20)
    .getResultList();

Une requête de projection charge exactement les colonnes demandées, ne crée aucune entité managed et ne peut pas provoquer de lazy loading inattendu par la suite : c'est la bonne forme pour les écrans en lecture seule et les rapports. Remarquez le littéral d'enum : le JPQL portable exige le nom pleinement qualifié de la constante, même si Hibernate 6 accepte aussi la forme courte.

Sous-requêtes et prédicats sur collections

Les sous-requêtes sont autorisées dans WHERE et HAVING (where o.total.amount > (select avg(x.total.amount) from Order x)), avec EXISTS, ALL, ANY et SOME. Les prédicats sur collections expriment bien des conditions du type « au moins un » sans jointure explicite : where exists (select l from o.lines l where l.product.discontinued = true) ou, plus simplement, where :product member of o.products.

Named queries, résultat unique et pagination

@NamedQuery rattache une requête à une entité et la valide au démarrage de l'unité de persistance : une faute de frappe fait échouer le déploiement plutôt que la première requête d'un utilisateur :

@Entity
@NamedQuery(name = "Order.byReference",
            query = "select o from Order o where o.reference = :ref")
public class Order { ... }

Order order = em.createNamedQuery("Order.byReference", Order.class)
                .setParameter("ref", ref)
                .getSingleResult();

getSingleResult lève NoResultException s'il n'y a aucune ligne et NonUniqueResultException s'il y en a plusieurs ; ce sont deux exceptions runtime et, dans une transaction gérée par le conteneur, la première n'est pas en soi une condition de rollback, mais se propage souvent comme telle. getResultStream renvoie un Stream que certains fournisseurs adossent à un curseur défilant : fermez-le. Pour une ligne qui peut être absente, getResultList().stream().findFirst() évite l'exception ; la prochaine version de la spécification ajoute getSingleResultOrNull précisément pour ce cas.

La pagination, c'est setFirstResult(offset) plus setMaxResults(size), traduits en LIMIT/OFFSET ou en l'équivalent de la base. Deux règles : ajoutez toujours un ORDER BY déterministe (une page sans ordre total renvoie des lignes différentes d'une exécution à l'autre), et ne combinez jamais la pagination par offset avec un fetch join sur une collection, pour la raison donnée dans la FAQ. Pour les pages lointaines, la pagination par clé (keyset, where o.placedAt < :lastSeen order by o.placedAt desc) est à la fois plus rapide et stable face aux insertions.

Entity graphs

Les fetch joins règlent le chargement pour une requête donnée. Les entity graphs décrivent ce qu'il faut charger indépendamment de la requête et se transmettent sous forme de hint, ce qui convient aux repositories qui exposent une même requête avec plusieurs profils de chargement :

@NamedEntityGraph(name = "Order.withLines",
    attributeNodes = { @NamedAttributeNode("customer"),
                       @NamedAttributeNode(value = "lines", subgraph = "lines") },
    subgraphs = @NamedSubgraph(name = "lines", attributeNodes = @NamedAttributeNode("product")))
@Entity
public class Order { ... }

EntityGraph<?> graph = em.getEntityGraph("Order.withLines");
Order order = em.find(Order.class, id, Map.of("jakarta.persistence.fetchgraph", graph));

fetchgraph charge en eager exactement les attributs listés et traite tout le reste comme lazy ; loadgraph charge les attributs listés en plus de ce qui est déjà eager par mapping. Les graphes fonctionnent avec find et avec les requêtes via setHint. La façon dont le fournisseur les exécute (une jointure, plusieurs selects) lui appartient ; Hibernate utilise des jointures par défaut.

L'API Criteria pour les requêtes dynamiques

Les écrans de recherche aux filtres optionnels sont le cas d'école où concaténer des fragments de JPQL devient vite laid. L'API Criteria construit la même requête à partir d'objets et, avec le métamodèle statique (les classes Order_, Customer_ générées par l'annotation processor du fournisseur), les références aux attributs sont vérifiées par le compilateur :

public List<Order> search(String customer, OrderStatus status, Instant since) {
    CriteriaBuilder cb = em.getCriteriaBuilder();
    CriteriaQuery<Order> cq = cb.createQuery(Order.class);
    Root<Order> o = cq.from(Order.class);
    Join<Order, Customer> c = o.join(Order_.customer);

    List<Predicate> where = new ArrayList<>();
    if (customer != null) where.add(cb.like(cb.lower(c.get(Customer_.name)), customer.toLowerCase() + "%"));
    if (status != null)   where.add(cb.equal(o.get(Order_.status), status));
    if (since != null)    where.add(cb.greaterThanOrEqualTo(o.get(Order_.placedAt), since));

    cq.select(o).where(where.toArray(Predicate[]::new))
      .orderBy(cb.desc(o.get(Order_.placedAt)));

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

Tout ce que fait JPQL (fetch joins avec o.fetch(Order_.lines, JoinType.LEFT), sous-requêtes avec cq.subquery(...), projections par constructeur avec cb.construct(...), regroupement, CriteriaUpdate et CriteriaDelete) a son équivalent Criteria. Le prix à payer est la verbosité : réservez Criteria aux requêtes réellement dynamiques et utilisez JPQL pour le reste. Sans le métamodèle, vous retombez sur des noms d'attributs en chaînes (o.get("status")), et vous perdez la vérification de types qui justifiait cette verbosité.

Mises à jour et suppressions en masse

Les instructions UPDATE et DELETE en JPQL s'exécutent en une seule instruction SQL, ce qui est la manière efficace de modifier de nombreuses lignes :

int archived = em.createQuery("""
        update Order o set o.status = :archived
        where o.status = :closed and o.placedAt < :cutoff""")
    .setParameter("archived", OrderStatus.ARCHIVED)
    .setParameter("closed", OrderStatus.CLOSED)
    .setParameter("cutoff", cutoff)
    .executeUpdate();

Elles contournent entièrement le contexte de persistance : les entités déjà chargées ne sont pas mises à jour, @Version n'est pas incrémenté à moins de le faire dans l'instruction, les cascades et orphanRemoval ne s'exécutent pas, et les callbacks de cycle de vie ne sont pas invoqués. Lancez les instructions en masse avant de charger des entités dans la même transaction, ou appelez em.clear() ensuite. Elles exigent aussi une transaction active.

SQL natif

Quand JPQL ne sait pas exprimer la requête (fonctions de fenêtrage, CTE récursives, opérateurs JSON propres à une base, hints d'optimiseur), utilisez createNativeQuery. Le résultat peut être mappé sur une entité, sur des scalaires ou sur un DTO via @SqlResultSetMapping :

@SqlResultSetMapping(name = "DailyRevenue",
    classes = @ConstructorResult(targetClass = DailyRevenue.class,
        columns = { @ColumnResult(name = "day", type = LocalDate.class),
                    @ColumnResult(name = "revenue", type = BigDecimal.class) }))

List<DailyRevenue> series = em.createNativeQuery("""
        select date_trunc('day', placed_at)::date as day,
               sum(total_amount) over (order by date_trunc('day', placed_at)) as revenue
        from orders where status = 'PAID'
        group by 1 order by 1""", "DailyRevenue")
    .getResultList();

Les requêtes natives qui renvoient des entités produisent des instances managed comme les autres, donc les mêmes règles de lazy loading s'appliquent. Ce que vous perdez, c'est la portabilité entre bases de données et la connaissance du mapping par le fournisseur : les noms de colonnes dans le SQL doivent correspondre au schéma, pas à l'entité. Les procédures stockées ont leur propre API (@NamedStoredProcedureQuery, createStoredProcedureQuery) avec des paramètres IN, OUT et curseur.

Hints, timeouts et verrouillage

setHint transmet des hints standard ou propres au fournisseur : jakarta.persistence.query.timeout (en millisecondes), jakarta.persistence.lock.timeout, les hints d'entity graph vus plus haut, et les org.hibernate.readOnly ou org.hibernate.fetchSize d'Hibernate. setLockMode(LockModeType.PESSIMISTIC_WRITE) transforme le select en SELECT … FOR UPDATE, de sorte que les lignes restent verrouillées jusqu'au commit ; LockModeType.OPTIMISTIC_FORCE_INCREMENT incrémente la version d'un parent quand vous modifiez ses enfants, ce qui permet à la version d'un agrégat de couvrir ses parties. Hibernate propose aussi un cache de second niveau et un cache de requêtes, tous deux désactivés par défaut, qui ne valent la peine d'être activés que pour des données de référence qui changent rarement.

Erreurs fréquentes

  • Selects N+1. Parcourir les résultats en touchant des associations lazy. Fetch join, entity graph ou projection.
  • Pagination par offset avec un fetch join sur une collection. Tout le result set en mémoire. Paginez la racine, puis chargez.
  • Charger deux bags à la fois. MultipleBagFetchException ou explosion cartésienne. Une collection par requête.
  • Oublier distinct avec des jointures sur collection. Entités racines en double dans la liste.
  • Mise à jour en masse sans clear(). Entités périmées et contrôles de version ignorés. Videz le contexte ou lancez l'instruction en masse d'abord.
  • getSingleResult pour des lignes optionnelles. NoResultException sur le cas nominal. Utilisez getResultList et findFirst.
  • JPQL construit par concaténation à partir de saisies utilisateur. Injection et cache de plans de requête saturé. Paramètres et Criteria.
  • ORDER BY absent sur une requête paginée. Des lignes se répètent ou disparaissent d'une page à l'autre. Triez toujours sur une clé unique.

Points clés à retenir

  • JPQL désigne des entités et des attributs, pas des tables et des colonnes ; utilisez TypedQuery et des paramètres nommés, et ne concaténez jamais de saisie utilisateur.
  • Les collections exigent des jointures explicites ; distinct supprime les racines en double ; JOIN FETCH charge les associations dans la même instruction et constitue le principal remède au N+1.
  • Les expressions constructeur renvoient des DTO sans créer d'entités managed : c'est ce qu'il faut pour les écrans en lecture seule.
  • Les entity graphs décrivent le chargement indépendamment de la requête et se transmettent via les hints fetchgraph ou loadgraph.
  • Utilisez l'API Criteria avec le métamodèle généré pour les filtres dynamiques, et JPQL pour tout ce qui est statique.
  • Les UPDATE/DELETE en masse contournent le contexte de persistance (videz-le ensuite), et le SQL natif est la porte de sortie pour les fonctionnalités propres à une base, avec @SqlResultSetMapping pour mettre en forme les résultats.
  • Paginez avec un ordre déterministe, et jamais en combinaison avec un fetch join sur une collection.

Sources et lectures complémentaires

Questions fréquentes

Quelle différence entre JOIN et JOIN FETCH ?
Un JOIN simple permet de filtrer ou de projeter sur l'entité associée, mais ne la charge pas dans le résultat : l'association reste lazy. JOIN FETCH charge l'entité ou la collection associée dans la même instruction SQL et l'initialise sur les entités renvoyées. C'est ainsi qu'on évite les selects N+1 pour un schéma d'accès connu à l'avance.
Pourquoi ma requête paginée avec un fetch join charge-t-elle tout ?
Un fetch join sur une collection multiplie les lignes : une commande de cinq lignes devient cinq lignes de résultat. Le fournisseur ne peut pas appliquer LIMIT/OFFSET à ces lignes sans couper une entité en deux ; Hibernate applique donc la pagination en mémoire, après avoir récupéré tout le result set, et journalise un avertissement. Paginez d'abord sur l'entité racine, puis chargez les collections de cette page avec une seconde requête ou un entity graph.
Faut-il utiliser JPQL, Criteria ou du SQL natif ?
JPQL pour les requêtes connues au moment du développement : c'est lisible, et vérifié au démarrage de l'unité de persistance si vous utilisez des named queries. Criteria pour les requêtes construites à l'exécution à partir de filtres optionnels, là où la concaténation de chaînes serait fragile. Le SQL natif quand il vous faut une fonctionnalité de la base que JPQL ne sait pas exprimer (fonctions de fenêtrage, CTE récursives, hints propres à un éditeur) ou pour des requêtes de reporting qui ne doivent créer aucune entité.
Un UPDATE en masse en JPQL met-il à jour les entités déjà chargées ?
Non. Les UPDATE et DELETE en masse vont directement à la base en contournant le contexte de persistance : les entités managed conservent leur ancien état et leur version n'est pas incrémentée. Exécutez les instructions en masse en début de transaction, ou appelez clear() ensuite et rechargez ce dont vous avez besoin.