From 77cb4c6b74819b737ea7705fe450e5c11bd23016 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Mon, 4 Nov 2024 17:12:05 +0100 Subject: [PATCH] feat(doctrine): doctrine filters like laravel eloquent filters --- .../Common/Filter/BooleanFilterTrait.php | 2 +- .../Filter/ManagerRegistryAwareInterface.php | 25 +++ .../Odm/Extension/ParameterExtension.php | 52 +++++-- src/Doctrine/Odm/Filter/AbstractFilter.php | 34 ++++- src/Doctrine/Odm/Filter/BooleanFilter.php | 12 +- src/Doctrine/Odm/Filter/DateFilter.php | 28 +++- src/Doctrine/Odm/PropertyHelperTrait.php | 8 +- .../Orm/Extension/ParameterExtension.php | 41 ++++- src/Doctrine/Orm/Filter/AbstractFilter.php | 37 +++-- src/Doctrine/Orm/Filter/BooleanFilter.php | 12 +- src/Doctrine/Orm/Filter/DateFilter.php | 28 +++- src/Doctrine/Orm/PropertyHelperTrait.php | 2 +- src/Laravel/ApiPlatformProvider.php | 1 + src/Metadata/Parameter.php | 12 +- ...meterResourceMetadataCollectionFactory.php | 41 ++++- ...ResourceMetadataCollectionFactoryTests.php | 9 ++ .../Resources/config/doctrine_mongodb_odm.xml | 2 +- .../Bundle/Resources/config/doctrine_orm.xml | 1 + .../Resources/config/metadata/resource.xml | 1 + .../Document/FilteredBooleanParameter.php | 60 ++++++++ .../Document/FilteredDateParameter.php | 72 +++++++++ .../Entity/FilteredBooleanParameter.php | 62 ++++++++ .../Entity/FilteredDateParameter.php | 74 +++++++++ .../Parameters/BooleanFilterTest.php | 135 ++++++++++++++++ .../Functional/Parameters/DateFilterTest.php | 144 ++++++++++++++++++ 25 files changed, 843 insertions(+), 52 deletions(-) create mode 100644 src/Doctrine/Common/Filter/ManagerRegistryAwareInterface.php create mode 100644 tests/Fixtures/TestBundle/Document/FilteredBooleanParameter.php create mode 100644 tests/Fixtures/TestBundle/Document/FilteredDateParameter.php create mode 100644 tests/Fixtures/TestBundle/Entity/FilteredBooleanParameter.php create mode 100644 tests/Fixtures/TestBundle/Entity/FilteredDateParameter.php create mode 100644 tests/Functional/Parameters/BooleanFilterTest.php create mode 100644 tests/Functional/Parameters/DateFilterTest.php diff --git a/src/Doctrine/Common/Filter/BooleanFilterTrait.php b/src/Doctrine/Common/Filter/BooleanFilterTrait.php index c9ee4a364d8..88bc9822305 100644 --- a/src/Doctrine/Common/Filter/BooleanFilterTrait.php +++ b/src/Doctrine/Common/Filter/BooleanFilterTrait.php @@ -61,7 +61,7 @@ public function getDescription(string $resourceClass): array return $description; } - abstract protected function getProperties(): ?array; + abstract public function getProperties(): ?array; abstract protected function getLogger(): LoggerInterface; diff --git a/src/Doctrine/Common/Filter/ManagerRegistryAwareInterface.php b/src/Doctrine/Common/Filter/ManagerRegistryAwareInterface.php new file mode 100644 index 00000000000..3d0eb30c26c --- /dev/null +++ b/src/Doctrine/Common/Filter/ManagerRegistryAwareInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Common\Filter; + +use Doctrine\Persistence\ManagerRegistry; + +interface ManagerRegistryAwareInterface +{ + public function hasManagerRegistry(): bool; + + public function getManagerRegistry(): ManagerRegistry; + + public function setManagerRegistry(?ManagerRegistry $managerRegistry): void; +} diff --git a/src/Doctrine/Odm/Extension/ParameterExtension.php b/src/Doctrine/Odm/Extension/ParameterExtension.php index 0191871c07f..a4929d118b0 100644 --- a/src/Doctrine/Odm/Extension/ParameterExtension.php +++ b/src/Doctrine/Odm/Extension/ParameterExtension.php @@ -13,12 +13,17 @@ namespace ApiPlatform\Doctrine\Odm\Extension; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; +use ApiPlatform\Doctrine\Odm\Filter\AbstractFilter; use ApiPlatform\Doctrine\Odm\Filter\FilterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ParameterNotFound; +use Doctrine\Bundle\MongoDBBundle\ManagerRegistry; use Doctrine\ODM\MongoDB\Aggregation\Builder; +use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; /** * Reads operation parameters and execute its filter. @@ -29,14 +34,20 @@ final class ParameterExtension implements AggregationCollectionExtensionInterfac { use ParameterValueExtractorTrait; - public function __construct(private readonly ContainerInterface $filterLocator) - { + public function __construct( + private readonly ContainerInterface $filterLocator, + private readonly ?ManagerRegistry $managerRegistry = null, + ) { } + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass = null, ?Operation $operation = null, array &$context = []): void { foreach ($operation->getParameters() ?? [] as $parameter) { - if (!($v = $parameter->getValue()) || $v instanceof ParameterNotFound) { + if (null === ($v = $parameter->getValue()) || $v instanceof ParameterNotFound) { continue; } @@ -45,14 +56,33 @@ private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass continue; } - $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; - if ($filter instanceof FilterInterface) { - $filterContext = ['filters' => $values, 'parameter' => $parameter]; - $filter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext); - // update by reference - if (isset($filterContext['mongodb_odm_sort_fields'])) { - $context['mongodb_odm_sort_fields'] = $filterContext['mongodb_odm_sort_fields']; - } + $filter = match (true) { + $filterId instanceof FilterInterface => $filterId, + \is_string($filterId) && $this->filterLocator->has($filterId) => $this->filterLocator->get($filterId), + default => null, + }; + + if (!($filter instanceof FilterInterface)) { + return; + } + + if ($filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry()) { + $filter->setManagerRegistry($this->managerRegistry); + } + + if ($filter instanceof AbstractFilter && !$filter->getProperties()) { + $propertyKey = $parameter->getProperty() ?? $parameter->getKey(); + $filterContext = $parameter->getFilterContext(); + + $properties = \is_array($filterContext) ? $filterContext : [$propertyKey => $filterContext]; + $filter->setProperties($properties); + } + + $filterContext = ['filters' => $values, 'parameter' => $parameter]; + $filter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext); + // update by reference + if (isset($filterContext['mongodb_odm_sort_fields'])) { + $context['mongodb_odm_sort_fields'] = $filterContext['mongodb_odm_sort_fields']; } } } diff --git a/src/Doctrine/Odm/Filter/AbstractFilter.php b/src/Doctrine/Odm/Filter/AbstractFilter.php index 87c30390c32..7002e3f14cf 100644 --- a/src/Doctrine/Odm/Filter/AbstractFilter.php +++ b/src/Doctrine/Odm/Filter/AbstractFilter.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Doctrine\Odm\Filter; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; use ApiPlatform\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Doctrine\Odm\PropertyHelperTrait as MongoDbOdmPropertyHelperTrait; @@ -30,14 +31,18 @@ * * @author Alan Poulain */ -abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface +abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface, ManagerRegistryAwareInterface { use MongoDbOdmPropertyHelperTrait; use PropertyHelperTrait; protected LoggerInterface $logger; - public function __construct(protected ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, protected ?array $properties = null, protected ?NameConverterInterface $nameConverter = null) - { + public function __construct( + protected ?ManagerRegistry $managerRegistry = null, + ?LoggerInterface $logger = null, + protected ?array $properties = null, + protected ?NameConverterInterface $nameConverter = null, + ) { $this->logger = $logger ?? new NullLogger(); } @@ -56,18 +61,35 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera */ abstract protected function filterProperty(string $property, $value, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void; - protected function getManagerRegistry(): ManagerRegistry + public function hasManagerRegistry(): bool + { + return $this->managerRegistry instanceof ManagerRegistry; + } + + public function getManagerRegistry(): ManagerRegistry { + if (!$this->hasManagerRegistry()) { + throw new \RuntimeException('ManagerRegistry must be initialized before accessing it.'); + } + return $this->managerRegistry; } - protected function getProperties(): ?array + public function setManagerRegistry(?ManagerRegistry $managerRegistry): void + { + $this->managerRegistry = $managerRegistry; + } + + /** + * @return array|null + */ + public function getProperties(): ?array { return $this->properties; } /** - * @param string[] $properties + * @param array $properties */ public function setProperties(array $properties): void { diff --git a/src/Doctrine/Odm/Filter/BooleanFilter.php b/src/Doctrine/Odm/Filter/BooleanFilter.php index babe3309ed0..19086725acd 100644 --- a/src/Doctrine/Odm/Filter/BooleanFilter.php +++ b/src/Doctrine/Odm/Filter/BooleanFilter.php @@ -14,7 +14,9 @@ namespace ApiPlatform\Doctrine\Odm\Filter; use ApiPlatform\Doctrine\Common\Filter\BooleanFilterTrait; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\ODM\MongoDB\Types\Type as MongoDbType; @@ -104,7 +106,7 @@ * @author Teoh Han Hui * @author Alan Poulain */ -final class BooleanFilter extends AbstractFilter +final class BooleanFilter extends AbstractFilter implements JsonSchemaFilterInterface { use BooleanFilterTrait; @@ -139,4 +141,12 @@ protected function filterProperty(string $property, $value, Builder $aggregation $aggregationBuilder->match()->field($matchField)->equals($value); } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return $parameter->getSchema() ?? ['type' => 'boolean']; + } } diff --git a/src/Doctrine/Odm/Filter/DateFilter.php b/src/Doctrine/Odm/Filter/DateFilter.php index 8be5534fbca..b3fef152650 100644 --- a/src/Doctrine/Odm/Filter/DateFilter.php +++ b/src/Doctrine/Odm/Filter/DateFilter.php @@ -16,7 +16,12 @@ use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; use ApiPlatform\Doctrine\Common\Filter\DateFilterTrait; use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\ODM\MongoDB\Types\Type as MongoDbType; @@ -117,7 +122,7 @@ * @author Théo FIDRY * @author Alan Poulain */ -final class DateFilter extends AbstractFilter implements DateFilterInterface +final class DateFilter extends AbstractFilter implements DateFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { use DateFilterTrait; @@ -237,4 +242,25 @@ private function addMatch(Builder $aggregationBuilder, string $field, string $op $aggregationBuilder->match()->addAnd($aggregationBuilder->matchExpr()->field($field)->operator($operatorValue[$operator], $value)); } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return ['type' => 'date']; + } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + $in = $parameter instanceof QueryParameter ? 'query' : 'header'; + $key = $parameter->getKey(); + + return [ + new OpenApiParameter(name: $key.'[after]', in: $in), + new OpenApiParameter(name: $key.'[before]', in: $in), + new OpenApiParameter(name: $key.'[strictly_after]', in: $in), + new OpenApiParameter(name: $key.'[strictly_before]', in: $in), + ]; + } } diff --git a/src/Doctrine/Odm/PropertyHelperTrait.php b/src/Doctrine/Odm/PropertyHelperTrait.php index e1c7693f2b0..6e73db7893e 100644 --- a/src/Doctrine/Odm/PropertyHelperTrait.php +++ b/src/Doctrine/Odm/PropertyHelperTrait.php @@ -27,7 +27,7 @@ */ trait PropertyHelperTrait { - abstract protected function getManagerRegistry(): ManagerRegistry; + abstract protected function getManagerRegistry(): ?ManagerRegistry; /** * Splits the given property into parts. @@ -39,9 +39,9 @@ abstract protected function splitPropertyParts(string $property, string $resourc */ protected function getClassMetadata(string $resourceClass): ClassMetadata { - $manager = $this - ->getManagerRegistry() - ->getManagerForClass($resourceClass); + /** @var ?ManagerRegistry $managerRegistry */ + $managerRegistry = $this->getManagerRegistry(); + $manager = $managerRegistry?->getManagerForClass($resourceClass); if ($manager) { return $manager->getClassMetadata($resourceClass); diff --git a/src/Doctrine/Orm/Extension/ParameterExtension.php b/src/Doctrine/Orm/Extension/ParameterExtension.php index 6ae3a00100b..f31ca563643 100644 --- a/src/Doctrine/Orm/Extension/ParameterExtension.php +++ b/src/Doctrine/Orm/Extension/ParameterExtension.php @@ -13,14 +13,19 @@ namespace ApiPlatform\Doctrine\Orm\Extension; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; +use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ParameterNotFound; use Doctrine\ORM\QueryBuilder; +use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; +use Symfony\Bridge\Doctrine\ManagerRegistry; /** * Reads operation parameters and execute its filter. @@ -31,17 +36,22 @@ final class ParameterExtension implements QueryCollectionExtensionInterface, Que { use ParameterValueExtractorTrait; - public function __construct(private readonly ContainerInterface $filterLocator) - { + public function __construct( + private readonly ContainerInterface $filterLocator, + private readonly ?ManagerRegistry $managerRegistry = null, + ) { } /** * @param array $context + * + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface */ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { foreach ($operation?->getParameters() ?? [] as $parameter) { - if (!($v = $parameter->getValue()) || $v instanceof ParameterNotFound) { + if (null === ($v = $parameter->getValue()) || $v instanceof ParameterNotFound) { continue; } @@ -50,12 +60,31 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter continue; } - $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; - if (!$filter instanceof FilterInterface) { + $filter = match (true) { + $filterId instanceof FilterInterface => $filterId, + \is_string($filterId) && $this->filterLocator->has($filterId) => $this->filterLocator->get($filterId), + default => null, + }; + + if (!($filter instanceof FilterInterface)) { throw new InvalidArgumentException(\sprintf('Could not find filter "%s" for parameter "%s" in operation "%s" for resource "%s".', $filterId, $parameter->getKey(), $operation?->getShortName(), $resourceClass)); } - $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $values, 'parameter' => $parameter] + $context); + if ($filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry()) { + $filter->setManagerRegistry($this->managerRegistry); + } + + if ($filter instanceof AbstractFilter && !$filter->getProperties()) { + $propertyKey = $parameter->getProperty() ?? $parameter->getKey(); + $filterContext = $parameter->getFilterContext(); + + $properties = \is_array($filterContext) ? $filterContext : [$propertyKey => $filterContext]; + $filter->setProperties($properties); + } + + $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, + ['filters' => $values, 'parameter' => $parameter] + $context + ); } } diff --git a/src/Doctrine/Orm/Filter/AbstractFilter.php b/src/Doctrine/Orm/Filter/AbstractFilter.php index 4ec704638a7..4900c33c10b 100644 --- a/src/Doctrine/Orm/Filter/AbstractFilter.php +++ b/src/Doctrine/Orm/Filter/AbstractFilter.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Doctrine\Orm\Filter; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; use ApiPlatform\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Doctrine\Orm\PropertyHelperTrait as OrmPropertyHelperTrait; @@ -24,14 +25,18 @@ use Psr\Log\NullLogger; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; -abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface +abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface, ManagerRegistryAwareInterface { use OrmPropertyHelperTrait; use PropertyHelperTrait; protected LoggerInterface $logger; - public function __construct(protected ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, protected ?array $properties = null, protected ?NameConverterInterface $nameConverter = null) - { + public function __construct( + protected ?ManagerRegistry $managerRegistry = null, + ?LoggerInterface $logger = null, + protected ?array $properties = null, + protected ?NameConverterInterface $nameConverter = null, + ) { $this->logger = $logger ?? new NullLogger(); } @@ -53,29 +58,43 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q */ abstract protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void; - protected function getManagerRegistry(): ManagerRegistry + public function hasManagerRegistry(): bool + { + return $this->managerRegistry instanceof ManagerRegistry; + } + + public function getManagerRegistry(): ManagerRegistry { + if (!$this->hasManagerRegistry()) { + throw new \RuntimeException('ManagerRegistry must be initialized before accessing it.'); + } + return $this->managerRegistry; } - protected function getProperties(): ?array + public function setManagerRegistry(?ManagerRegistry $managerRegistry): void { - return $this->properties; + $this->managerRegistry = $managerRegistry; } - protected function getLogger(): LoggerInterface + public function getProperties(): ?array { - return $this->logger; + return $this->properties; } /** - * @param string[] $properties + * @param array $properties */ public function setProperties(array $properties): void { $this->properties = $properties; } + protected function getLogger(): LoggerInterface + { + return $this->logger; + } + /** * Determines whether the given property is enabled. */ diff --git a/src/Doctrine/Orm/Filter/BooleanFilter.php b/src/Doctrine/Orm/Filter/BooleanFilter.php index e9f0a8373e0..a9ac1127a12 100644 --- a/src/Doctrine/Orm/Filter/BooleanFilter.php +++ b/src/Doctrine/Orm/Filter/BooleanFilter.php @@ -15,7 +15,9 @@ use ApiPlatform\Doctrine\Common\Filter\BooleanFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; @@ -106,7 +108,7 @@ * @author Amrouche Hamza * @author Teoh Han Hui */ -final class BooleanFilter extends AbstractFilter +final class BooleanFilter extends AbstractFilter implements JsonSchemaFilterInterface { use BooleanFilterTrait; @@ -145,4 +147,12 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB ->andWhere(\sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) ->setParameter($valueParameter, $value); } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return $parameter->getSchema() ?? ['type' => 'boolean']; + } } diff --git a/src/Doctrine/Orm/Filter/DateFilter.php b/src/Doctrine/Orm/Filter/DateFilter.php index 8533ee34406..ccfb8d85b0c 100644 --- a/src/Doctrine/Orm/Filter/DateFilter.php +++ b/src/Doctrine/Orm/Filter/DateFilter.php @@ -17,7 +17,12 @@ use ApiPlatform\Doctrine\Common\Filter\DateFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Doctrine\DBAL\Types\Type as DBALType; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Query\Expr\Join; @@ -120,7 +125,7 @@ * @author Kévin Dunglas * @author Théo FIDRY */ -final class DateFilter extends AbstractFilter implements DateFilterInterface +final class DateFilter extends AbstractFilter implements DateFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { use DateFilterTrait; @@ -269,4 +274,25 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf $queryBuilder->setParameter($valueParameter, $value, $type); } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return ['type' => 'date']; + } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + $in = $parameter instanceof QueryParameter ? 'query' : 'header'; + $key = $parameter->getKey(); + + return [ + new OpenApiParameter(name: $key.'[after]', in: $in), + new OpenApiParameter(name: $key.'[before]', in: $in), + new OpenApiParameter(name: $key.'[strictly_after]', in: $in), + new OpenApiParameter(name: $key.'[strictly_before]', in: $in), + ]; + } } diff --git a/src/Doctrine/Orm/PropertyHelperTrait.php b/src/Doctrine/Orm/PropertyHelperTrait.php index 8431e3e1680..d9376bc7ff6 100644 --- a/src/Doctrine/Orm/PropertyHelperTrait.php +++ b/src/Doctrine/Orm/PropertyHelperTrait.php @@ -29,7 +29,7 @@ */ trait PropertyHelperTrait { - abstract protected function getManagerRegistry(): ManagerRegistry; + abstract protected function getManagerRegistry(): ?ManagerRegistry; /** * Splits the given property into parts. diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index a71a43ebb92..f458fa2887d 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -360,6 +360,7 @@ public function register(): void new ParameterResourceMetadataCollectionFactory( $this->app->make(PropertyNameCollectionFactoryInterface::class), $this->app->make(PropertyMetadataFactoryInterface::class), + $this->app->make(LoggerInterface::class), new AlternateUriResourceMetadataCollectionFactory( new FiltersResourceMetadataCollectionFactory( new FormatsResourceMetadataCollectionFactory( diff --git a/src/Metadata/Parameter.php b/src/Metadata/Parameter.php index aa91e5d762f..a1faa5b12c5 100644 --- a/src/Metadata/Parameter.php +++ b/src/Metadata/Parameter.php @@ -45,7 +45,7 @@ public function __construct( protected string|\Stringable|null $security = null, protected ?string $securityMessage = null, protected ?array $extraProperties = [], - protected ?array $filterContext = null, + protected array|string|null $filterContext = null, ) { } @@ -138,7 +138,7 @@ public function getExtraProperties(): array return $this->extraProperties; } - public function getFilterContext(): ?array + public function getFilterContext(): array|string|null { return $this->filterContext; } @@ -203,6 +203,14 @@ public function withFilter(mixed $filter): static return $self; } + public function withFilterContext(array|string $filterContext): static + { + $self = clone $this; + $self->filterContext = $filterContext; + + return $self; + } + public function withProperty(string $property): static { $self = clone $this; diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 066244428d1..df1b065262a 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -28,6 +28,7 @@ use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use ApiPlatform\Serializer\Filter\FilterInterface as SerializerFilterInterface; use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** @@ -42,6 +43,7 @@ final class ParameterResourceMetadataCollectionFactory implements ResourceMetada public function __construct( private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, + private readonly LoggerInterface $logger, private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, private readonly ?ContainerInterface $filterLocator = null, private readonly ?NameConverterInterface $nameConverter = null, @@ -184,12 +186,6 @@ private function setDefaults(string $key, Parameter $parameter, string $resource $parameter = $parameter->withProvider('api_platform.serializer.filter_parameter_provider'); } - // Read filter description to populate the Parameter - $description = $filter instanceof FilterInterface ? $filter->getDescription($this->getFilterClass($operation)) : []; - if (($schema = $description[$key]['schema'] ?? null) && null === $parameter->getSchema()) { - $parameter = $parameter->withSchema($schema); - } - $currentKey = $key; if (null === $parameter->getProperty() && isset($properties[$key])) { $parameter = $parameter->withProperty($key); @@ -204,11 +200,42 @@ private function setDefaults(string $key, Parameter $parameter, string $resource $parameter = $parameter->withExtraProperties(['_query_property' => $eloquentRelation['foreign_key']] + $parameter->getExtraProperties()); } + $parameter = $this->addFilterMetadata($parameter); + + if ($filter instanceof FilterInterface) { + try { + return $this->getLegacyFilterMetadata($parameter, $operation, $filter); + } catch (\RuntimeException $exception) { + $this->logger->alert($exception->getMessage(), ['exception' => $exception]); + + return $parameter; + } + } + + return $parameter; + } + + private function getLegacyFilterMetadata(Parameter $parameter, Operation $operation, FilterInterface $filter): Parameter + { + $description = $filter->getDescription($this->getFilterClass($operation)); + $key = $parameter->getKey(); + if (($schema = $description[$key]['schema'] ?? null) && null === $parameter->getSchema()) { + $parameter = $parameter->withSchema($schema); + } + + if (null === $parameter->getProperty() && ($property = $description[$key]['property'] ?? null)) { + $parameter = $parameter->withProperty($property); + } + if (null === $parameter->getRequired() && ($required = $description[$key]['required'] ?? null)) { $parameter = $parameter->withRequired($required); } - return $this->addFilterMetadata($parameter); + if (null === $parameter->getOpenApi() && ($openApi = $description[$key]['openapi'] ?? null) && $openApi instanceof OpenApiParameter) { + $parameter = $parameter->withOpenApi($openApi); + } + + return $parameter; } private function getFilterClass(Operation $operation): ?string diff --git a/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTests.php b/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTests.php index d845833e69c..2eabcdd65d3 100644 --- a/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTests.php +++ b/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTests.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Metadata\Tests\Resource\Factory; +use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException; use ApiPlatform\Metadata\FilterInterface; use ApiPlatform\Metadata\Parameters; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; @@ -22,16 +23,23 @@ use ApiPlatform\Metadata\Resource\Factory\ParameterResourceMetadataCollectionFactory; use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\WithParameter; use ApiPlatform\OpenApi\Model\Parameter; +use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; class ParameterResourceMetadataCollectionFactoryTests extends TestCase { + /** + * @throws Exception + * @throws ResourceClassNotFoundException + */ public function testParameterFactory(): void { $nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class); $propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class); $filterLocator = $this->createStub(ContainerInterface::class); + $logger = $this->createStub(LoggerInterface::class); $filterLocator->method('has')->willReturn(true); $filterLocator->method('get')->willReturn(new class implements FilterInterface { public function getDescription(string $resourceClass): array @@ -55,6 +63,7 @@ public function getDescription(string $resourceClass): array $parameter = new ParameterResourceMetadataCollectionFactory( $nameCollection, $propertyMetadata, + $logger, new AttributesResourceMetadataCollectionFactory(), $filterLocator ); diff --git a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml index e4206ea097d..d6485bf1d1a 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml @@ -137,7 +137,7 @@ - + diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml index d6b3b1ffee8..6e00d888829 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml @@ -150,6 +150,7 @@ + diff --git a/src/Symfony/Bundle/Resources/config/metadata/resource.xml b/src/Symfony/Bundle/Resources/config/metadata/resource.xml index 59b9422a9df..aa01292a2bc 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/resource.xml +++ b/src/Symfony/Bundle/Resources/config/metadata/resource.xml @@ -82,6 +82,7 @@ + diff --git a/tests/Fixtures/TestBundle/Document/FilteredBooleanParameter.php b/tests/Fixtures/TestBundle/Document/FilteredBooleanParameter.php new file mode 100644 index 00000000000..efa2428f864 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/FilteredBooleanParameter.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Doctrine\Odm\Filter\BooleanFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ApiResource] +#[GetCollection( + parameters: [ + 'active' => new QueryParameter( + filter: new BooleanFilter(), + ), + 'enabled' => new QueryParameter( + filter: new BooleanFilter(), + property: 'active', + ), + ], +)] +#[ODM\Document] +class FilteredBooleanParameter +{ + public function __construct( + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + public ?int $id = null, + + #[ODM\Field(type: 'bool', nullable: true)] + public ?bool $active = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(?bool $active): void + { + $this->active = $active; + } +} diff --git a/tests/Fixtures/TestBundle/Document/FilteredDateParameter.php b/tests/Fixtures/TestBundle/Document/FilteredDateParameter.php new file mode 100644 index 00000000000..6a4e77e7b7c --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/FilteredDateParameter.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; +use ApiPlatform\Doctrine\Odm\Filter\DateFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ApiResource] +#[GetCollection( + paginationItemsPerPage: 5, + parameters: [ + 'createdAt' => new QueryParameter( + filter: new DateFilter(), + ), + 'date' => new QueryParameter( + filter: new DateFilter(), + property: 'createdAt', + ), + 'date_include_null_always' => new QueryParameter( + filter: new DateFilter(), + property: 'createdAt', + filterContext: DateFilterInterface::INCLUDE_NULL_BEFORE_AND_AFTER, + ), + 'date_old_way' => new QueryParameter( + filter: new DateFilter(), + property: 'createdAt', + filterContext: ['createdAt' => DateFilterInterface::INCLUDE_NULL_BEFORE_AND_AFTER], + ), + ], +)] +#[ODM\Document] +class FilteredDateParameter +{ + public function __construct( + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + public ?int $id = null, + + #[ODM\Field(type: 'date_immutable', nullable: true)] + public ?\DateTimeImmutable $createdAt = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(?\DateTimeImmutable $createdAt): void + { + $this->createdAt = $createdAt; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/FilteredBooleanParameter.php b/tests/Fixtures/TestBundle/Entity/FilteredBooleanParameter.php new file mode 100644 index 00000000000..6ba24650a79 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/FilteredBooleanParameter.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource] +#[GetCollection( + parameters: [ + 'active' => new QueryParameter( + filter: new BooleanFilter(), + ), + 'enabled' => new QueryParameter( + filter: new BooleanFilter(), + property: 'active', + ), + ], +)] +#[ORM\Entity] +class FilteredBooleanParameter +{ + public function __construct( + #[ORM\Column] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null, + + #[ORM\Column(nullable: true)] + public ?bool $active = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(?bool $isActive): void + { + $this->active = $isActive; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/FilteredDateParameter.php b/tests/Fixtures/TestBundle/Entity/FilteredDateParameter.php new file mode 100644 index 00000000000..8c06aa4c809 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/FilteredDateParameter.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; +use ApiPlatform\Doctrine\Orm\Filter\DateFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource] +#[GetCollection( + paginationItemsPerPage: 5, + parameters: [ + 'createdAt' => new QueryParameter( + filter: new DateFilter(), + ), + 'date' => new QueryParameter( + filter: new DateFilter(), + property: 'createdAt', + ), + 'date_include_null_always' => new QueryParameter( + filter: new DateFilter(), + property: 'createdAt', + filterContext: DateFilterInterface::INCLUDE_NULL_BEFORE_AND_AFTER, + ), + 'date_old_way' => new QueryParameter( + filter: new DateFilter(), + property: 'createdAt', + filterContext: ['createdAt' => DateFilterInterface::INCLUDE_NULL_BEFORE_AND_AFTER], + ), + ], +)] +#[ORM\Entity] +class FilteredDateParameter +{ + public function __construct( + #[ORM\Column] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null, + + #[ORM\Column(nullable: true)] + public ?\DateTimeImmutable $createdAt = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(?\DateTimeImmutable $createdAt): void + { + $this->createdAt = $createdAt; + } +} diff --git a/tests/Functional/Parameters/BooleanFilterTest.php b/tests/Functional/Parameters/BooleanFilterTest.php new file mode 100644 index 00000000000..2ca58092536 --- /dev/null +++ b/tests/Functional/Parameters/BooleanFilterTest.php @@ -0,0 +1,135 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\FilteredBooleanParameter as FilteredBooleanParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilteredBooleanParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; + +final class BooleanFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FilteredBooleanParameter::class]; + } + + /** + * @throws MongoDBException + * @throws \Throwable + */ + protected function setUp(): void + { + $entityClass = $this->isMongoDB() ? FilteredBooleanParameterDocument::class : FilteredBooleanParameter::class; + + $this->recreateSchema([$entityClass]); + $this->loadFixtures($entityClass); + } + + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + */ + #[DataProvider('booleanFilterScenariosProvider')] + public function testBooleanFilterResponses(string $url, int $expectedCount): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + + $expectedValue = str_contains($url, '=true') || str_contains($url, '=1'); + foreach ($filteredItems as $item) { + $errorMessage = \sprintf("Expected 'active' to be %s", $expectedValue ? 'true' : 'false'); + $this->assertSame($expectedValue, $item['active'], $errorMessage); + } + } + + public static function booleanFilterScenariosProvider(): \Generator + { + yield 'active_true' => ['/filtered_boolean_parameters?active=true', 2]; + yield 'active_false' => ['/filtered_boolean_parameters?active=false', 1]; + yield 'active_numeric_1' => ['/filtered_boolean_parameters?active=1', 2]; + yield 'active_numeric_0' => ['/filtered_boolean_parameters?active=0', 1]; + yield 'enabled_alias_true' => ['/filtered_boolean_parameters?enabled=true', 2]; + yield 'enabled_alias_false' => ['/filtered_boolean_parameters?enabled=false', 1]; + yield 'enabled_alias_numeric_1' => ['/filtered_boolean_parameters?enabled=1', 2]; + yield 'enabled_alias_numeric_0' => ['/filtered_boolean_parameters?enabled=0', 1]; + } + + /** + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + */ + #[DataProvider('booleanFilterNullAndEmptyScenariosProvider')] + public function testBooleanFilterWithNullAndEmptyValues(string $url): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $expectedCount = 3; + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + } + + public static function booleanFilterNullAndEmptyScenariosProvider(): \Generator + { + yield 'active_null_value' => ['/filtered_boolean_parameters?active=null']; + yield 'active_empty_value' => ['/filtered_boolean_parameters?active=', 3]; + yield 'enabled_alias_null_value' => ['/filtered_boolean_parameters?enabled=null']; + yield 'enabled_alias_empty_value' => ['/filtered_boolean_parameters?enabled=', 3]; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(string $entityClass): void + { + $manager = $this->getManager(); + + $booleanStates = [true, true, false, null]; + foreach ($booleanStates as $activeValue) { + $entity = new $entityClass(active: $activeValue); + $manager->persist($entity); + } + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/DateFilterTest.php b/tests/Functional/Parameters/DateFilterTest.php new file mode 100644 index 00000000000..3d068a031cb --- /dev/null +++ b/tests/Functional/Parameters/DateFilterTest.php @@ -0,0 +1,144 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\FilteredDateParameter as FilteredDateParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilteredDateParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; + +final class DateFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FilteredDateParameter::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $entityClass = $this->isMongoDB() ? FilteredDateParameterDocument::class : FilteredDateParameter::class; + + $this->recreateSchema([$entityClass]); + $this->loadFixtures($entityClass); + } + + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + */ + #[DataProvider('dateFilterScenariosProvider')] + public function testDateFilterResponses(string $url, int $expectedCount): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + } + + public static function dateFilterScenariosProvider(): \Generator + { + yield 'created_at_after' => ['/filtered_date_parameters?createdAt[after]=2024-01-01', 3]; + yield 'created_at_before' => ['/filtered_date_parameters?createdAt[before]=2024-12-31', 3]; + yield 'created_at_before_single_result' => ['/filtered_date_parameters?createdAt[before]=2024-01-02', 1]; + yield 'created_at_strictly_after' => ['/filtered_date_parameters?createdAt[strictly_after]=2024-01-01', 2]; + yield 'created_at_strictly_before' => ['/filtered_date_parameters?createdAt[strictly_before]=2024-12-31T23:59:59Z', 3]; + yield 'date_alias_after' => ['/filtered_date_parameters?date[after]=2024-01-01', 3]; + yield 'date_alias_before' => ['/filtered_date_parameters?date[before]=2024-12-31', 3]; + yield 'date_alias_before_first' => ['/filtered_date_parameters?date[before]=2024-01-02', 1]; + yield 'date_alias_strictly_after' => ['/filtered_date_parameters?date[strictly_after]=2024-01-01', 2]; + yield 'date_alias_strictly_before' => ['/filtered_date_parameters?date[strictly_before]=2024-12-31T23:59:59Z', 3]; + yield 'date_alias_include_null_always_after_date' => ['/filtered_date_parameters?date_include_null_always[after]=2024-06-15', 3]; + yield 'date_alias_include_null_always_before_date' => ['/filtered_date_parameters?date_include_null_always[before]=2024-06-14', 2]; + yield 'date_alias_include_null_always_before_all_date' => ['/filtered_date_parameters?date_include_null_always[before]=2024-12-31', 4]; + yield 'date_alias_old_way' => ['/filtered_date_parameters?date_old_way[before]=2024-06-14', 2]; + yield 'date_alias_old_way_after_last_one' => ['/filtered_date_parameters?date_old_way[after]=2024-12-31', 1]; + } + + /** + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + */ + #[DataProvider('dateFilterNullAndEmptyScenariosProvider')] + public function testDateFilterWithNullAndEmptyValues(string $url, int $expectedCount): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + } + + public static function dateFilterNullAndEmptyScenariosProvider(): \Generator + { + yield 'created_at_null_value' => ['/filtered_date_parameters?createdAt=null', 4]; + yield 'created_at_empty_value' => ['/filtered_date_parameters?createdAt=', 4]; + yield 'date_null_value_alias' => ['/filtered_date_parameters?date=null', 4]; + yield 'date_empty_value_alias' => ['/filtered_date_parameters?date=', 4]; + yield 'date_alias__include_null_always_with_null_alias' => ['/filtered_date_parameters?date_include_null_always=null', 4]; + yield 'date__alias_include_null_always_with_empty_alias' => ['/filtered_date_parameters?date_include_null_always=', 4]; + yield 'date_alias_old_way_with_null_alias' => ['/filtered_date_parameters?date_old_way=null', 4]; + yield 'date__alias_old_way_with_empty_alias' => ['/filtered_date_parameters?date_old_way=', 4]; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(string $entityClass): void + { + $manager = $this->getManager(); + + $dates = [ + new \DateTimeImmutable('2024-01-01'), + new \DateTimeImmutable('2024-06-15'), + new \DateTimeImmutable('2024-12-25'), + null, + ]; + + foreach ($dates as $createdAtValue) { + $entity = new $entityClass(createdAt: $createdAtValue); + $manager->persist($entity); + } + + $manager->flush(); + } +}