Filtres de recherche : paramètres et requêtes dynamiques

Publié le · Mis à jour le

L'idée en une phrase

Une liste de produits, de commandes ou de clients a presque toujours des filtres : par catégorie, par prix, par date, par texte. L'utilisateur en remplit certains, pas tous. Le défi est de construire une seule requête qui n'applique que les critères renseignés, sans écrire une méthode par combinaison possible. Cette fiche montre comment recevoir ces filtres dans l'API, puis comment construire la requête avec les Specifications de Spring Data JPA.

Une image pour comprendre : le vendeur et la liste de critères

Dans un magasin, un client arrive et dit : « Je cherche un clavier, à moins de 100 €. » Il n'a rien dit sur la marque, la couleur ou la disponibilité.

Un bon vendeur ne lui répond pas : « Il me faut aussi la marque, la couleur et le délai. » Il prend les critères donnés et ignore les autres. Si un autre client dit seulement « en stock », le vendeur cherche parmi tous les produits en stock.

Une recherche avec filtres fonctionne exactement ainsi :

  • chaque filtre est facultatif ;
  • un filtre renseigné ajoute une condition ;
  • un filtre vide est ignoré.

Recevoir les filtres dans l'API

Dans l'adresse

Les filtres passent en paramètres de l'adresse, avec une requête GET :

GET /produits?categorie=CLAVIER&prixMax=100&enStock=true&page=0&size=20&sort=prix,asc

C'est la convention des API REST : une recherche ne modifie rien, et l'adresse peut être partagée, mise en favori, rejouée.

Un record pour regrouper les filtres

Plutôt qu'un @RequestParam par filtre, regroupe-les dans un record :

public record ProduitFiltre(
        Categorie categorie,

        @PositiveOrZero BigDecimal prixMin,
        @PositiveOrZero BigDecimal prixMax,

        @Size(min = 2, max = 50) String recherche,

        Boolean enStock
) {
    @AssertTrue(message = "Le prix minimum doit être inférieur ou égal au prix maximum")
    public boolean isIntervalleDePrixValide() {
        return prixMin == null || prixMax == null || prixMin.compareTo(prixMax) <= 0;
    }
}
@GetMapping("/produits")
public Page<ProduitDto> lister(@Valid ProduitFiltre filtre, Pageable pageable) {
    return produitService.rechercher(filtre, pageable);
}

Spring remplit automatiquement le record à partir des paramètres de l'adresse : ?categorie=CLAVIER donne filtre.categorie() == Categorie.CLAVIER. Un paramètre absent donne null.

Quelques détails importants :

  • tous les champs sont des types objets (Boolean, BigDecimal), jamais boolean ou int. Un boolean absent vaudrait false : impossible de distinguer « je veux les produits hors stock » de « je n'ai pas filtré sur le stock » ;
  • la validation s'applique comme sur un corps JSON (voir la fiche sur la validation des DTO) : un prixMin négatif ou une recherche d'une seule lettre renvoie une 400 ;
  • un filtre est une donnée venue de l'extérieur : comme toute donnée reçue, il se vérifie.

Le problème : les combinaisons

Avec quatre filtres facultatifs, il y a 16 combinaisons possibles (chacun présent ou absent). Avec six, 64. Impossible d'écrire une méthode par combinaison :

List<Produit> findByCategorie(...);
List<Produit> findByCategorieAndPrixLessThanEqual(...);
List<Produit> findByCategorieAndPrixLessThanEqualAndStockGreaterThan(...);
// … et 13 autres ❌

Il faut construire la requête au moment de l'appel, en n'ajoutant que les conditions utiles. C'est le rôle des Specifications.

Les Specifications

Le principe

Une Specification est une condition réutilisable, écrite en Java : « la catégorie vaut X », « le prix est inférieur à Y ». On les combine avec and et or pour former la requête finale.

D'abord, le repository doit savoir exécuter des Specifications :

public interface ProduitRepository extends JpaRepository<Produit, Long>,
                                           JpaSpecificationExecutor<Produit> {
}

Une méthode par condition

On regroupe les conditions dans une classe dédiée, une méthode par filtre :

public final class ProduitSpecifications {

    private ProduitSpecifications() {
    }

    public static Specification<Produit> deCategorie(Categorie categorie) {
        return (produit, requete, cb) -> cb.equal(produit.get("categorie"), categorie);
    }

