From 13c15dd2f00b9f73d6c88dba7fa5285158045e3a Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Wed, 23 Oct 2024 17:15:02 +0200 Subject: [PATCH] feat(doctrine): boolean filter like laravel filters --- .../Common/Filter/BooleanFilterTrait.php | 2 +- .../Odm/Extension/ParameterExtension.php | 48 +++++-- src/Doctrine/Odm/Filter/AbstractFilter.php | 35 ++++- src/Doctrine/Odm/Filter/BooleanFilter.php | 12 +- .../Filter/ManagerRegistryAwareInterface.php | 25 ++++ src/Doctrine/Odm/PropertyHelperTrait.php | 8 +- .../Orm/Extension/ParameterExtension.php | 39 ++++- src/Doctrine/Orm/Filter/AbstractFilter.php | 36 +++-- src/Doctrine/Orm/Filter/BooleanFilter.php | 12 +- .../Filter/ManagerRegistryAwareInterface.php | 25 ++++ src/Doctrine/Orm/PropertyHelperTrait.php | 2 +- ...meterResourceMetadataCollectionFactory.php | 41 ++++-- .../Resources/config/doctrine_mongodb_odm.xml | 2 +- .../Bundle/Resources/config/doctrine_orm.xml | 1 + .../Resources/config/metadata/resource.xml | 1 + .../Document/FilteredBooleanParameter.php | 60 ++++++++ .../Entity/FilteredBooleanParameter.php | 62 ++++++++ .../Parameters/BooleanFilterTest.php | 135 ++++++++++++++++++ 18 files changed, 493 insertions(+), 53 deletions(-) create mode 100644 src/Doctrine/Odm/Filter/ManagerRegistryAwareInterface.php create mode 100644 src/Doctrine/Orm/Filter/ManagerRegistryAwareInterface.php create mode 100644 tests/Fixtures/TestBundle/Document/FilteredBooleanParameter.php create mode 100644 tests/Fixtures/TestBundle/Entity/FilteredBooleanParameter.php create mode 100644 tests/Functional/Parameters/BooleanFilterTest.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/Odm/Extension/ParameterExtension.php b/src/Doctrine/Odm/Extension/ParameterExtension.php index 0191871c07f..bbe7b2f1835 100644 --- a/src/Doctrine/Odm/Extension/ParameterExtension.php +++ b/src/Doctrine/Odm/Extension/ParameterExtension.php @@ -14,11 +14,16 @@ namespace ApiPlatform\Doctrine\Odm\Extension; use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; +use ApiPlatform\Doctrine\Odm\Filter\AbstractFilter; use ApiPlatform\Doctrine\Odm\Filter\FilterInterface; +use ApiPlatform\Doctrine\Odm\Filter\ManagerRegistryAwareInterface; 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,29 @@ 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 AbstractFilter => $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->getProperties()) { + $filter->setProperties([$parameter->getProperty() ?? $parameter->getKey() => []]); + } + + $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..27798bf72b9 100644 --- a/src/Doctrine/Odm/Filter/AbstractFilter.php +++ b/src/Doctrine/Odm/Filter/AbstractFilter.php @@ -17,8 +17,8 @@ use ApiPlatform\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Doctrine\Odm\PropertyHelperTrait as MongoDbOdmPropertyHelperTrait; use ApiPlatform\Metadata\Operation; +use Doctrine\Bundle\MongoDBBundle\ManagerRegistry; use Doctrine\ODM\MongoDB\Aggregation\Builder; -use Doctrine\Persistence\ManagerRegistry; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -30,14 +30,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 +60,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/ManagerRegistryAwareInterface.php b/src/Doctrine/Odm/Filter/ManagerRegistryAwareInterface.php new file mode 100644 index 00000000000..80bdd2485d0 --- /dev/null +++ b/src/Doctrine/Odm/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\Odm\Filter; + +use Doctrine\Bundle\MongoDBBundle\ManagerRegistry; + +interface ManagerRegistryAwareInterface +{ + public function hasManagerRegistry(): bool; + + public function getManagerRegistry(): ManagerRegistry; + + public function setManagerRegistry(?ManagerRegistry $managerRegistry): void; +} 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 aa65efbe577..236bb67797d 100644 --- a/src/Doctrine/Orm/Extension/ParameterExtension.php +++ b/src/Doctrine/Orm/Extension/ParameterExtension.php @@ -14,12 +14,17 @@ namespace ApiPlatform\Doctrine\Orm\Extension; use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; +use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; +use ApiPlatform\Doctrine\Orm\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; 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. @@ -30,17 +35,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; } @@ -49,10 +59,27 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter continue; } - $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; - if ($filter instanceof FilterInterface) { - $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $values, 'parameter' => $parameter] + $context); + $filter = match (true) { + $filterId instanceof AbstractFilter => $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->getProperties()) { + $filter->setProperties([$parameter->getProperty() ?? $parameter->getKey() => []]); + } + + $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..6f8e1415d7e 100644 --- a/src/Doctrine/Orm/Filter/AbstractFilter.php +++ b/src/Doctrine/Orm/Filter/AbstractFilter.php @@ -24,14 +24,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 +57,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/ManagerRegistryAwareInterface.php b/src/Doctrine/Orm/Filter/ManagerRegistryAwareInterface.php new file mode 100644 index 00000000000..f700925964d --- /dev/null +++ b/src/Doctrine/Orm/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\Orm\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/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/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 9e13fb2121a..4fbab49acb7 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, @@ -188,16 +190,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); - } - - if (null === $parameter->getProperty() && ($property = $description[$key]['property'] ?? null)) { - $parameter = $parameter->withProperty($property); - } - $currentKey = $key; if (null === $parameter->getProperty() && isset($properties[$key])) { $parameter = $parameter->withProperty($key); @@ -212,6 +204,33 @@ 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); } @@ -220,7 +239,7 @@ private function setDefaults(string $key, Parameter $parameter, string $resource $parameter = $parameter->withOpenApi($openApi); } - return $this->addFilterMetadata($parameter); + return $parameter; } private function getFilterClass(Operation $operation): ?string 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/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/Functional/Parameters/BooleanFilterTest.php b/tests/Functional/Parameters/BooleanFilterTest.php new file mode 100644 index 00000000000..28bdb4454e2 --- /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 + { + $resource = $this->isMongoDB() ? FilteredBooleanParameterDocument::class : FilteredBooleanParameter::class; + + $this->recreateSchema([$resource]); + $this->loadFixtures($resource); + } + + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + */ + #[DataProvider('booleanFilterTrueFalseValuesProvider')] + public function testBooleanFilter(string $url, int $expectedCount): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $entities = $data['hydra:member']; + + $this->assertCount($expectedCount, $entities, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + + $expectedValue = str_contains($url, '=true') || str_contains($url, '=1'); + foreach ($entities as $entity) { + $errorMessage = \sprintf("Expected 'active' to be %s", $expectedValue ? 'true' : 'false'); + $this->assertSame($expectedValue, $entity['active'], $errorMessage); + } + } + + public static function booleanFilterTrueFalseValuesProvider(): \Generator + { + yield 'active_true' => ['/filtered_boolean_parameters?active=true', 2]; + yield 'active_false' => ['/filtered_boolean_parameters?active=false', 1]; + yield 'active_1' => ['/filtered_boolean_parameters?active=1', 2]; + yield 'active_0' => ['/filtered_boolean_parameters?active=0', 1]; + yield 'enabled_true' => ['/filtered_boolean_parameters?enabled=true', 2]; + yield 'enabled_false' => ['/filtered_boolean_parameters?enabled=false', 1]; + yield 'enabled_1' => ['/filtered_boolean_parameters?enabled=1', 2]; + yield 'enabled_0' => ['/filtered_boolean_parameters?enabled=0', 1]; + } + + /** + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + */ + #[DataProvider('nullableAndEmptyBooleanFilterProvider')] + public function testBooleanFilterWithNullAndEmptyValues(string $url): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $entities = $data['hydra:member']; + + $expectedCount = 3; + $this->assertCount($expectedCount, $entities, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + } + + public static function nullableAndEmptyBooleanFilterProvider(): \Generator + { + yield 'null_value' => ['/filtered_boolean_parameters?active=null']; + yield 'null_value_alias' => ['/filtered_boolean_parameters?enabled=null']; + yield 'active_empty' => ['/filtered_boolean_parameters?active=', 3]; + yield 'enabled_empty' => ['/filtered_boolean_parameters?enabled=', 3]; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(string $resource): void + { + $manager = $this->getManager(); + + $entitiesData = [true, true, false, null]; + foreach ($entitiesData as $activeValue) { + $entity = new $resource(active: $activeValue); + $manager->persist($entity); + } + + $manager->flush(); + } +}