Les Critères de recherche avec Juery
Chaque fois que j’entreprends un nouveau projet, que ce soit professionnel ou perso, il y a des bouts de code que l’on ré-écrit chaque fois. L’api de critère de recherche et de filtre en fait partie. Bien sur il existe des librairies notamment dans Spring qui sont prêtent à l’emploi, mais toutes ou presque se basent sur JPA. Ce qui, quand on n’utilise pas JPA, impose une quantité de dépendances inutiles à embarquer dans votre build.
De ce constat est née Juery .
Edit: 17 juin 2024:
Sortie de la version 1.4.0
- Utilisation des records
- Ajout d’un module MongoDB
Description
Juery
est une librairie java. L’idée est de rester le plus simple possible et aussi le plus extensible possible. L’api se compose d’un ensemble de classes permettant de créer des jeux de critères pour la recherche ou le filtrage de vos entités. juery-api
s’utilise de manière “fluent".
import fr.ght1pc9kc.juery.api.Criteria;
Criteria.property("jedi").eq("Obiwan")
.and(Criteria.property("age").gt(40)
.or(Criteria.property("age").lt(20)));
juery-api
dispose aussi d’une classe permettant de manipuler de la pagination.
import fr.ght1pc9kc.juery.api.PageRequest;
import fr.ght1pc9kc.juery.api.pagination.Direction;
import fr.ght1pc9kc.juery.api.pagination.Order;
import fr.ght1pc9kc.juery.api.pagination.Sort;
PageRequest.builder()
.page(2).size(100)
.filter(Criteria.property("profile").eq("jedi").and(Criteria.property("job").eq("master")))
.sort(Sort.of(new Order(Direction.ASC, "name"), new Order(Direction.DESC, "email")))
.build();
L’interface Criteria
dispose d’un Visitor
qui va permettre de transformer vos critères en n’importe quoi. Par exemple en QueryString ou en Predicate ou encore en Condition
jOOQ
que vous pourrez utiliser dans vos requêtes.
Le package juery-basic
fournit des implémentations de base de ce Visitor
pour extraire les critères sous forme de liste ou pour transformer un objet Criteria
en query string ou en chaîne de caractères.
Le package juery-jooq
fournit des implémentations relatives à la librairie jOOQ
comme un Visitor
qui transforme un objet Criteria
en Condition
. Ce package est dépendant de la librairie jOOQ.
Installation
L’api fait 30 kb et ne dépend de rien, donc très léger si vous surveillez le poids de votre application. La version 1.4.0 pour tous les packages est disponible sur Maven Central.
<dependency>
<groupId>fr.ght1pc9kc</groupId>
<artifactId>juery-api</artifactId>
<version>1.4.0</version>
</dependency>
<dependency>
<groupId>fr.ght1pc9kc</groupId>
<artifactId>juery-basic</artifactId>
<version>1.4.0</version>
</dependency>
<dependency>
<groupId>fr.ght1pc9kc</groupId>
<artifactId>juery-jooq</artifactId>
<version>1.4.0</version>
</dependency>
<dependency>
<groupId>fr.ght1pc9kc</groupId>
<artifactId>juery-mongo</artifactId>
<version>1.4.0</version>
</dependency>
ou pour gradle
compile "fr.ght1pc9kc:juery-api:1.4.0"
compile "fr.ght1pc9kc:juery-basic:1.4.0"
compile "fr.ght1pc9kc:juery-jooq:1.4.0"
compile "fr.ght1pc9kc:juery-mongo:1.4.0"
Utilisation
Prenons comme exemple une application Spring dans le contrôleur qui gère un CRUD d’entités :
import fr.ght1pc9kc.juery.basic.PageRequestFormatter;
@GetMapping
public Flux<Feed> list(@RequestParam Map<String, String> queryStringParams) {
return feedService.list(PageRequestFormatter.parse(queryStringParams))
.onErrorMap(BadCriteriaFilter.class, e -> new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getLocalizedMessage()));
}
PageRequestFormatter
va donc transformer la query string en PageRequest
qui contient un Criteria
. Selon votre architecture, l’objet va traverser les couches jusqu’à la persistance. Il pourra être enrichi au passage grâce aux méthodes with
qui créent un nouvel objet enrichi. Toute l’api est strictement immutable.
Filtrage
Dans la couche de persistance on va pouvoir utiliser les Visitor
comme suit.
import fr.ght1pc9kc.juery.jooq.filter.JooqConditionVisitor;
private static final JooqConditionVisitor JOOQ_CONDITION_VISITOR =
new JooqConditionVisitor(PropertiesMappers.FEEDS_PROPERTIES_MAPPING::get);
L’implémentation JooqConditionVisitor
prend en entrée une Function<String, Field<?>>
qui va permettre de transformer les propriétés de vos critères, c’est-à-dire les noms des champs, en Field
jOOQ correspondant aux colonnes de vos tables.
Dans l’exemple au-dessus, PropertiesMappers.FEEDS_PROPERTIES_MAPPING
est une Map<String, Field<?>
rempli avec les Field
issues de la génération du DSL. Ensuite dans ma fonction de liste.
import fr.ght1pc9kc.juery.api.Criteria;
import fr.ght1pc9kc.juery.api.PageRequest;
// transformation du Criteria présent dans pageRequest en Condition
Condition conditions = pageRequest.filter.visit(JOOQ_CONDITION_VISITOR);
// Exécution de la requête avec les conditions générées par le visiteur
Cursor<Record> cursor = dsl
.select(FEEDS.fields()).select(FEEDS_USERS.FEUS_TAGS)
.from(FEEDS)
.leftJoin(FEEDS_USERS).on(FEEDS_USERS.FEUS_FEED_ID.eq(FEEDS.FEED_ID))
.where(conditions).fetchLazy();
Pagination
L’objet PageRequest
contient les informations nécessaires à la pagination. Vous pouvez l’implémenter vous-même, mais pour les utilisateurs de jOOQ, l’objet JooqPagination
vient vous faciliter la tâche.
import fr.ght1pc9kc.juery.api.Criteria;
import fr.ght1pc9kc.juery.api.PageRequest;
// transformation du Criteria présent dans pageRequest en Condition
Condition conditions = pageRequest.filter.visit(NEWS_CONDITION_VISITOR);
// Application des paramètres de pagination à la requête
final Select<Record> query = JooqPagination.apply(pageRequest, PropertiesMappers.FEEDS_PROPERTIES_MAPPING, dsl
.select(FEEDS.fields()).select(FEEDS_USERS.FEUS_TAGS)
.from(FEEDS)
.leftJoin(FEEDS_USERS).on(FEEDS_USERS.FEUS_FEED_ID.eq(FEEDS.FEED_ID))
.where(conditions)
);
Cursor<Record> cursor = query.fetchLazy();
On retrouve le PropertiesMappers.FEEDS_PROPERTIES_MAPPING
, optionnel qui permet de faire le lien entre les critères de tri et les champs de tables.
Le visiteur (du futur)
Comme évoqué plus haut, juery
est développé autour d’une api proposant un modèle de représentation de critères. Ce modèle afin d’être réutilisable dans une majorité de cas, propose un Visitor
simple à étendre. Dans le package juery-basic
quelques implémentations simples et indépendantes sont proposés, mais l’objectif de juery
est de s’adapter à tous que l’on peut rencontrer. Il est donc fortement conseillé d’utiliser le Visitor
pour adapter juery
à votre besoin.
interface Visitor<R> {
R visitNoCriteria(NoCriterion none);
R visitAnd(AndOperation operation);
R visitNot(NotOperation operation);
R visitOr(OrOperation operation);
<T> R visitEqual(EqualOperation<T> operation);
<T> R visitGreaterThan(GreaterThanOperation<T> operation);
<T> R visitLowerThan(LowerThanOperation<T> operation);
default <T> R visitIn(InOperation<T> operation) {
throw new IllegalStateException("IN operation not implemented in visitor");
}
default <T> R visitValue(CriterionValue<T> value) {
throw new IllegalStateException("Value not implemented in visitor");
}
}
En implémentant ce Visitor
il est possible de transformer un ensemble de Criteria
en n’importe quoi. Par exemple en Predicate<Entity>
qui va pouvoir filtrer une liste d’Entity
.
public class PredicateSearchVisitor<E> implements Criteria.Visitor<Predicate<E>> {
// L’utilisation de jackson ets pratique ici pour simplifier l’exemple
// Mais on peut surement faire mieux.
private final ObjectMapper mapper = new ObjectMapper()
.findAndRegisterModules();
}
Si pas de critère on retourne toujours vrai
@Override
public Predicate<E> visitNoCriteria(NoCriterion none) {
return n -> true;
}
Pour un opérateur && on visite les deux opérandes et on vérifie que les deux soient vrais.
@Override
public Predicate<E> visitAnd(AndOperation operation) {
return n -> operation.andCriteria.stream()
.map(a -> a.visit(this))
.allMatch(a -> a.test(n));
}
Pour une négation on visite l’opérande et on l’inverse
@Override
public Predicate<E> visitNot(NotOperation operation) {
return n -> !operation.negative.visit(this).test(n);
}
Pour un opérateur && on visite les deux opérandes et on vérifie que l’une des deux est vraie.
@Override
public Predicate<E> visitOr(OrOperation operation) {
return n -> operation.orCriteria.stream()
.map(a -> a.visit(this))
.anyMatch(a -> a.test(n));
}
Pour un opérateur in, on visite chacune des valeurs et on vérifie qu’au moins une soit vrai, c’est un or.
@Override
public <T> Predicate<E> visitIn(InOperation<T> operation) {
return n -> {
Map<String, Object> json = mapper.convertValue(n, new TypeReference<>() {
});
Object o = json.get(operation.field.property);
if (o == null) return true;
else return operation.value.value.stream().anyMatch(o::equals);
};
}
Pour une égalité on vérifie qu’elle soit vraie. C’est là qu’il faut mapper le nom du critère avec un champ de la classe. On utilise jackson on pourrait faire de la reflection.
@Override
public <T> Predicate<E> visitEqual(EqualOperation<T> operation) {
return n -> {
Map<String, Object> json = mapper.convertValue(n, new TypeReference<>() {
});
Object o = json.get(operation.field.property);
if (o == null) return true;
else return o.equals(operation.value.value);
};
}
Idem pour plus grand que…
@Override
public <T> Predicate<E> visitGreaterThan(GreaterThanOperation<T> operation) {
return n -> {
Map<String, Comparable<T>> json = mapper.convertValue(n, new TypeReference<>() {
});
Comparable<T> o = json.get(operation.field.property);
if (o == null) return true;
else return o.compareTo(operation.value.value) > 0;
};
}
…et pour plus petit que.
@Override
public <T> Predicate<E> visitLowerThan(LowerThanOperation<T> operation) {
return n -> {
try {
Field field = n.getClass().getDeclaredField(operation.field.property);
Object o = field.get(n);
if (o == null || !o.getClass().isAssignableFrom(Comparable.class)) return true;
else return ((Comparable<T>) o).compareTo(operation.value.value) < 0;
} catch (NoSuchFieldException | IllegalAccessException e) {
log.trace("Field '{}' not found in {}", operation.field.property, n);
return true;
}
};
}
On note que dans cette implémentation, la visite d’une valeur n’est pas implémentée, elle n’est pas nécessaire.
Contributions
C’est tout Open Source bien-sur, sous licence MIT et les sources du projet sont disponible sur github .
Toute contribution et critique (constructive) sont les biens venus.
N’hésitez pas à l’essayer et à me faire part de vos avis.