    public static Specification<Produit> prixAuMoins(BigDecimal minimum) {
        return (produit, requete, cb) -> cb.greaterThanOrEqualTo(produit.get("prix"), minimum);
    }

    public static Specification<Produit> prixAuPlus(BigDecimal maximum) {
        return (produit, requete, cb) -> cb.lessThanOrEqualTo(produit.get("prix"), maximum);
    }

    public static Specification<Produit> enStock() {
        return (produit, requete, cb) -> cb.greaterThan(produit.get("stock"), 0);
    }

    public static Specification<Produit> nomContient(String texte) {
        return (produit, requete, cb) ->
                cb.like(cb.lower(produit.get("nom")), "%" + texte.toLowerCase() + "%");
    }
}

Chaque méthode renvoie une petite fonction qui reçoit :

Paramètre Rôle
produit (souvent nommé root) l'entité sur laquelle porte la requête : produit.get("prix") désigne son champ prix
requete (query) la requête en cours de construction (rarement utilisée)
cb (CriteriaBuilder) la boîte à outils pour écrire les conditions : equal, lessThan, like…

Les noms ("prix", "categorie") sont ceux des champs Java de l'entité, comme en JPQL.

Assembler selon les filtres renseignés

Dans le service, on part d'une Specification sans restriction, et on ajoute une condition seulement quand le filtre est renseigné :

@Transactional(readOnly = true)
public Page<ProduitDto> rechercher(ProduitFiltre filtre, Pageable pageable) {
    Specification<Produit> spec = Specification.unrestricted(); // au départ : tous les produits

    if (filtre.categorie() != null) {
        spec = spec.and(deCategorie(filtre.categorie()));
    }
    if (filtre.prixMin() != null) {
        spec = spec.and(prixAuMoins(filtre.prixMin()));
    }
    if (filtre.prixMax() != null) {
        spec = spec.and(prixAuPlus(filtre.prixMax()));
    }
    if (Boolean.TRUE.equals(filtre.enStock())) {
        spec = spec.and(enStock());
    }
    if (filtre.recherche() != null && !filtre.recherche().isBlank()) {
        spec = spec.and(nomContient(filtre.recherche().strip()));
    }

    return produitRepository.findAll(spec, pageable).map(ProduitDto::depuis);
}

Exactement le vendeur : chaque critère donné ajoute une condition, chaque critère absent est ignoré.

Pour ?categorie=CLAVIER&prixMax=100, Hibernate génère :

SELECT ... FROM produit p
WHERE p.categorie = ? AND p.prix <= ?
ORDER BY ...
LIMIT 20

Les valeurs passent en paramètres (les ?) : pas de risque d'injection SQL.

Note : Specification.unrestricted() existe depuis Spring Data JPA 3.5, et c'est la façon de faire avec Spring Boot 4. Sur un projet plus ancien, tu verras Specification.where(null) à la place : cette écriture est supprimée dans Spring Data JPA 4.

Une condition sur une relation

Pour filtrer sur un champ d'une entité liée, on fait une jointure :

public static Specification<Produit> deMarque(String nomMarque) {
    return (produit, requete, cb) -> {
        Join<Produit, Marque> marque = produit.join("marque");
        return cb.equal(marque.get("nom"), nomMarque);
    };
}

C'est l'équivalent du p.marque.nom = :nom d'une requête JPQL.

Les pièges

Le tri libre

Pageable lit le paramètre sort de l'adresse : ?sort=prix,asc. Mais rien n'empêche un client de demander ?sort=prixAchat,asc (un champ interne qui ne devrait pas être exposé), ou ?sort=nimportequoi (qui provoque une erreur, souvent transformée en 500).

Limite le tri aux champs prévus :

private static final Set<String> TRIS_AUTORISES = Set.of("nom", "prix", "dateCreation");

private Pageable verifierTri(Pageable pageable) {
    for (Sort.Order ordre : pageable.getSort()) {
        if (!TRIS_AUTORISES.contains(ordre.getProperty())) {
            throw new TriNonAutoriseException(ordre.getProperty());
        }
    }
    return pageable;
}

Et, comme dans la fiche sur JPA, fixe une taille de page maximale.

La recherche de texte

cb.like(cb.lower(produit.get("nom")), "%" + texte.toLowerCase() + "%")

