diff --git a/src/Doctrine/Odm/Filter/EndSearchFilter.php b/src/Doctrine/Odm/Filter/EndSearchFilter.php new file mode 100644 index 00000000000..f798dd636bf --- /dev/null +++ b/src/Doctrine/Odm/Filter/EndSearchFilter.php @@ -0,0 +1,77 @@ + + * + * 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\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Odm\NestedPropertyHelperTrait; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use MongoDB\BSON\Regex; + +/** + * Filters the collection by the end of a string property, using a regular expression anchored at the end. + */ +final class EndSearchFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; + use OpenApiFilterTrait; + + public function __construct(private readonly bool $caseSensitive = true) + { + } + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + $parameter = $context['parameter']; + + 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(); + $values = $parameter->getValue(); + $match = $context['match'] = $context['match'] ?? + $aggregationBuilder + ->matchExpr(); + $operator = $context['operator'] ?? 'addAnd'; + + $matchField = $this->addNestedParameterLookups($property, $aggregationBuilder, $parameter, false, $context); + + if (!is_iterable($values)) { + $escapedValue = preg_quote($values, '/'); + $match->{$operator}( + $aggregationBuilder->matchExpr()->field($matchField)->equals(new Regex($escapedValue.'$', $this->caseSensitive ? '' : 'i')) + ); + + return; + } + + $or = $aggregationBuilder->matchExpr(); + foreach ($values as $value) { + $escapedValue = preg_quote($value, '/'); + + $or->addOr( + $aggregationBuilder->matchExpr() + ->field($matchField) + ->equals(new Regex($escapedValue.'$', $this->caseSensitive ? '' : 'i')) + ); + } + + $match->{$operator}($or); + } +} diff --git a/src/Doctrine/Odm/Tests/Filter/EndSearchFilterTest.php b/src/Doctrine/Odm/Tests/Filter/EndSearchFilterTest.php new file mode 100644 index 00000000000..88b7eab45d2 --- /dev/null +++ b/src/Doctrine/Odm/Tests/Filter/EndSearchFilterTest.php @@ -0,0 +1,134 @@ + + * + * 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\Tests\Filter; + +use ApiPlatform\Doctrine\Odm\Filter\EndSearchFilter; +use ApiPlatform\Doctrine\Odm\Tests\DoctrineMongoDbOdmTestCase; +use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\Dummy; +use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\RelatedDummy; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use Doctrine\ODM\MongoDB\DocumentManager; +use MongoDB\BSON\Regex; +use PHPUnit\Framework\TestCase; + +class EndSearchFilterTest extends TestCase +{ + private DocumentManager $manager; + + protected function setUp(): void + { + $this->manager = DoctrineMongoDbOdmTestCase::createTestDocumentManager(); + } + + public function testEndSearchSimpleProperty(): void + { + $filter = new EndSearchFilter(); + + $parameter = new QueryParameter(property: 'name', key: 'name'); + $parameter->setValue('foo'); + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + 'filters' => ['name' => 'foo'], + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + + // The filter populates $context['match'] with the match expression (no pipeline stage added) + $this->assertArrayHasKey('match', $context); + $this->assertEquals( + ['$and' => [['name' => new Regex('foo$', '')]]], + $context['match']->getQuery() + ); + $this->assertNoPipelineStages($aggregationBuilder); + } + + public function testEndSearchCaseInsensitive(): void + { + $filter = new EndSearchFilter(caseSensitive: false); + + $parameter = new QueryParameter(property: 'name', key: 'name'); + $parameter->setValue('foo'); + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + 'filters' => ['name' => 'foo'], + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + + $this->assertEquals( + ['$and' => [['name' => new Regex('foo$', 'i')]]], + $context['match']->getQuery() + ); + } + + public function testEndSearchNestedProperty(): void + { + $filter = new EndSearchFilter(); + + $parameter = new QueryParameter( + property: 'relatedDummy.name', + key: 'relatedDummy.name', + extraProperties: [ + 'nested_properties_info' => ['relatedDummy.name' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'name', + 'leaf_class' => RelatedDummy::class, + 'odm_segments' => [ + [ + 'type' => 'reference', + 'target_document' => RelatedDummy::class, + 'is_owning_side' => true, + 'mapped_by' => null, + ], + ], + ]], + ], + ); + $parameter->setValue('bar'); + + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + 'filters' => ['relatedDummy.name' => 'bar'], + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + $pipeline = $aggregationBuilder->getPipeline(); + + // Nested property adds $lookup + $unwind stages + $this->assertCount(2, $pipeline); + $this->assertArrayHasKey('$lookup', $pipeline[0]); + $this->assertArrayHasKey('$unwind', $pipeline[1]); + + // The match expression is populated for the parameter extension to commit + $this->assertArrayHasKey('match', $context); + } + + private function assertNoPipelineStages(Builder $aggregationBuilder): void + { + try { + $pipeline = $aggregationBuilder->getPipeline(); + $this->assertEmpty($pipeline); + } catch (\OutOfRangeException) { + // No stages added — expected for simple property filters + } + } +} diff --git a/src/Doctrine/Orm/Filter/EndSearchFilter.php b/src/Doctrine/Orm/Filter/EndSearchFilter.php new file mode 100644 index 00000000000..242e79152df --- /dev/null +++ b/src/Doctrine/Orm/Filter/EndSearchFilter.php @@ -0,0 +1,83 @@ + + * + * 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\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; + +/** + * Filters the collection by the end of a string property, using a `LIKE '%value'` clause. + */ +final class EndSearchFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; + use OpenApiFilterTrait; + + public function __construct(private readonly bool $caseSensitive = false) + { + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter']; + + 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(); + $alias = $queryBuilder->getRootAliases()[0]; + [$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); + $field = $alias.'.'.$property; + $values = $parameter->getValue(); + + if (!is_iterable($values)) { + $parameterName = $queryNameGenerator->generateParameterName($property); + $queryBuilder->setParameter($parameterName, $this->formatLikeValue($values)); + + $likeExpression = $this->caseSensitive + ? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\'' + : 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}($likeExpression); + + return; + } + + $likeExpressions = []; + foreach ($values as $val) { + $parameterName = $queryNameGenerator->generateParameterName($property); + $likeExpressions[] = $this->caseSensitive + ? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\'' + : 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; + + $queryBuilder->setParameter($parameterName, $this->formatLikeValue($val)); + } + + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( + $queryBuilder->expr()->orX(...$likeExpressions) + ); + } + + private function formatLikeValue(string $value): string + { + return '%'.addcslashes($value, '\\%_'); + } +} diff --git a/tests/Fixtures/TestBundle/Document/Chicken.php b/tests/Fixtures/TestBundle/Document/Chicken.php index 55fd33676c1..af8fe04296e 100644 --- a/tests/Fixtures/TestBundle/Document/Chicken.php +++ b/tests/Fixtures/TestBundle/Document/Chicken.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; use ApiPlatform\Doctrine\Odm\Filter\ComparisonFilter; +use ApiPlatform\Doctrine\Odm\Filter\EndSearchFilter; use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; use ApiPlatform\Doctrine\Odm\Filter\FreeTextQueryFilter; use ApiPlatform\Doctrine\Odm\Filter\IriFilter; @@ -45,6 +46,15 @@ filter: new PartialSearchFilter(true), property: 'name', ), + 'nameEnd' => new QueryParameter( + filter: new EndSearchFilter(false), + property: 'name', + ), + 'nameEndNoProperty' => new QueryParameter(filter: new EndSearchFilter()), + 'nameEndSensitive' => new QueryParameter( + filter: new EndSearchFilter(true), + property: 'name', + ), 'autocomplete' => new QueryParameter(filter: new FreeTextQueryFilter(new OrFilter(new ExactFilter())), properties: ['name', 'ean']), 'q' => new QueryParameter(filter: new FreeTextQueryFilter(new PartialSearchFilter()), properties: ['name', 'ean']), 'qmixed' => new QueryParameter(filter: new FreeTextQueryFilter([ diff --git a/tests/Fixtures/TestBundle/Entity/Chicken.php b/tests/Fixtures/TestBundle/Entity/Chicken.php index 95f8e8f9598..386a04c5852 100644 --- a/tests/Fixtures/TestBundle/Entity/Chicken.php +++ b/tests/Fixtures/TestBundle/Entity/Chicken.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; use ApiPlatform\Doctrine\Orm\Filter\ComparisonFilter; +use ApiPlatform\Doctrine\Orm\Filter\EndSearchFilter; use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Doctrine\Orm\Filter\FreeTextQueryFilter; use ApiPlatform\Doctrine\Orm\Filter\IriFilter; @@ -45,6 +46,15 @@ filter: new PartialSearchFilter(true), property: 'name', ), + 'nameEnd' => new QueryParameter( + filter: new EndSearchFilter(), + property: 'name', + ), + 'nameEndNoProperty' => new QueryParameter(filter: new EndSearchFilter()), + 'nameEndSensitive' => new QueryParameter( + filter: new EndSearchFilter(true), + property: 'name', + ), 'autocomplete' => new QueryParameter(filter: new FreeTextQueryFilter(new OrFilter(new ExactFilter())), properties: ['name', 'ean']), 'q' => new QueryParameter(filter: new FreeTextQueryFilter(new PartialSearchFilter()), properties: ['name', 'ean']), 'qmixed' => new QueryParameter(filter: new FreeTextQueryFilter([ diff --git a/tests/Functional/Parameters/EndSearchFilterTest.php b/tests/Functional/Parameters/EndSearchFilterTest.php new file mode 100644 index 00000000000..a6e45d1e576 --- /dev/null +++ b/tests/Functional/Parameters/EndSearchFilterTest.php @@ -0,0 +1,215 @@ + + * + * 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\Chicken as DocumentChicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ChickenCoop as DocumentChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Owner as DocumentOwner; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Chicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Owner; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; + +final class EndSearchFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Chicken::class, ChickenCoop::class, Owner::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $entities = $this->isMongoDB() + ? [DocumentChicken::class, DocumentChickenCoop::class, DocumentOwner::class] + : [Chicken::class, ChickenCoop::class, Owner::class]; + + $this->recreateSchema($entities); + $this->loadFixtures(); + } + + #[DataProvider('endSearchFilterProvider')] + public function testEndSearchFilter(string $url, int $expectedCount, array $expectedNames): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + + $names = array_map(static fn ($chicken) => $chicken['name'], $filteredItems); + sort($names); + sort($expectedNames); + + $this->assertSame($expectedNames, $names, 'The returned names do not match the expected values.'); + } + + public static function endSearchFilterProvider(): \Generator + { + yield 'filter by ending "rude"' => [ + '/chickens?nameEnd=rude', + 1, + ['Gertrude'], + ]; + + yield 'filter by ending "tte"' => [ + '/chickens?nameEnd=tte', + 1, + ['Henriette'], + ]; + + yield 'filter by ending "e" (should match both)' => [ + '/chickens?nameEnd=e', + 2, + ['Gertrude', 'Henriette'], + ]; + + yield 'filter by ending "rud" (must not match — not a suffix)' => [ + '/chickens?nameEnd=rud', + 0, + [], + ]; + + yield 'filter by ending with no matching entities' => [ + '/chickens?nameEnd=Zebra', + 0, + [], + ]; + + yield 'filter by ending "xx"' => [ + '/chickens?nameEnd=xx', + 1, + ['xx_%_\\_%_xx'], + ]; + + yield 'filter with multiple endings "rude" OR "tte"' => [ + '/chickens?nameEnd[]=rude&nameEnd[]=tte', + 2, + ['Gertrude', 'Henriette'], + ]; + + yield 'filter with multiple endings, one matching "rude", the other not matching "Zebra"' => [ + '/chickens?nameEnd[]=rude&nameEnd[]=Zebra', + 1, + ['Gertrude'], + ]; + } + + public function testEndSearchFilterThrowsExceptionWhenPropertyIsMissing(): void + { + $response = self::createClient()->request('GET', '/chickens?nameEndNoProperty=rude'); + $this->assertResponseStatusCodeSame(400); + + $responseData = $response->toArray(false); + + $this->assertStringContainsString( + 'The filter parameter with key "nameEndNoProperty" must specify a property', + $responseData['detail'] + ); + } + + #[DataProvider('endSearchFilterCaseSensitiveProvider')] + public function testEndSearchCaseSensitiveFilter(string $url, int $expectedCount, array $expectedNames): void + { + if ($this->isMysql() || $this->isSqlite()) { + $this->markTestSkipped('Mysql and sqlite use case insensitive LIKE.'); + } + + $this->testEndSearchFilter($url, $expectedCount, $expectedNames); + } + + public static function endSearchFilterCaseSensitiveProvider(): \Generator + { + yield 'case insensitive ending "rude"' => [ + '/chickens?nameEnd=RUDE', + 1, + ['Gertrude'], + ]; + + yield 'case sensitive ending "rude"' => [ + '/chickens?nameEndSensitive=rude', + 1, + ['Gertrude'], + ]; + + yield 'case sensitive ending "RUDE"' => [ + '/chickens?nameEndSensitive=RUDE', + 0, + [], + ]; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(): void + { + $manager = $this->getManager(); + + $chickenClass = $this->isMongoDB() ? DocumentChicken::class : Chicken::class; + $coopClass = $this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class; + $ownerClass = $this->isMongoDB() ? DocumentOwner::class : Owner::class; + + $owner1 = new $ownerClass(); + $owner1->setName('Alice'); + + $manager->persist($owner1); + $manager->flush(); + + $chickenCoop1 = new $coopClass(); + + $chicken1 = new $chickenClass(); + $chicken1->setName('Gertrude'); + $chicken1->setChickenCoop($chickenCoop1); + $chicken1->setOwner($owner1); + + $chicken2 = new $chickenClass(); + $chicken2->setName('Henriette'); + $chicken2->setChickenCoop($chickenCoop1); + $chicken2->setOwner($owner1); + + $chicken3 = new $chickenClass(); + $chicken3->setName('xx_%_\\_%_xx'); + $chicken3->setChickenCoop($chickenCoop1); + $chicken3->setOwner($owner1); + + $chickenCoop1->addChicken($chicken1); + $chickenCoop1->addChicken($chicken2); + $chickenCoop1->addChicken($chicken3); + + $manager->persist($chickenCoop1); + $manager->persist($chicken1); + $manager->persist($chicken2); + $manager->persist($chicken3); + + $manager->flush(); + } +}