From c69c666b5c9d9d2de767089fb7dab932cee0a470 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9=20Schopmeijer?= Date: Mon, 1 Dec 2025 13:43:24 +0100 Subject: [PATCH 1/3] fix: fix PartialSearchFilter Fixes #7478 --- .../Orm/Filter/PartialSearchFilter.php | 76 ++++++++++++------- .../Entity/SearchFilterParameter.php | 5 ++ tests/Functional/Parameters/DoctrineTest.php | 16 +++- 3 files changed, 67 insertions(+), 30 deletions(-) diff --git a/src/Doctrine/Orm/Filter/PartialSearchFilter.php b/src/Doctrine/Orm/Filter/PartialSearchFilter.php index 90cde75c3fa..3ceb766cd78 100644 --- a/src/Doctrine/Orm/Filter/PartialSearchFilter.php +++ b/src/Doctrine/Orm/Filter/PartialSearchFilter.php @@ -20,46 +20,64 @@ use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; +use function is_iterable; +use function strtolower; + /** * @author Vincent Amstoutz + * @author Ré Schopmeijer */ final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilterInterface { use BackwardCompatibleFilterDescriptionTrait; use OpenApiFilterTrait; - public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void - { - $parameter = $context['parameter']; - $property = $parameter->getProperty(); - $alias = $queryBuilder->getRootAliases()[0]; - $field = $alias.'.'.$property; - $parameterName = $queryNameGenerator->generateParameterName($property); - $values = $parameter->getValue(); - - if (!is_iterable($values)) { - $queryBuilder->setParameter($parameterName, '%'.strtolower($values).'%'); + public function apply( + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + ?Operation $operation = null, + array $context = [], + ): void { + $parameter = $context['parameter']; + $alias = $queryBuilder->getRootAliases()[0]; + $properties = $parameter->getProperties() ?? [$parameter->getProperty()]; + foreach ($properties as $property) { + $field = $alias . '.' . $property; + $parameterName = $queryNameGenerator->generateParameterName($property); + $values = $parameter->getValue(); - $queryBuilder->{$context['whereClause'] ?? 'andWhere'}($queryBuilder->expr()->like( - 'LOWER('.$field.')', - ':'.$parameterName - )); + if (!is_iterable($values)) { + $queryBuilder->setParameter($parameterName, '%' . strtolower($values) . '%'); - return; - } + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( + $queryBuilder + ->expr() + ->like( + 'LOWER(' . $field . ')', + ':' . $parameterName, + ), + ); + } else { + $likeExpressions = []; + foreach ($values as $val) { + $parameterName = $queryNameGenerator->generateParameterName($property); + $likeExpressions[] = $queryBuilder + ->expr() + ->like( + 'LOWER(' . $field . ')', + ':' . $parameterName, + ) + ; + $queryBuilder->setParameter($parameterName, '%' . strtolower($val) . '%'); + } - $likeExpressions = []; - foreach ($values as $val) { - $parameterName = $queryNameGenerator->generateParameterName($property); - $likeExpressions[] = $queryBuilder->expr()->like( - 'LOWER('.$field.')', - ':'.$parameterName - ); - $queryBuilder->setParameter($parameterName, '%'.strtolower($val).'%'); + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( + $queryBuilder + ->expr() + ->orX(...$likeExpressions), + ); + } } - - $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( - $queryBuilder->expr()->orX(...$likeExpressions) - ); } } diff --git a/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php b/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php index 533bb7a14b7..72651074d71 100644 --- a/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php +++ b/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; +use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; @@ -34,6 +35,10 @@ 'searchPartial[:property]' => new QueryParameter(filter: 'app_search_filter_partial'), 'searchExact[:property]' => new QueryParameter(filter: 'app_search_filter_with_exact'), 'searchOnTextAndDate[:property]' => new QueryParameter(filter: 'app_filter_date_and_search'), + 'searchParameter[:property]' => new QueryParameter( + filter : new PartialSearchFilter(), + properties: ['foo'] + ), 'q' => new QueryParameter(property: 'hydra:freetextQuery'), ] )] diff --git a/tests/Functional/Parameters/DoctrineTest.php b/tests/Functional/Parameters/DoctrineTest.php index cf825a61676..ef6afd10d3b 100644 --- a/tests/Functional/Parameters/DoctrineTest.php +++ b/tests/Functional/Parameters/DoctrineTest.php @@ -49,7 +49,7 @@ public function testDoctrineEntitySearchFilter(): void $this->assertEquals('bar', $a['hydra:member'][1]['foo']); $this->assertArraySubset(['hydra:search' => [ - 'hydra:template' => \sprintf('/%s{?foo,fooAlias,order[order[id]],order[order[foo]],searchPartial[foo],searchExact[foo],searchOnTextAndDate[foo],searchOnTextAndDate[createdAt][before],searchOnTextAndDate[createdAt][strictly_before],searchOnTextAndDate[createdAt][after],searchOnTextAndDate[createdAt][strictly_after],q,id,createdAt}', $route), + 'hydra:template' => \sprintf('/%s{?foo,fooAlias,order[order[id]],order[order[foo]],searchPartial[foo],searchExact[foo],searchOnTextAndDate[foo],searchOnTextAndDate[createdAt][before],searchOnTextAndDate[createdAt][strictly_before],searchOnTextAndDate[createdAt][after],searchOnTextAndDate[createdAt][strictly_after],searchParameter[foo],q,id,createdAt}', $route), ]], $a); $this->assertArraySubset(['@type' => 'IriTemplateMapping', 'variable' => 'fooAlias', 'property' => 'foo'], $a['hydra:search']['hydra:mapping'][1]); @@ -118,6 +118,20 @@ public function testPropertyPlaceholderFilter(): void $this->assertEquals($a['hydra:member'][0]['foo'], 'baz'); } + public function testPartialSearchFilter(): void + { + static::bootKernel(); + $resource = $this->isMongoDB() ? SearchFilterParameterDocument::class : SearchFilterParameter::class; + $this->recreateSchema([$resource]); + $this->loadFixtures($resource); + $route = 'search_filter_parameter'; + $response = self::createClient() + ->request('GET', $route . '?searchParameter[foo]=baz') + ; + $a = $response->toArray(); + $this->assertEquals($a['hydra:member'][0]['foo'], 'baz'); + } + public function testStateOptions(): void { if ($this->isMongoDB()) { From 345ebd95462988b68166cd17672b05dec66d5bf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9=20Schopmeijer?= Date: Mon, 1 Dec 2025 14:09:29 +0100 Subject: [PATCH 2/3] refactor: php-cs-fixer style changes Fixes #7478 --- .../Orm/Filter/PartialSearchFilter.php | 65 +++++++------------ .../Entity/SearchFilterParameter.php | 2 +- tests/Functional/Parameters/DoctrineTest.php | 8 +-- 3 files changed, 29 insertions(+), 46 deletions(-) diff --git a/src/Doctrine/Orm/Filter/PartialSearchFilter.php b/src/Doctrine/Orm/Filter/PartialSearchFilter.php index 3ceb766cd78..f13d436f87f 100644 --- a/src/Doctrine/Orm/Filter/PartialSearchFilter.php +++ b/src/Doctrine/Orm/Filter/PartialSearchFilter.php @@ -20,9 +20,6 @@ use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; -use function is_iterable; -use function strtolower; - /** * @author Vincent Amstoutz * @author Ré Schopmeijer @@ -32,52 +29,40 @@ final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilt use BackwardCompatibleFilterDescriptionTrait; use OpenApiFilterTrait; - public function apply( - QueryBuilder $queryBuilder, - QueryNameGeneratorInterface $queryNameGenerator, - string $resourceClass, - ?Operation $operation = null, - array $context = [], - ): void { - $parameter = $context['parameter']; - $alias = $queryBuilder->getRootAliases()[0]; + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter']; + $alias = $queryBuilder->getRootAliases()[0]; $properties = $parameter->getProperties() ?? [$parameter->getProperty()]; foreach ($properties as $property) { - $field = $alias . '.' . $property; + $field = $alias.'.'.$property; $parameterName = $queryNameGenerator->generateParameterName($property); - $values = $parameter->getValue(); + $values = $parameter->getValue(); if (!is_iterable($values)) { - $queryBuilder->setParameter($parameterName, '%' . strtolower($values) . '%'); + $queryBuilder->setParameter($parameterName, '%'.strtolower($values).'%'); - $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( - $queryBuilder - ->expr() - ->like( - 'LOWER(' . $field . ')', - ':' . $parameterName, - ), - ); - } else { - $likeExpressions = []; - foreach ($values as $val) { - $parameterName = $queryNameGenerator->generateParameterName($property); - $likeExpressions[] = $queryBuilder - ->expr() - ->like( - 'LOWER(' . $field . ')', - ':' . $parameterName, - ) - ; - $queryBuilder->setParameter($parameterName, '%' . strtolower($val) . '%'); - } + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}($queryBuilder->expr()->like( + 'LOWER('.$field.')', + ':'.$parameterName, + )); - $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( - $queryBuilder - ->expr() - ->orX(...$likeExpressions), + continue; + } + + $likeExpressions = []; + foreach ($values as $val) { + $parameterName = $queryNameGenerator->generateParameterName($property); + $likeExpressions[] = $queryBuilder->expr()->like( + 'LOWER('.$field.')', + ':'.$parameterName, ); + $queryBuilder->setParameter($parameterName, '%'.strtolower($val).'%'); } + + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( + $queryBuilder->expr()->orX(...$likeExpressions), + ); } } } diff --git a/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php b/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php index 72651074d71..a6d48650115 100644 --- a/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php +++ b/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php @@ -37,7 +37,7 @@ 'searchOnTextAndDate[:property]' => new QueryParameter(filter: 'app_filter_date_and_search'), 'searchParameter[:property]' => new QueryParameter( filter : new PartialSearchFilter(), - properties: ['foo'] + properties: ['foo'], ), 'q' => new QueryParameter(property: 'hydra:freetextQuery'), ] diff --git a/tests/Functional/Parameters/DoctrineTest.php b/tests/Functional/Parameters/DoctrineTest.php index ef6afd10d3b..3e8c0819bf6 100644 --- a/tests/Functional/Parameters/DoctrineTest.php +++ b/tests/Functional/Parameters/DoctrineTest.php @@ -124,11 +124,9 @@ public function testPartialSearchFilter(): void $resource = $this->isMongoDB() ? SearchFilterParameterDocument::class : SearchFilterParameter::class; $this->recreateSchema([$resource]); $this->loadFixtures($resource); - $route = 'search_filter_parameter'; - $response = self::createClient() - ->request('GET', $route . '?searchParameter[foo]=baz') - ; - $a = $response->toArray(); + $route = 'search_filter_parameter'; + $response = self::createClient()->request('GET', $route.'?searchParameter[foo]=baz'); + $a = $response->toArray(); $this->assertEquals($a['hydra:member'][0]['foo'], 'baz'); } From c5ddd703e4682b038613ff9c2378b8afd0932ae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9=20Schopmeijer?= Date: Mon, 1 Dec 2025 14:19:39 +0100 Subject: [PATCH 3/3] fix: fix ExactFilter Fixes #7478 --- src/Doctrine/Orm/Filter/ExactFilter.php | 30 +++++++++++-------- .../Entity/SearchFilterParameter.php | 7 ++++- tests/Functional/Parameters/DoctrineTest.php | 18 +++++++++-- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/src/Doctrine/Orm/Filter/ExactFilter.php b/src/Doctrine/Orm/Filter/ExactFilter.php index 37956151713..afe3c063183 100644 --- a/src/Doctrine/Orm/Filter/ExactFilter.php +++ b/src/Doctrine/Orm/Filter/ExactFilter.php @@ -31,19 +31,25 @@ final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterf public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { $parameter = $context['parameter']; - $value = $parameter->getValue(); - $property = $parameter->getProperty(); $alias = $queryBuilder->getRootAliases()[0]; - $parameterName = $queryNameGenerator->generateParameterName($property); - - if (\is_array($value)) { - $queryBuilder - ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s IN (:%s)', $alias, $property, $parameterName)); - } else { - $queryBuilder - ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s = :%s', $alias, $property, $parameterName)); + $properties = $parameter->getProperties() ?? [$parameter->getProperty()]; + foreach ($properties as $property) { + $parameterName = $queryNameGenerator->generateParameterName($property); + $values = $parameter->getValue(); + + if (is_iterable($values)) { + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}( + \sprintf('%s.%s IN (:%s)', $alias, $property, $parameterName) + ); + } else { + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}( + \sprintf('%s.%s = :%s', $alias, $property, $parameterName) + ); + } + + $queryBuilder->setParameter($parameterName, $values); } - - $queryBuilder->setParameter($parameterName, $value); } } diff --git a/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php b/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php index a6d48650115..cbfedb892d1 100644 --- a/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php +++ b/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiResource; @@ -35,10 +36,14 @@ 'searchPartial[:property]' => new QueryParameter(filter: 'app_search_filter_partial'), 'searchExact[:property]' => new QueryParameter(filter: 'app_search_filter_with_exact'), 'searchOnTextAndDate[:property]' => new QueryParameter(filter: 'app_filter_date_and_search'), - 'searchParameter[:property]' => new QueryParameter( + 'searchPartialProperties[:property]' => new QueryParameter( filter : new PartialSearchFilter(), properties: ['foo'], ), + 'searchExactProperties[:property]' => new QueryParameter( + filter: new ExactFilter(), + properties: ['foo'], + ), 'q' => new QueryParameter(property: 'hydra:freetextQuery'), ] )] diff --git a/tests/Functional/Parameters/DoctrineTest.php b/tests/Functional/Parameters/DoctrineTest.php index 3e8c0819bf6..90f33789329 100644 --- a/tests/Functional/Parameters/DoctrineTest.php +++ b/tests/Functional/Parameters/DoctrineTest.php @@ -49,7 +49,7 @@ public function testDoctrineEntitySearchFilter(): void $this->assertEquals('bar', $a['hydra:member'][1]['foo']); $this->assertArraySubset(['hydra:search' => [ - 'hydra:template' => \sprintf('/%s{?foo,fooAlias,order[order[id]],order[order[foo]],searchPartial[foo],searchExact[foo],searchOnTextAndDate[foo],searchOnTextAndDate[createdAt][before],searchOnTextAndDate[createdAt][strictly_before],searchOnTextAndDate[createdAt][after],searchOnTextAndDate[createdAt][strictly_after],searchParameter[foo],q,id,createdAt}', $route), + 'hydra:template' => \sprintf('/%s{?foo,fooAlias,order[order[id]],order[order[foo]],searchPartial[foo],searchExact[foo],searchOnTextAndDate[foo],searchOnTextAndDate[createdAt][before],searchOnTextAndDate[createdAt][strictly_before],searchOnTextAndDate[createdAt][after],searchOnTextAndDate[createdAt][strictly_after],searchPartialProperties[foo],searchExactProperties[foo],q,id,createdAt}', $route), ]], $a); $this->assertArraySubset(['@type' => 'IriTemplateMapping', 'variable' => 'fooAlias', 'property' => 'foo'], $a['hydra:search']['hydra:mapping'][1]); @@ -118,14 +118,26 @@ public function testPropertyPlaceholderFilter(): void $this->assertEquals($a['hydra:member'][0]['foo'], 'baz'); } - public function testPartialSearchFilter(): void + public function testPartialSearchFilterWithProperties(): void { static::bootKernel(); $resource = $this->isMongoDB() ? SearchFilterParameterDocument::class : SearchFilterParameter::class; $this->recreateSchema([$resource]); $this->loadFixtures($resource); $route = 'search_filter_parameter'; - $response = self::createClient()->request('GET', $route.'?searchParameter[foo]=baz'); + $response = self::createClient()->request('GET', $route.'?searchPartialProperties[foo]=baz'); + $a = $response->toArray(); + $this->assertEquals($a['hydra:member'][0]['foo'], 'baz'); + } + + public function testExactFilterWithProperties(): void + { + static::bootKernel(); + $resource = $this->isMongoDB() ? SearchFilterParameterDocument::class : SearchFilterParameter::class; + $this->recreateSchema([$resource]); + $this->loadFixtures($resource); + $route = 'search_filter_parameter'; + $response = self::createClient()->request('GET', $route.'?searchExactProperties[foo]=baz'); $a = $response->toArray(); $this->assertEquals($a['hydra:member'][0]['foo'], 'baz'); }