Deux points à connaître :

  • les caractères % et _ ont un sens spécial dans un LIKE (« n'importe quels caractères », « un caractère »). Un utilisateur qui tape % dans le champ de recherche obtient tous les produits. Ce n'est pas une faille grave, mais sur une grosse table, c'est une requête lourde. Une recherche avec une longueur minimale (le @Size(min = 2) du filtre) limite déjà le problème ;
  • un LIKE '%texte%' ne peut pas utiliser un index classique : la base doit lire toutes les lignes. Sur quelques milliers de produits, aucun problème ; sur des millions, la recherche devient lente. Il faut alors une vraie recherche plein texte (voir l'exemple PostgreSQL dans la fiche sur JPA).

Les performances

Chaque filtre fréquemment utilisé est une colonne interrogée : categorie, prix, date_creation. Sur une table qui grossit, ces colonnes méritent un index (voir la section sur les index dans la fiche sur les entités JPA). Active les logs SQL et regarde les requêtes générées pour les combinaisons les plus courantes.

Et rappelle-toi qu'une Page exécute deux requêtes : les résultats, puis un COUNT sur l'ensemble filtré. Sur une grosse table, le COUNT peut coûter plus cher que les résultats : un Slice l'évite, si l'écran n'a pas besoin du nombre total.

Les N+1, encore

findAll(spec, pageable) renvoie des entités. Si le DTO lit une relation (produit.getMarque().getNom()), on retrouve le problème des N+1 requêtes (voir la fiche sur JPA). Regarde les logs SQL, et charge la relation si besoin, par exemple avec un @EntityGraph sur une redéfinition de findAll(Specification, Pageable) dans le repository.

Les autres façons de faire

Les Specifications ne sont pas la seule solution. Selon le nombre de filtres, une approche plus simple peut suffire :

Approche Pour
requêtes dérivées (findByCategorie) des filtres fixes : toujours les mêmes, toujours présents
JPQL avec IS NULL OR deux ou trois filtres facultatifs simples
Specifications des filtres nombreux et facultatifs : la solution de cette fiche
Querydsl la même chose que les Specifications, avec des noms de champs vérifiés à la compilation ; demande une bibliothèque et une génération de code

L'astuce IS NULL OR, pour quelques filtres simples :

@Query("""
        SELECT p FROM Produit p
        WHERE (:categorie IS NULL OR p.categorie = :categorie)
          AND (:prixMax IS NULL OR p.prix <= :prixMax)
        """)
Page<Produit> rechercher(Categorie categorie, BigDecimal prixMax, Pageable pageable);

Un paramètre null rend sa condition toujours vraie, donc ignorée. C'est lisible avec deux ou trois filtres. Au-delà, la requête devient difficile à lire et à faire évoluer, et la base l'optimise parfois moins bien : passe aux Specifications.

Les bonnes habitudes

  1. Les filtres en paramètres d'une requête GET, regroupés dans un record validé avec @Valid.
  2. Des types objets (Boolean, BigDecimal) pour distinguer « pas de filtre » de « faux » ou « zéro ».
  3. Une méthode par condition dans une classe de Specifications, combinées seulement quand le filtre est renseigné.
  4. Specification.unrestricted() comme point de départ, jamais null.
  5. Limite les tris autorisés et la taille de page.
  6. Une longueur minimale pour la recherche de texte, et une vraie recherche plein texte si la table est grande.
  7. Des index sur les colonnes les plus filtrées, et un œil sur les logs SQL.
  8. Jamais de requête construite par concaténation de texte : les Specifications passent toujours les valeurs en paramètres.
  9. La solution la plus simple qui suffit : requêtes dérivées pour des filtres fixes, IS NULL OR pour deux ou trois filtres, Specifications au-delà.

À retenir

  • Une recherche avec filtres n'applique que les critères renseignés : un filtre vide est ignoré.
  • Les filtres arrivent en paramètres d'adresse, dans un record validé, avec des types objets.
  • Avec plusieurs filtres facultatifs, les combinaisons explosent : on construit la requête au moment de l'appel.
  • Les Specifications sont des conditions réutilisables, combinées avec and à partir de Specification.unrestricted().
  • Pense aux tris autorisés, à la taille de page, aux index et à la recherche de texte sur les grosses tables.

Avis sur l'article

Pas encore de note : soyez le premier à donner votre avis.

Les avis sont publiés après validation par le modérateur.

Votre note