# 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 CityFilter y déclare qu'il réagira au paramètre d'URL city ;
  • Le CityFilter, une fois les paramètres envoyés validés (un ou plusieurs nombres, supérieurs à 0), il va demander au Repository disponible 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.