# Système de filtres (Filters)
Les filtres sont les bouts de code exécutés lors d'une requête entrante afin de préparer la requête finale à la base de données.
Reprenons l'exemple de la requête simple présentée dans la Gestion des requêtes.
Il s'agit de la requête GET /public/professional?city=11&limit=10.
Ici, pour rappel, on cherche à trouver les établissements dont l'adresse est dans la City d'id 11 (Fort-de-France), et d'en récupérer maximum 10.
Afin de traiter ce filtrage des établissements, on fait appel au système de filtres.
# Définition d'un Filter
Un Filter est une classe implémentant l'interface src/Filters/IFilter.php.
Son rôle est de réagir à certains paramètres de requête (eg. city, limit, ...) et de modifier une requête en conséquence.
Chaque filtre a un rôle bien précis : dans notre cas, nous allons appeler deux filtres : CityFilter, et LimitFilter.
# Mise en application des Filters
Lorsqu'une requête arrive, après avoir extrait de la requête les différents filtres demandés (city=11, limit=10), le Controller va en déduire les filtres à appliquer sur la requête.
Il commence à construire une requête globale à tous les établissements :
Extrait de src/Controller/AbstractCrudController.php
$context = new FilterContext();
$context->repository = $this->getRepository();
$context->user = $this->currentUser;
$context->request = $request;
$context->controller = $this;
$context->em = $this->em;
$context->queryBuilder = $repository->createFilterQueryBuilder();
TIP
Au passage, le Controller en profite pour créer un contexte d'exécution pour les filtres, afin qu'ils aient accès à toutes les informations qu'ils pourraient juger utile : requête actuelle, utilisateur connecté, controlleur appelé, ...
Puis, muni de ce contexte d'exécution, il va appeler un à un les filtres demandés.
# CityFilter
Pour mieux comprendre, voyons le code du CityFilter :
Extrait de src/Filter/CityFilter.php
class CityFilter implements IFilter {
/**
* @inheritdoc
*/
public static function getFilterName()
{
return 'city';
}
/**
* @inheritdoc
*/
public static function apply(string $filter, FilterContext $context, $value = null)
{
if (!is_array($value)) {
$value = [$value];
}
$cities = [];
foreach ($value as $city) {
$city = filter_var($city, FILTER_VALIDATE_INT);
if ($city <= 0 || $city === false) {
throw new APIException(400, 1000, 'Given `city` id is not valid.');
}
$cities[] = $city;
}
$context->repository->filterByCity($context->queryBuilder, $cities);
}
}
On y voit deux choses :
- Le
CityFiltery déclare qu'il réagira au paramètre d'URLcity; - Le
CityFilter, une fois les paramètres envoyés validés (un ou plusieurs nombres, supérieurs à 0), il va demander auRepositorydisponible dans le contexte d'exécution d'appliquer la méthode filterByCity.
TIP
Le Repository disponible dans le contexte d'exécution y est placé par le Controller : EventController y mettra un EventRepository, ProfessionalController un ProfessionalRepository, etc...
TIP
De manière générale, dans les Repository, les méthodes filterByXXX prennent un QueryBuilder en premier argument, et ont pour responsabilité de modifier ce QueryBuilder.
Par exemple, dans le ProfessionalRepository :
public function filterByCity(QueryBuilder $qb, array $ids)
{
$qb
->andWhere('city.id IN (:city_ids)')
->setParameter('city_ids', $ids);
return $qb;
}
WARNING
Il appartient aux Repository d'implémenter ces fonctions.
Souvent, ces fonctions sont identiques d'un Repository à l'autre. (filterByCity dans EventRepository et ProfessionalRepository sont identiques par exemple).
Peut être que les implémenter sous forme de Traits est envisageable, cependant attention, les nommages des différentes tables jointes dans les requêtes doit être fixé clairement (règle de nommage, configuration au niveau de la classe (City::CITY_SQL_JOIN_NAME = 'city'), ... ?).
# LimitFilter
Ensuite, est apppelé le LimitFilter :
class LimitFilter implements IFilter {
/**
* @inheritdoc
*/
public static function getFilterName()
{
return 'limit';
}
/**
* @inheritdoc
*/
public static function apply(string $filter, FilterContext $context, $value = null)
{
$value = filter_var($value, FILTER_VALIDATE_INT);
if ($value < 0 || $value === false) {
throw new APIException(400, 1000, 'Given `limit` is not valid. (int needed)');
} else if ($value >= 0) {
$context->repository->limit($context->queryBuilder, $value);
}
}
}
On retrouve la déclaration du paramètre d'URL auquel le filtre réagira, ainsi que le coeur du filtre, qui est une validation des données entrantes et l'appel au repository.
# Portée des filtres
Par défaut, les filtres ne sont pas disponibles sur toutes les routes.
Un controller peut choisir d'autoriser un filtre donné grâce à sa méthode getFilters, qui sera appelée par AbstractCrudController (en savoir plus) lors de l'exécution des filtres, afin de déterminer quels filtres peuvent s'exécuter.
TIP
Si un filtre n'est pas déclaré dans getFilters, envoyer le paramètre d'URL correspondant n'aura aucun effet : celui-ci sera ignoré.
# Filtres imposés, filtres par défaut
De plus, chaque route de chaque controller peut déclarer des filtres par défaut, ainsi que des filtres avec des valeurs fixées.
# Filtres imposés
Par exemple, dans la route permettant de récupérer les établissements liés à son compte, GET /user/professional (src/Controller/User/ProfessionalController.php), la récupération de ces établissements uniquement se base sur le système de filtres : on envoie un filtre owned avec pour valeur true.
C'est d'autant plus important, que ces établissements seront sérializés avec un groupe de sérialization permettant d'avoir accès à des données confidentielles sur les établissements remontés : numéro de SIRET, configuration des notifications, offre en cours, ...
Évidemment, on ne se base pas sur la bonne foi du client de l'API, qui appelant cette route, pensera à appeler GET /user/professional?owned=true.
Un controller peut donc, en transférant sa requête à l'AbstractCrudController, décider de forcer certains filtres.
Dans notre cas, on va choisir de forcer le filtre owned, avec la valeur true, ainsi que le Domain sur lequel s'est inscrit l'établissement, qui doit être identique au domaine appelant :
public function listingAction(Request $request, DomainManager $domainManager)
{
return parent::listing($request, [
'filters' => [
'owned' => true,
'domain' => $domainManager->getCurrentDomain()->getId(),
],
]);
}
# Filtres par défaut
Dans certains cas, on veut permettre à un filtre d'être activé par défaut avec une certaine valeur, mais modifiable par le client API.
Par exemple, ça pourrait être le cas du filtre limit : dans notre route précédente, nous pourrions avoir eu envie de limiter par défaut à 10 le nombre d'établissements remontés par la route.
Cela aurait donné :
public function listingAction(Request $request, DomainManager $domainManager)
{
return parent::listing($request, [
'defaults' => [
'limit' => 50,
],
'filters' => [
'owned' => true,
'domain' => $domainManager->getCurrentDomain()->getId(),
],
]);
}
TIP
En réalité, la limite par défaut répond d'une autre logique, plutôt que d'être présente sur toutes les routes de l'application : elle est définie dans config/services.yaml : app.default_listing_limit: 50, et utilisée par défaut dans l'AbstractCrudController si aucune autre limite n'est donnée.