소스 검색

Cherry-picked specific files from develop to master

opentalent 1 년 전
부모
커밋
c4f127d554

+ 53 - 0
src/Filter/ApiPlatform/Person/FullNameFilter.php

@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Filter\ApiPlatform\Person;
+
+use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
+use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
+use ApiPlatform\Metadata\Operation;
+use Doctrine\ORM\QueryBuilder;
+
+class FullNameFilter extends AbstractFilter
+{
+    /**
+     * @param mixed[] $context
+     */
+    protected function filterProperty(string $property,
+        mixed $value,
+        QueryBuilder $queryBuilder,
+        QueryNameGeneratorInterface $queryNameGenerator,
+        string $resourceClass,
+        ?Operation $operation = null,
+        array $context = []): void
+    {
+        if ('fullname' !== $property) {
+            return;
+        }
+        $alias = $queryBuilder->getRootAliases()[0];
+        $queryBuilder
+            ->innerJoin(sprintf('%s.person', $alias), 'person')
+            ->andWhere('person.name LIKE :search OR person.givenName LIKE :search')
+            ->setParameter('search', '%'.$value.'%');
+    }
+
+    /**
+     * API docs.
+     *
+     * @return array<string, mixed[]>
+     */
+    public function getDescription(string $resourceClass): array
+    {
+        return [
+            'fullname' => [
+                'property' => null,
+                'type' => 'string',
+                'required' => false,
+                'openapi' => [
+                    'description' => 'Rechercher parmi les champs name et givenName',
+                ],
+            ],
+        ];
+    }
+}

+ 79 - 0
src/Filter/ApiPlatform/Utils/ArrayFieldFilter.php

