diff --git a/src/Doctrine/Odm/Extension/ParameterExtension.php b/src/Doctrine/Odm/Extension/ParameterExtension.php index 0191871c07..077f9f97dd 100644 --- a/src/Doctrine/Odm/Extension/ParameterExtension.php +++ b/src/Doctrine/Odm/Extension/ParameterExtension.php @@ -14,11 +14,15 @@ namespace ApiPlatform\Doctrine\Odm\Extension; 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,8 +33,10 @@ 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, + ) { } private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass = null, ?Operation $operation = null, array &$context = []): void @@ -45,7 +51,8 @@ private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass continue; } - $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; + $filter = $this->getFilter($filterId); + if ($filter instanceof FilterInterface) { $filterContext = ['filters' => $values, 'parameter' => $parameter]; $filter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext); @@ -72,4 +79,26 @@ public function applyToItem(Builder $aggregationBuilder, string $resourceClass, { $this->applyFilter($aggregationBuilder, $resourceClass, $operation, $context); } + + /** + * @param $values array + * + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function getFilter(AbstractFilter|string|null $filterId, array $values = []): ?FilterInterface + { + if ($filterId instanceof AbstractFilter) { + $filterId->setManagerRegistry($this->managerRegistry); + $filterId->setProperties($values); + + return $filterId; + } + + if (\is_string($filterId) && $this->filterLocator->has($filterId)) { + return $this->filterLocator->get($filterId); + } + + return null; + } } diff --git a/src/Doctrine/Odm/Filter/AbstractFilter.php b/src/Doctrine/Odm/Filter/AbstractFilter.php index 87c30390c3..ac5f13eaf7 100644 --- a/src/Doctrine/Odm/Filter/AbstractFilter.php +++ b/src/Doctrine/Odm/Filter/AbstractFilter.php @@ -36,8 +36,12 @@ abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInt 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,11 +60,16 @@ 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 + protected function getManagerRegistry(): ?ManagerRegistry { return $this->managerRegistry; } + public function setManagerRegistry(?ManagerRegistry $managerRegistry): ?ManagerRegistry + { + return $this->managerRegistry = $managerRegistry; + } + protected function getProperties(): ?array { return $this->properties; diff --git a/src/Doctrine/Odm/PropertyHelperTrait.php b/src/Doctrine/Odm/PropertyHelperTrait.php index e1c7693f2b..285a5256bd 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. @@ -41,7 +41,7 @@ protected function getClassMetadata(string $resourceClass): ClassMetadata { $manager = $this ->getManagerRegistry() - ->getManagerForClass($resourceClass); + ?->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 aa65efbe57..548665d65d 100644 --- a/src/Doctrine/Orm/Extension/ParameterExtension.php +++ b/src/Doctrine/Orm/Extension/ParameterExtension.php @@ -14,12 +14,16 @@ 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\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 +34,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,9 +58,12 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter continue; } - $filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null; + $filter = $this->getFilter($filterId, $values); + if ($filter instanceof FilterInterface) { - $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $values, 'parameter' => $parameter] + $context); + $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, + ['filters' => $values, 'parameter' => $parameter] + $context + ); } } } @@ -71,4 +83,26 @@ public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterf { $this->applyFilter($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); } + + /** + * @param $values array + * + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + private function getFilter(AbstractFilter|string|null $filterId, array $values = []): ?FilterInterface + { + if ($filterId instanceof AbstractFilter) { + $filterId->setManagerRegistry($this->managerRegistry); + $filterId->setProperties($values); + + return $filterId; + } + + if (\is_string($filterId) && $this->filterLocator->has($filterId)) { + return $this->filterLocator->get($filterId); + } + + return null; + } } diff --git a/src/Doctrine/Orm/Filter/AbstractFilter.php b/src/Doctrine/Orm/Filter/AbstractFilter.php index 4ec704638a..4a223c0db7 100644 --- a/src/Doctrine/Orm/Filter/AbstractFilter.php +++ b/src/Doctrine/Orm/Filter/AbstractFilter.php @@ -30,8 +30,12 @@ abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInt 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,19 +57,19 @@ 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 + protected function getManagerRegistry(): ?ManagerRegistry { return $this->managerRegistry; } - protected function getProperties(): ?array + public function setManagerRegistry(?ManagerRegistry $managerRegistry): ?ManagerRegistry { - return $this->properties; + return $this->managerRegistry = $managerRegistry; } - protected function getLogger(): LoggerInterface + public function getProperties(): ?array { - return $this->logger; + return $this->properties; } /** @@ -76,6 +80,11 @@ 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 e9f0a8373e..f0aa60804b 100644 --- a/src/Doctrine/Orm/Filter/BooleanFilter.php +++ b/src/Doctrine/Orm/Filter/BooleanFilter.php @@ -15,7 +15,12 @@ use ApiPlatform\Doctrine\Common\Filter\BooleanFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +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\Types; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; @@ -106,7 +111,7 @@ * @author Amrouche Hamza * @author Teoh Han Hui */ -final class BooleanFilter extends AbstractFilter +final class BooleanFilter extends AbstractFilter implements OpenApiParameterFilterInterface, JsonSchemaFilterInterface { use BooleanFilterTrait; @@ -145,4 +150,27 @@ 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 ['type' => 'boolean']; + } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + $in = $parameter instanceof QueryParameter ? 'query' : 'header'; + $key = $parameter->getKey(); + + return [ + new OpenApiParameter( + name: $key, + in: $in, + required: false, + schema: ['type' => 'boolean'], + ), + ]; + } } diff --git a/src/Doctrine/Orm/PropertyHelperTrait.php b/src/Doctrine/Orm/PropertyHelperTrait.php index 8431e3e168..670f7a71ec 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. @@ -43,7 +43,7 @@ protected function getClassMetadata(string $resourceClass): ClassMetadata { $manager = $this ->getManagerRegistry() - ->getManagerForClass($resourceClass); + ?->getManagerForClass($resourceClass); if ($manager) { return $manager->getClassMetadata($resourceClass); diff --git a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml index e4206ea097..d6485bf1d1 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 d6b3b1ffee..6e00d88882 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/tests/Fixtures/TestBundle/Document/FilteredBooleanParameter.php b/tests/Fixtures/TestBundle/Document/FilteredBooleanParameter.php new file mode 100644 index 0000000000..efa2428f86 --- /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 0000000000..6ba24650a7 --- /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 0000000000..55e37e6000 --- /dev/null +++ b/tests/Functional/Parameters/BooleanFilterTest.php @@ -0,0 +1,187 @@ + + * + * 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 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; + + /** @var string */ + private const ROUTE = '/filtered_boolean_parameters'; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FilteredBooleanParameter::class]; + } + + /** + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws TransportExceptionInterface + */ + public function testBooleanFilterWithValidValue(): void + { + $resource = $this->isMongoDB() ? FilteredBooleanParameterDocument::class : FilteredBooleanParameter::class; + $this->recreateSchema([$resource]); + $this->loadFixtures($resource); + + $this->assertBooleanFilterResponse('true', 2); + $this->assertBooleanFilterResponse('false', 1); + $this->assertBooleanFilterResponse('1', 2); + $this->assertBooleanFilterResponse('0', 1); + } + + /** + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + */ + public function testBooleanFilterWithInvalidValue(): void + { + $this->assertBooleanFilterResponse('null', 3); // <=> ignored + $this->assertValidationErrorResponse(''); + } + + /** + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws TransportExceptionInterface + */ + public function testBooleanFilterAliasWithValidValue(): void + { + $resource = $this->isMongoDB() ? FilteredBooleanParameterDocument::class : FilteredBooleanParameter::class; + $this->recreateSchema([$resource]); + $this->loadFixtures($resource); + + $this->assertBooleanFilterResponse('true', 2, 'enabled'); + $this->assertBooleanFilterResponse('false', 1, 'enabled'); + $this->assertBooleanFilterResponse('1', 2, 'enabled'); + $this->assertBooleanFilterResponse('0', 1, 'enabled'); + } + + /** + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + */ + public function testBooleanFilterAliasWithInvalidValue(): void + { + $this->assertBooleanFilterResponse('null', 3, 'enabled'); // <=> ignored + $this->assertValidationErrorResponse('', 'enabled'); + } + + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + */ + private function assertBooleanFilterResponse(?string $activeValue, int $expectedCount, string $param = 'active'): void + { + $route = $this->getRoute($activeValue, $param); + $response = self::createClient()->request('GET', $route); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $entities = $data['hydra:member']; + + $this->assertCount($expectedCount, $entities, \sprintf('The number of items with %s=%s should be %d', $param, $activeValue, $expectedCount)); + + if ('null' !== $activeValue) { + foreach ($entities as $entity) { + $isActiveExpected = \in_array($activeValue, ['true', '1'], true); + $expectedValue = $isActiveExpected ? 'true' : 'false'; + $message = \sprintf("Expected 'active' to be %s", $expectedValue); + + $this->assertSame($isActiveExpected, $entity['active'], $message); + } + } + } + + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + */ + private function assertValidationErrorResponse(string $activeValue, string $param = 'active'): void + { + $route = $this->getRoute($activeValue, $param); + $response = self::createClient()->request('GET', $route); + + $this->assertEquals(422, $response->getStatusCode(), 'Expected status code 422 for validation error.'); + $this->assertJsonContains([ + '@context' => '/contexts/ConstraintViolationList', + '@id' => '/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3', + '@type' => 'ConstraintViolationList', + 'status' => 422, + 'violations' => [ + 0 => [ + 'propertyPath' => 'active', + 'message' => 'This value should not be blank.', + 'code' => 'c1051bb4-d103-4f74-8988-acbcafc7fdc3', + ], + ], + 'detail' => 'active: This value should not be blank.', + 'description' => 'active: This value should not be blank.', + 'type' => '/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3', + 'title' => 'An error occurred', + 'hydra:description' => 'active: This value should not be blank.', + 'hydra:title' => 'An error occurred', + ]); + } + + 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(); + } + + private function getRoute(?string $value, string $param = 'active'): string + { + return \sprintf('%s?%s=%s', self::ROUTE, $param, $value); + } +}