From 5fc56f5b5832b3a6d60d576ae04f5a54ecf00410 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 22 Jul 2026 16:55:36 +0200 Subject: [PATCH 1/2] feat(doctrine): add ChainFilter to compose filters on one parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChainFilter applies each wrapped filter in turn so a single query-parameter key can accept several value shapes — e.g. equality plus comparison operators — without a bespoke decorator. Each primary self-selects by value shape (ComparisonFilter ignores scalars; ExactFilter/PartialSearchFilter ignore operator-map arrays), so the composite applies all of them and merges their OpenAPI parameters. ManagerRegistry/Logger are forwarded to aware inner filters, mirroring FreeTextQueryFilter. Also adds the associative-array guard on ExactFilter/PartialSearchFilter, which is load-bearing for the chain (same guard as #8415 on 4.3; will reconcile on merge-up). --- src/Doctrine/Orm/Filter/ChainFilter.php | 69 +++++++++++++ src/Doctrine/Orm/Filter/ExactFilter.php | 5 + .../Orm/Filter/PartialSearchFilter.php | 5 + .../Entity/ChainFilterParameter.php | 50 ++++++++++ .../Functional/Parameters/ChainFilterTest.php | 98 +++++++++++++++++++ 5 files changed, 227 insertions(+) create mode 100644 src/Doctrine/Orm/Filter/ChainFilter.php create mode 100644 tests/Fixtures/TestBundle/Entity/ChainFilterParameter.php create mode 100644 tests/Functional/Parameters/ChainFilterTest.php diff --git a/src/Doctrine/Orm/Filter/ChainFilter.php b/src/Doctrine/Orm/Filter/ChainFilter.php new file mode 100644 index 00000000000..405f04cd4b5 --- /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 bd45dbf5d20..ea7cd5e5566 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 2b78b5350f5..ac6fd903fc7 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/Entity/ChainFilterParameter.php b/tests/Fixtures/TestBundle/Entity/ChainFilterParameter.php new file mode 100644 index 00000000000..42c42d9955e --- /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/ChainFilterTest.php b/tests/Functional/Parameters/ChainFilterTest.php new file mode 100644 index 00000000000..c68aa900da3 --- /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(); + } +} From d6400f4812c29fc3eb98beb2ad85abae01f50fa9 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 26 Jul 2026 09:37:25 +0200 Subject: [PATCH 2/2] feat(doctrine): add ODM ChainFilter and guard ODM Exact/PartialSearch filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the ORM ChainFilter for MongoDB ODM. Add the operator-map guard to the ODM ExactFilter and PartialSearchFilter so they self-select by value shape inside a ChainFilter — an associative operator-map array belongs to ComparisonFilter/DateFilter, not equality. --- src/Doctrine/Odm/Filter/ChainFilter.php | 71 ++++++++++++++ src/Doctrine/Odm/Filter/ExactFilter.php | 7 +- .../Odm/Filter/PartialSearchFilter.php | 6 ++ .../Document/ChainFilterParameter.php | 52 ++++++++++ .../Parameters/ChainFilterMongoDbTest.php | 98 +++++++++++++++++++ 5 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 src/Doctrine/Odm/Filter/ChainFilter.php create mode 100644 tests/Fixtures/TestBundle/Document/ChainFilterParameter.php create mode 100644 tests/Functional/Parameters/ChainFilterMongoDbTest.php diff --git a/src/Doctrine/Odm/Filter/ChainFilter.php b/src/Doctrine/Odm/Filter/ChainFilter.php new file mode 100644 index 00000000000..d6f28a20660 --- /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 399843a582d..16c56cfb38e 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 392a2ab6ab9..2b1b8c69957 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/tests/Fixtures/TestBundle/Document/ChainFilterParameter.php b/tests/Fixtures/TestBundle/Document/ChainFilterParameter.php new file mode 100644 index 00000000000..96ebf9ea509 --- /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/Functional/Parameters/ChainFilterMongoDbTest.php b/tests/Functional/Parameters/ChainFilterMongoDbTest.php new file mode 100644 index 00000000000..f3fd5c6c851 --- /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(); + } +}