@@ -0,0 +1,79 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Filter\ApiPlatform\Utils;
+
+use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
+use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
+use ApiPlatform\Metadata\Operation;
+use Doctrine\ORM\QueryBuilder;
+
+/**
+ * Cette classe est un filtre personnalisé pour les champs de type array
+ * Elle recherche des correspondances partielles dans un champ de type array JSON.
+ *
+ * différences avec le filtre InFilter:
+ * - ArrayFieldFilter recherche des correspondances partielles
+ * - InFilter utilise la clause SQL IN pour rechercher des correspondances exactes
+ * - Format est différent : pour ArrayFieldFilter, le format est un tableau JSON,
+ *  pour InFilter, le format est une liste CSV
+ */
+class ArrayFieldFilter extends AbstractFilter
+{
+    /**
+     * @param array<mixed> $context
+     */
+    protected function filterProperty(
+        string $property,
+        mixed $value,
+        QueryBuilder $queryBuilder,
+        QueryNameGeneratorInterface $queryNameGenerator,
+        string $resourceClass,
+        ?Operation $operation = null,
+        array $context = []
+    ): void {
+        if (!$this->isPropertyEnabled($property, $resourceClass)) {
+            return;
+        }
+
+        $parameterName = $queryNameGenerator->generateParameterName($property);
+        $valueArray = json_decode($value, true);
+
+        if (is_array($valueArray)) {
+            $queryBuilder->andWhere($queryBuilder->expr()->orX(
+                ...array_map(function ($val) use ($queryBuilder, $property, $parameterName) {
+                    return $queryBuilder->expr()->like(sprintf('o.%s', $property), ':'.$parameterName);
+                }, $valueArray)
+            ));
+            foreach ($valueArray as $key => $val) {
+                $queryBuilder->setParameter($parameterName, '%'.$val.'%');
+            }
+        } else {
+            $queryBuilder->andWhere(sprintf('o.%s LIKE :%s', $property, $parameterName))
+                ->setParameter($parameterName, '%'.$value.'%');
+        }
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function getDescription(string $resourceClass): array
+    {
+        if (!$this->properties) {
+            return [];
+        }
+
+        $description = [];
+        foreach ($this->properties as $property => $strategy) {
+            $description[$property] = [
+                'property' => $property,
+                'type' => 'array',
+                'required' => false,
+                'swagger' => ['description' => "Filter by $property"],
+            ];
+        }
+
+        return $description;
+    }
+}

+ 94 - 0
src/Filter/ApiPlatform/Utils/DistanceFilter.php

@@ -0,0 +1,94 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Filter\ApiPlatform\Utils;
+
+use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
+use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
+use ApiPlatform\Metadata\Operation;
+use Doctrine\ORM\QueryBuilder;
+use JetBrains\PhpStorm\ArrayShape;
+
+/**
+ * Add a distance filter to en entity.
+ *
+ * To use it, add the following query :
+ *
+ *     withinDistance={latitude},{longitude},{distance}
+ *
+ * Where latitude and longitude are the coordinates of the origine point, and distance the maximum
+ * distance in Km.
+ *
+ * /!\ The subject entity shall have a longitude and a latitude properties
+ */
+final class DistanceFilter extends AbstractFilter
+{
+    /**
+     * API docs.
+     *
+     * @return array<string, mixed[]>
+     */
+    #[ArrayShape(['search' => 'array'])]
+    public function getDescription(string $resourceClass): array
+    {
+        if (!property_exists($resourceClass, 'latitude') || !property_exists($resourceClass, 'longitude')) {
+            throw new \RuntimeException('DistanceFilter can only used with resources having both latitude and longitude properties');
+        }
+
+        return [
+            'search' => [
+                'property' => 'withinDistance',
+                'type' => 'string',
+                'required' => false,
+                'swagger' => [
+                    'description' => 'Filtre une entity selon sa distance (km) à un point (latitude, longitude). '.
+                                     "L'entité doit-elle aussi posséder des propriétés 'latitude' et 'longitude'.".
+                                     'Pass the following query to use it : `withinDistance=({latitude}, {longitude}, {distance})`, '.
+                                     'where {latitude} and {longitude} are the coordinates of the origine point, and {distance} the maximum distance in Km.',
+                    'name' => 'Distance Filter',
+                    'type' => 'Utils Filter',
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * @param mixed[] $context
+     */
+    protected function filterProperty(string $property,
+        mixed $value,
+        QueryBuilder $queryBuilder,
+        QueryNameGeneratorInterface $queryNameGenerator,
+        string $resourceClass,
+        ?Operation $operation = null,
+        array $context = []): void
+    {
+        if ('withinDistance' !== $property) {
+            return;
+        }
+        if (!preg_match('/^(-?\d+(\.\d+)?,){2}\d+(\.\d+)?$/', $value)) {
+            throw new \RuntimeException('DistanceFilter : Invalid argument, please pass latitude, longitude and distance to the parameter as comma separated floating numbers.');
+        }
+
+        [$latitude, $longitude, $distance] = explode(',', $value);
+
+        $alias = $queryBuilder->getRootAliases()[0];
+
+        // Generate unique parameters names to avoid collisions with other filters
+        $latitudeParameterName = $queryNameGenerator->generateParameterName('latitude');
+        $longitudeParameterName = $queryNameGenerator->generateParameterName('longitude');
+        $distanceParameterName = $queryNameGenerator->generateParameterName('distance');
+
+        $queryBuilder
+            ->andWhere(
+                sprintf(
+                    'SPHERICAL_DISTANCE(%1$s.latitude, %1$s.longitude, :%2$s, :%3$s) <= :%4$s',
+                    $alias, $latitudeParameterName, $longitudeParameterName, $distanceParameterName
+                )
+            )
+            ->setParameter($latitudeParameterName, $latitude)
+            ->setParameter($longitudeParameterName, $longitude)
+            ->setParameter($distanceParameterName, $distance);
+    }
+}

+ 66 - 0
src/Filter/ApiPlatform/Utils/FindInSetFilter.php

@@ -0,0 +1,66 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Filter\ApiPlatform\Utils;
+
+use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
+use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
+use ApiPlatform\Metadata\Operation;
+use Doctrine\ORM\QueryBuilder;
+use Symfony\Component\PropertyInfo\Type;
+
+final class FindInSetFilter extends AbstractFilter
+{
+    /**
+     * @param mixed[] $context
+     */
+    protected function filterProperty(string $property,
+        mixed $value,
+        QueryBuilder $queryBuilder,
+        QueryNameGeneratorInterface $queryNameGenerator,
+        string $resourceClass,
+        ?Operation $operation = null,
+        array $context = []): void
+    {
+        // otherwise filter is applied to order and page as well
+        if (!$this->isPropertyEnabled($property, $resourceClass) || !$this->isPropertyMapped($property, $resourceClass)
+        ) {
+            return;
+        }
+
+        $alias = $queryBuilder->getRootAliases()[0];
+        $parameterName = $queryNameGenerator->generateParameterName($property); // Generate a unique parameter name to avoid collisions with other filters
+        $queryBuilder
+            ->andWhere(sprintf('find_in_set(:%s, %s.%s) <> 0', $parameterName, $alias, $property))
+            ->setParameter($parameterName, explode(',', $value));
+    }
+
+    /**
+     * API docs.
+     *
+     * @return array<string, mixed[]>
+     */
+    public function getDescription(string $resourceClass): array
+    {
+        if (!$this->properties) {
+            return [];
+        }
+
+        $description = [];
+        foreach ($this->properties as $property => $strategy) {
+            $description["$property"] = [
+                'property' => $property,
+                'type' => Type::BUILTIN_TYPE_STRING,
+                'required' => false,
+                'swagger' => [
+                    'description' => "Filtre de type find_in_set(), vérifie que la valeur est dans le set d'un champs de type CSV",
+                    'name' => 'FindInSet Filter',
+                    'type' => 'Utils Filter',
+                ],
+            ];
+        }
+
+        return $description;
+    }
+}

+ 78 - 0
src/Filter/ApiPlatform/Utils/InFilter.php

@@ -0,0 +1,78 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Filter\ApiPlatform\Utils;
+
+use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
+use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
+use ApiPlatform\Metadata\Operation;
+use Doctrine\ORM\QueryBuilder;
+use Symfony\Component\PropertyInfo\Type;
+
+/**
+ * Is property included in the given CSV array.
+ *
+ * Ex:
+ *
+ *    /api/accesses?id[in]=123,124,125
+ */
+final class InFilter extends AbstractFilter
+{
+    /**
+     * @param mixed[] $context
+     */
+    protected function filterProperty(string $property,
+        mixed $value,
+        QueryBuilder $queryBuilder,
+        QueryNameGeneratorInterface $queryNameGenerator,
+        string $resourceClass,
+        ?Operation $operation = null,
+        array $context = []): void
+    {
+        // otherwise filter is applied to order and page as well
+        if (
+            !$this->isPropertyEnabled($property, $resourceClass)
+            || !$this->isPropertyMapped($property, $resourceClass)
+        ) {
+            return;
+        }
+
+        if (!isset($value['in'])) {
+            return;
+        }
+
+        $alias = $queryBuilder->getRootAliases()[0];
+        $parameterName = $queryNameGenerator->generateParameterName($property); // Generate a unique parameter name to avoid collisions with other filters
+        $queryBuilder
+            ->andWhere(sprintf('%s.%s IN (%s)', $alias, $property, $value['in']));
+    }
+
+    /**
+     * API docs.
+     *
+     * @return array<string, mixed[]>
+     */
+    public function getDescription(string $resourceClass): array
+    {
+        if (!$this->properties) {
+            return [];
+        }
+
+        $description = [];
+        foreach ($this->properties as $property => $strategy) {
+            $description[$property.'[in]'] = [
+                'property' => $property,
+                'type' => Type::BUILTIN_TYPE_STRING,
+                'required' => false,
+                'swagger' => [
+                    'description' => 'Filtre permettant d\'utiliser les IN. (usage: `id[in]=1,2,3`)',
+                    'name' => 'In Filter',
+                    'type' => 'Utils Filter',
+                ],
+            ];
+        }
+
+        return $description;
+    }
+}

+ 167 - 0
src/Filter/Doctrine/TimeConstraint/AbstractTimeFilter.php

@@ -0,0 +1,167 @@
+<?php
+
+namespace App\Filter\Doctrine\TimeConstraint;
+
+use App\Service\Constraint\ActivityYearConstraint;
+use App\Service\Constraint\DateTimeConstraint;
+use App\Service\Constraint\TimeConstraintInterface;
+use Doctrine\ORM\Mapping\ClassMetadata;
+use Doctrine\ORM\Query\Filter\SQLFilter;
+
+/**
+ * Applique les contraintes temporelles aux entités possédant l'annotation requise.
+ */
+abstract class AbstractTimeFilter extends SQLFilter
+{
+    protected bool $disabled = false;
+    protected ?int $accessId = null;
+    protected ?TimeConstraintInterface $timeConstraint = null;
+
+    /**
+     * Annotation expected, have to be re-defined in subclasses.
+     */
+    protected static ?string $constraintAnnotation = null;
+
+    /**
+     * Parameter of the annotation that design the field containing the starting date of the period
+     * Have to be re-defined in subclasses.
+     */
+    protected static ?string $annotationStartField = null;
+
+    /**
+     * Parameter of the annotation that design the field containing the ending date of the period
+     * Have to be re-defined in subclasses.
+     */
+    protected static ?string $annotationEndField = null;
+
+    public function setDisabled(bool $disabled): void
+    {
+        $this->disabled = $disabled;
+    }
+
+    public function isDisabled(): bool
+    {
+        return $this->disabled;
+    }
+
+    public function setAccessId(int $accessId): void
+    {
+        $this->accessId = $accessId;
+    }
+
+    public function setTimeConstraint(TimeConstraintInterface $timeConstraint): void
+    {
+        $this->timeConstraint = $timeConstraint;
+    }
+
+    /**
+     * Retourne les noms des champs contenant les dates de début et de fin de la période de la ressource testée.
+     *
+     * @param array<string, string> $arguments
+     *
+     * @return array<string>
+     */
+    protected function getStartAndEndFields(array $arguments): array
+    {
+        if (null === static::$annotationStartField || null === static::$annotationEndField) {
+            throw new \RuntimeException('Constraint annotation has not been properly configured');
+        }
+
+        $startField = $arguments[static::$annotationStartField] ?? null;
+        $endField = $arguments[static::$annotationEndField] ?? null;
+
+        return [$startField, $endField];
+    }
+
+    /**
+     * Invoke the TimeConstraintUtils to retrieve the active constraints for the given user.
+     *
+     * @return array<string, array<string, list<int>>>
+     *
+     * @throws \Exception
+     */
+    protected function getConstraints(int $accessId): array
+    {
+        return $this->timeConstraint->invoke($accessId);
+    }
+
+    /**
+     * Méthode surchargée de SQLFilter permettant d'appliquer un filtre supplémentaire aux requêtes SQL.
+     *
+     * @param string $targetTableAlias
+     */
+    public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string
+    {
+        if (null === static::$constraintAnnotation) {
+            throw new \RuntimeException('Constraint annotation has not been set');
+        }
+        if (null === $this->accessId) {
+            throw new \RuntimeException('AccessId has not been set');
+        }
+
+        $constraintAnnotation = $targetEntity->getReflectionClass()->getAttributes(static::$constraintAnnotation)[0] ?? null;
+        if (!$constraintAnnotation || $this->disabled) {
+            return '';
+        }
+
+        [$startFieldName, $endFieldName] = $this->getStartAndEndFields($constraintAnnotation->getArguments());
+        if (!$startFieldName || !$endFieldName) {
+            throw new \RuntimeException('Missing start and/or end field names in constraint annotation');
+        }
+
+        $constraints = $this->getConstraints($this->accessId);
+
+        $fields = [
+            ActivityYearConstraint::START_KEY => $startFieldName,
+            ActivityYearConstraint::END_KEY => $endFieldName,
+        ];
+
+        return $this->constructQuery($constraints, $targetTableAlias, $fields);
+    }
+
+    /**
+     * Fonction permettant de construire la requête SQL correspondante aux contraintes.
+     *
+     * @param array<string, array<string, int[]>> $constraints
+     * @param array<string, mixed>                $fields
+     */
+    protected function constructQuery(array $constraints, string $targetTableAlias, array $fields): string
+    {
+        $queryConditionsAND = [];
+        foreach ($constraints as $key => $constraint) {
+            $queryConditionsOR = [];
+            foreach ($constraint as $date => $conditions) {
+                foreach ($conditions as $condition) {
+                    $arithmetic = $this->getArithmeticValue($condition);
+                    if (!is_null($arithmetic)) {
+                        $queryConditionsOR[] = sprintf("%s.%s %s '%s'", $targetTableAlias, $fields[$key], $arithmetic, $date);
+                    } else {
+                        $queryConditionsOR[] = sprintf('%s.%s IS NULL', $targetTableAlias, $fields[$key]);
+                    }
+                }
+            }
+            if (!empty($queryConditionsOR)) {
+                $queryConditionsAND[] = sprintf('(%s)', join(' OR ', $queryConditionsOR));
+            }
+        }
+
+        return implode(' AND ', $queryConditionsAND);
+    }
+
+    /**
+     * Fonction retournant la valeur arithmétique correspondant à la condition de la contrainte.
+     *
+     * @see DateTimeFilterTest::testGetArithmeticValue()
+     */
+    protected function getArithmeticValue(int $condition): ?string
+    {
+        return match ($condition) {
+            DateTimeConstraint::INF => '<',
+            DateTimeConstraint::EQUAL => '=',
+            DateTimeConstraint::SUP => '>',
+            DateTimeConstraint::INF + DateTimeConstraint::EQUAL => '<=',
+            DateTimeConstraint::SUP + DateTimeConstraint::EQUAL => '>=',
+            default => null,
+        };
+    }
+}

+ 18 - 0
src/Filter/Doctrine/TimeConstraint/ActivityYearFilter.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace App\Filter\Doctrine\TimeConstraint;
+
+use App\Attribute\ActivityYearConstraintAware;
+
+/**
+ * Filtre Doctrine permettant d'assurer les contraintes de temps sur l'année d'activité actuelle
+ * de l'utilisateur en cours.
+ */
+class ActivityYearFilter extends AbstractTimeFilter
+{
+    protected static ?string $constraintAnnotation = ActivityYearConstraintAware::class;
+
+    protected static ?string $annotationStartField = 'startYearFieldName';
+
+    protected static ?string $annotationEndField = 'endYearFieldName';
+}

+ 17 - 0
src/Filter/Doctrine/TimeConstraint/DatetimeFilter.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace App\Filter\Doctrine\TimeConstraint;
+
+use App\Attribute\DateTimeConstraintAware;
+
+/**
+ * Filtre Doctrine permettant d'assurer les contraintes de temps sur la période choisie par l'utilisateur en cours.
+ */
+class DatetimeFilter extends AbstractTimeFilter
+{
+    protected static ?string $constraintAnnotation = DateTimeConstraintAware::class;
+
+    protected static ?string $annotationStartField = 'startDateFieldName';
+
+    protected static ?string $annotationEndField = 'endDateFieldName';
+}