diff --git a/src/Doctrine/Odm/Filter/ChainFilter.php b/src/Doctrine/Odm/Filter/ChainFilter.php new file mode 100644 index 0000000000..d6f28a2066 --- /dev/null +++ b/src/Doctrine/Odm/Filter/ChainFilter.php @@ -0,0 +1,71 @@ + + * + * 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 ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use Doctrine\ODM\MongoDB\Aggregation\Builder; + +/** + * Applies several filters to a single parameter; each filter self-selects by the value shape it supports. + */ +final class ChainFilter implements FilterInterface, OpenApiParameterFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use LoggerAwareTrait; + use ManagerRegistryAwareTrait; + + /** + * @param iterable $filters + */ + public function __construct(private readonly iterable $filters) + { + } + + /** + * @param-out array $context + */ + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + foreach ($this->filters as $filter) { + if ($filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry() && $this->hasManagerRegistry()) { + $filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($filter instanceof LoggerAwareInterface && !$filter->hasLogger() && $this->hasLogger()) { + $filter->setLogger($this->getLogger()); + } + + $filter->apply($aggregationBuilder, $resourceClass, $operation, $context); + } + } + + public function getOpenApiParameters(Parameter $parameter): array + { + $parameters = []; + foreach ($this->filters as $filter) { + if ($filter instanceof OpenApiParameterFilterInterface) { + $parameters = [...$parameters, ...(array) $filter->getOpenApiParameters($parameter)]; + } + } + + return $parameters; + } +} diff --git a/src/Doctrine/Odm/Filter/ExactFilter.php b/src/Doctrine/Odm/Filter/ExactFilter.php index 399843a582..16c56cfb38 100644 --- a/src/Doctrine/Odm/Filter/ExactFilter.php +++ b/src/Doctrine/Odm/Filter/ExactFilter.php @@ -43,13 +43,18 @@ final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterf public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { $parameter = $context['parameter']; + $value = $parameter->getValue(); + + if (\is_array($value) && !array_is_list($value)) { + // associative arrays are operator-maps owned by ComparisonFilter/DateFilter, not equality + return; + } if (null === $parameter->getProperty()) { throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); } $property = $parameter->getProperty(); - $value = $parameter->getValue(); $operator = $context['operator'] ?? 'addAnd'; $match = $context['match'] = $context['match'] ?? $aggregationBuilder diff --git a/src/Doctrine/Odm/Filter/PartialSearchFilter.php b/src/Doctrine/Odm/Filter/PartialSearchFilter.php index 392a2ab6ab..2b1b8c6995 100644 --- a/src/Doctrine/Odm/Filter/PartialSearchFilter.php +++ b/src/Doctrine/Odm/Filter/PartialSearchFilter.php @@ -45,6 +45,12 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera $property = $parameter->getProperty(); $values = $parameter->getValue(); + + if (\is_array($values) && !array_is_list($values)) { + // associative arrays are operator-maps owned by ComparisonFilter/DateFilter, not equality + return; + } + $match = $context['match'] = $context['match'] ?? $aggregationBuilder ->matchExpr(); diff --git a/src/Doctrine/Orm/Filter/ChainFilter.php b/src/Doctrine/Orm/Filter/ChainFilter.php new file mode 100644 index 0000000000..405f04cd4b --- /dev/null +++ b/src/Doctrine/Orm/Filter/ChainFilter.php @@ -0,0 +1,69 @@ + + * + * 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 ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use Doctrine\ORM\QueryBuilder; + +/** + * Applies several filters to a single parameter; each filter self-selects by the value shape it supports. + */ +final class ChainFilter implements FilterInterface, OpenApiParameterFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use LoggerAwareTrait; + use ManagerRegistryAwareTrait; + + /** + * @param iterable $filters + */ + public function __construct(private readonly iterable $filters) + { + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + foreach ($this->filters as $filter) { + if ($filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry() && $this->hasManagerRegistry()) { + $filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($filter instanceof LoggerAwareInterface && !$filter->hasLogger() && $this->hasLogger()) { + $filter->setLogger($this->getLogger()); + } + + $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + } + } + + public function getOpenApiParameters(Parameter $parameter): array + { + $parameters = []; + foreach ($this->filters as $filter) { + if ($filter instanceof OpenApiParameterFilterInterface) { + $parameters = [...$parameters, ...(array) $filter->getOpenApiParameters($parameter)]; + } + } + + return $parameters; + } +} diff --git a/src/Doctrine/Orm/Filter/ExactFilter.php b/src/Doctrine/Orm/Filter/ExactFilter.php index bd45dbf5d2..ea7cd5e556 100644 --- a/src/Doctrine/Orm/Filter/ExactFilter.php +++ b/src/Doctrine/Orm/Filter/ExactFilter.php @@ -36,6 +36,11 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $parameter = $context['parameter']; $value = $parameter->getValue(); + if (\is_array($value) && !array_is_list($value)) { + // associative arrays are operator-maps owned by ComparisonFilter/DateFilter, not equality + return; + } + if (null === $parameter->getProperty()) { throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); } diff --git a/src/Doctrine/Orm/Filter/PartialSearchFilter.php b/src/Doctrine/Orm/Filter/PartialSearchFilter.php index 2b78b5350f..ac6fd903fc 100644 --- a/src/Doctrine/Orm/Filter/PartialSearchFilter.php +++ b/src/Doctrine/Orm/Filter/PartialSearchFilter.php @@ -49,6 +49,11 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $field = $alias.'.'.$property; $values = $parameter->getValue(); + if (\is_array($values) && !array_is_list($values)) { + // associative arrays are operator-maps owned by ComparisonFilter/DateFilter, not equality + return; + } + if (!is_iterable($values)) { $parameterName = $queryNameGenerator->generateParameterName($property); $queryBuilder->setParameter($parameterName, $this->formatLikeValue($values)); diff --git a/tests/Fixtures/TestBundle/Document/ChainFilterParameter.php b/tests/Fixtures/TestBundle/Document/ChainFilterParameter.php new file mode 100644 index 0000000000..96ebf9ea50 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/ChainFilterParameter.php @@ -0,0 +1,52 @@ + + * + * 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\ChainFilter; +use ApiPlatform\Doctrine\Odm\Filter\ComparisonFilter; +use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +#[ApiResource] +#[GetCollection( + paginationEnabled: false, + parameters: [ + 'quantity' => new QueryParameter( + filter: new ChainFilter([ + new ExactFilter(), + new ComparisonFilter(new ExactFilter()), + ]), + property: 'quantity', + nativeType: new BuiltinType(TypeIdentifier::INT), + castToNativeType: true, + ), + ], +)] +#[ODM\Document] +class ChainFilterParameter +{ + public function __construct( + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + public ?int $id = null, + + #[ODM\Field(type: 'int')] + public ?int $quantity = null, + ) { + } +} diff --git a/tests/Fixtures/TestBundle/Entity/ChainFilterParameter.php b/tests/Fixtures/TestBundle/Entity/ChainFilterParameter.php new file mode 100644 index 0000000000..42c42d9955 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/ChainFilterParameter.php @@ -0,0 +1,50 @@ + + * + * 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\ChainFilter; +use ApiPlatform\Doctrine\Orm\Filter\ComparisonFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource] +#[GetCollection( + paginationEnabled: false, + parameters: [ + 'quantity' => new QueryParameter( + filter: new ChainFilter([ + new ExactFilter(), + new ComparisonFilter(new ExactFilter()), + ]), + property: 'quantity', + ), + ], +)] +#[ORM\Entity] +class ChainFilterParameter +{ + public function __construct( + #[ORM\Column] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null, + + #[ORM\Column] + public ?int $quantity = null, + ) { + } +} diff --git a/tests/Functional/Parameters/ChainFilterMongoDbTest.php b/tests/Functional/Parameters/ChainFilterMongoDbTest.php new file mode 100644 index 0000000000..f3fd5c6c85 --- /dev/null +++ b/tests/Functional/Parameters/ChainFilterMongoDbTest.php @@ -0,0 +1,98 @@ + + * + * 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\ChainFilterParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ChainFilterMongoDbTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ChainFilterParameter::class]; + } + + protected function setUp(): void + { + if (!$this->isMongoDB()) { + $this->markTestSkipped('Only tested with mongodb.'); + } + + $this->recreateSchema([ChainFilterParameter::class]); + $this->loadFixtures(); + } + + public function testExactMatch(): void + { + $response = self::createClient()->request('GET', '/chain_filter_parameters?quantity=10'); + $this->assertResponseIsSuccessful(); + $quantities = array_map(static fn ($m) => $m['quantity'], $response->toArray()['hydra:member']); + $this->assertSame([10], $quantities); + } + + public function testLessThan(): void + { + $response = self::createClient()->request('GET', '/chain_filter_parameters?quantity[lt]=10'); + $this->assertResponseIsSuccessful(); + $quantities = array_map(static fn ($m) => $m['quantity'], $response->toArray()['hydra:member']); + sort($quantities); + $this->assertSame([5], $quantities); + } + + public function testGreaterThanOrEqual(): void + { + $response = self::createClient()->request('GET', '/chain_filter_parameters?quantity[gte]=10'); + $this->assertResponseIsSuccessful(); + $quantities = array_map(static fn ($m) => $m['quantity'], $response->toArray()['hydra:member']); + sort($quantities); + $this->assertSame([10, 15], $quantities); + } + + public function testOpenApiParametersMergeBothFilters(): void + { + $response = self::createClient()->request('GET', '/docs', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + $this->assertResponseIsSuccessful(); + $openApiDoc = $response->toArray(); + + $parameters = $openApiDoc['paths']['/chain_filter_parameters']['get']['parameters']; + $parameterNames = array_column($parameters, 'name'); + + foreach (['quantity', 'quantity[gt]', 'quantity[gte]', 'quantity[lt]', 'quantity[lte]', 'quantity[ne]'] as $expectedName) { + $this->assertContains($expectedName, $parameterNames, \sprintf('Expected parameter "%s" in OpenAPI documentation', $expectedName)); + } + } + + private function loadFixtures(): void + { + $manager = $this->getManager(); + + foreach ([5, 10, 15] as $quantity) { + $manager->persist(new ChainFilterParameter(quantity: $quantity)); + } + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/ChainFilterTest.php b/tests/Functional/Parameters/ChainFilterTest.php new file mode 100644 index 0000000000..c68aa900da --- /dev/null +++ b/tests/Functional/Parameters/ChainFilterTest.php @@ -0,0 +1,98 @@ + + * + * 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\Entity\ChainFilterParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ChainFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ChainFilterParameter::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Not tested with mongodb.'); + } + + $this->recreateSchema([ChainFilterParameter::class]); + $this->loadFixtures(); + } + + public function testExactMatch(): void + { + $response = self::createClient()->request('GET', '/chain_filter_parameters?quantity=10'); + $this->assertResponseIsSuccessful(); + $quantities = array_map(static fn ($m) => $m['quantity'], $response->toArray()['hydra:member']); + $this->assertSame([10], $quantities); + } + + public function testLessThan(): void + { + $response = self::createClient()->request('GET', '/chain_filter_parameters?quantity[lt]=10'); + $this->assertResponseIsSuccessful(); + $quantities = array_map(static fn ($m) => $m['quantity'], $response->toArray()['hydra:member']); + sort($quantities); + $this->assertSame([5], $quantities); + } + + public function testGreaterThanOrEqual(): void + { + $response = self::createClient()->request('GET', '/chain_filter_parameters?quantity[gte]=10'); + $this->assertResponseIsSuccessful(); + $quantities = array_map(static fn ($m) => $m['quantity'], $response->toArray()['hydra:member']); + sort($quantities); + $this->assertSame([10, 15], $quantities); + } + + public function testOpenApiParametersMergeBothFilters(): void + { + $response = self::createClient()->request('GET', '/docs', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + $this->assertResponseIsSuccessful(); + $openApiDoc = $response->toArray(); + + $parameters = $openApiDoc['paths']['/chain_filter_parameters']['get']['parameters']; + $parameterNames = array_column($parameters, 'name'); + + foreach (['quantity', 'quantity[gt]', 'quantity[gte]', 'quantity[lt]', 'quantity[lte]', 'quantity[ne]'] as $expectedName) { + $this->assertContains($expectedName, $parameterNames, \sprintf('Expected parameter "%s" in OpenAPI documentation', $expectedName)); + } + } + + private function loadFixtures(): void + { + $manager = $this->getManager(); + + foreach ([5, 10, 15] as $quantity) { + $manager->persist(new ChainFilterParameter(quantity: $quantity)); + } + + $manager->flush(); + } +}