From 60cd91cadfccf5abf05840be4189ee85b017f5e6 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 17 Jun 2026 18:05:42 +0200 Subject: [PATCH] feat(doctrine): add StartSearchFilter and WordStartSearchFilter (ORM + ODM) Ports the LIKE 'value%' prefix search and the word-boundary prefix search to the canonical Doctrine filter family for 4.4, mirroring EndSearchFilter. StartSearchFilter matches strings starting with the value (ORM LIKE 'value%', ODM /^value/). WordStartSearchFilter matches any word starting with the value (ORM LIKE 'value%' OR '% value%', ODM /(^value|\svalue)/), with parity to the legacy SearchFilter word_start strategy. Both are new primary filters: not experimental, not deprecated. Additive, zero BC break. --- src/Doctrine/Odm/Filter/StartSearchFilter.php | 77 +++++++ .../Odm/Filter/WordStartSearchFilter.php | 82 +++++++ src/Doctrine/Orm/Filter/StartSearchFilter.php | 83 +++++++ .../Orm/Filter/WordStartSearchFilter.php | 92 ++++++++ .../Fixtures/TestBundle/Document/Chicken.php | 16 ++ tests/Fixtures/TestBundle/Entity/Chicken.php | 16 ++ .../Parameters/StartSearchFilterTest.php | 203 ++++++++++++++++++ .../Parameters/WordStartSearchFilterTest.php | 185 ++++++++++++++++ 8 files changed, 754 insertions(+) create mode 100644 src/Doctrine/Odm/Filter/StartSearchFilter.php create mode 100644 src/Doctrine/Odm/Filter/WordStartSearchFilter.php create mode 100644 src/Doctrine/Orm/Filter/StartSearchFilter.php create mode 100644 src/Doctrine/Orm/Filter/WordStartSearchFilter.php create mode 100644 tests/Functional/Parameters/StartSearchFilterTest.php create mode 100644 tests/Functional/Parameters/WordStartSearchFilterTest.php diff --git a/src/Doctrine/Odm/Filter/StartSearchFilter.php b/src/Doctrine/Odm/Filter/StartSearchFilter.php new file mode 100644 index 0000000000..bbec450837 --- /dev/null +++ b/src/Doctrine/Odm/Filter/StartSearchFilter.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 beginning of a string property, using a regular expression anchored at the start. + */ +final class StartSearchFilter 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/Filter/WordStartSearchFilter.php b/src/Doctrine/Odm/Filter/WordStartSearchFilter.php new file mode 100644 index 0000000000..3ece9a2cde --- /dev/null +++ b/src/Doctrine/Odm/Filter/WordStartSearchFilter.php @@ -0,0 +1,82 @@ + + * + * 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 a word boundary prefix, matching documents that contain a word starting with the value, + * using a regular expression anchored at the start of the string or at a word boundary. + */ +final class WordStartSearchFilter 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)) { + $match->{$operator}( + $aggregationBuilder->matchExpr()->field($matchField)->equals($this->createRegex($values)) + ); + + return; + } + + $or = $aggregationBuilder->matchExpr(); + foreach ($values as $value) { + $or->addOr( + $aggregationBuilder->matchExpr() + ->field($matchField) + ->equals($this->createRegex($value)) + ); + } + + $match->{$operator}($or); + } + + private function createRegex(string $value): Regex + { + $escapedValue = preg_quote($value, '/'); + + return new Regex('(^'.$escapedValue.'|\s'.$escapedValue.')', $this->caseSensitive ? '' : 'i'); + } +} diff --git a/src/Doctrine/Orm/Filter/StartSearchFilter.php b/src/Doctrine/Orm/Filter/StartSearchFilter.php new file mode 100644 index 0000000000..4cca0231e0 --- /dev/null +++ b/src/Doctrine/Orm/Filter/StartSearchFilter.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 beginning of a string property, using a `LIKE 'value%'` clause. + */ +final class StartSearchFilter 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/src/Doctrine/Orm/Filter/WordStartSearchFilter.php b/src/Doctrine/Orm/Filter/WordStartSearchFilter.php new file mode 100644 index 0000000000..e872b2d520 --- /dev/null +++ b/src/Doctrine/Orm/Filter/WordStartSearchFilter.php @@ -0,0 +1,92 @@ + + * + * 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 a word boundary prefix, matching fields that contain a word starting with the value, + * using a `LIKE 'value%' OR LIKE '% value%'` clause. + */ +final class WordStartSearchFilter 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)) { + $values = [$values]; + } + + $expressions = []; + foreach ($values as $val) { + $startName = $queryNameGenerator->generateParameterName($property); + $wordName = $queryNameGenerator->generateParameterName($property); + + $expressions[] = $queryBuilder->expr()->orX( + $this->createLikeExpression($field, $startName), + $this->createLikeExpression($field, $wordName), + ); + + $queryBuilder->setParameter($startName, $this->formatStartValue($val)); + $queryBuilder->setParameter($wordName, $this->formatWordValue($val)); + } + + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( + $queryBuilder->expr()->orX(...$expressions) + ); + } + + private function createLikeExpression(string $field, string $parameterName): string + { + return $this->caseSensitive + ? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\'' + : 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; + } + + private function formatStartValue(string $value): string + { + return addcslashes($value, '\\%_').'%'; + } + + private function formatWordValue(string $value): string + { + return '% '.addcslashes($value, '\\%_').'%'; + } +} diff --git a/tests/Fixtures/TestBundle/Document/Chicken.php b/tests/Fixtures/TestBundle/Document/Chicken.php index af8fe04296..06d5fa16fa 100644 --- a/tests/Fixtures/TestBundle/Document/Chicken.php +++ b/tests/Fixtures/TestBundle/Document/Chicken.php @@ -20,6 +20,8 @@ use ApiPlatform\Doctrine\Odm\Filter\IriFilter; use ApiPlatform\Doctrine\Odm\Filter\OrFilter; use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter; +use ApiPlatform\Doctrine\Odm\Filter\StartSearchFilter; +use ApiPlatform\Doctrine\Odm\Filter\WordStartSearchFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; @@ -55,6 +57,20 @@ filter: new EndSearchFilter(true), property: 'name', ), + 'nameStart' => new QueryParameter( + filter: new StartSearchFilter(false), + property: 'name', + ), + 'nameStartNoProperty' => new QueryParameter(filter: new StartSearchFilter()), + 'nameStartSensitive' => new QueryParameter( + filter: new StartSearchFilter(true), + property: 'name', + ), + 'nameWordStart' => new QueryParameter( + filter: new WordStartSearchFilter(false), + property: 'name', + ), + 'nameWordStartNoProperty' => new QueryParameter(filter: new WordStartSearchFilter()), '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 386a04c585..4a5c0d861a 100644 --- a/tests/Fixtures/TestBundle/Entity/Chicken.php +++ b/tests/Fixtures/TestBundle/Entity/Chicken.php @@ -20,6 +20,8 @@ use ApiPlatform\Doctrine\Orm\Filter\IriFilter; use ApiPlatform\Doctrine\Orm\Filter\OrFilter; use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; +use ApiPlatform\Doctrine\Orm\Filter\StartSearchFilter; +use ApiPlatform\Doctrine\Orm\Filter\WordStartSearchFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; @@ -55,6 +57,20 @@ filter: new EndSearchFilter(true), property: 'name', ), + 'nameStart' => new QueryParameter( + filter: new StartSearchFilter(), + property: 'name', + ), + 'nameStartNoProperty' => new QueryParameter(filter: new StartSearchFilter()), + 'nameStartSensitive' => new QueryParameter( + filter: new StartSearchFilter(true), + property: 'name', + ), + 'nameWordStart' => new QueryParameter( + filter: new WordStartSearchFilter(), + property: 'name', + ), + 'nameWordStartNoProperty' => new QueryParameter(filter: new WordStartSearchFilter()), '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/StartSearchFilterTest.php b/tests/Functional/Parameters/StartSearchFilterTest.php new file mode 100644 index 0000000000..fda860ec29 --- /dev/null +++ b/tests/Functional/Parameters/StartSearchFilterTest.php @@ -0,0 +1,203 @@ + + * + * 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 StartSearchFilterTest 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('startSearchFilterProvider')] + public function testStartSearchFilter(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 startSearchFilterProvider(): \Generator + { + yield 'filter by prefix "Gert"' => [ + '/chickens?nameStart=Gert', + 1, + ['Gertrude'], + ]; + + yield 'filter by prefix "Hen"' => [ + '/chickens?nameStart=Hen', + 1, + ['Henriette'], + ]; + + yield 'prefix in the middle does not match (start anchored)' => [ + '/chickens?nameStart=rude', + 0, + [], + ]; + + yield 'filter by prefix with no matching entities' => [ + '/chickens?nameStart=Zebra', + 0, + [], + ]; + + yield 'filter with multiple prefixes "Gert" OR "Hen"' => [ + '/chickens?nameStart[]=Gert&nameStart[]=Hen', + 2, + ['Gertrude', 'Henriette'], + ]; + + yield 'filter by prefix "xx_"' => [ + '/chickens?nameStart=xx_', + 1, + ['xx_%_\\_%_xx'], + ]; + } + + public function testStartSearchFilterThrowsExceptionWhenPropertyIsMissing(): void + { + $response = self::createClient()->request('GET', '/chickens?nameStartNoProperty=Gert'); + $this->assertResponseStatusCodeSame(400); + + $responseData = $response->toArray(false); + + $this->assertStringContainsString( + 'The filter parameter with key "nameStartNoProperty" must specify a property', + $responseData['detail'] + ); + } + + #[DataProvider('startSearchFilterCaseSensitiveProvider')] + public function testStartSearchCaseSensitiveFilter(string $url, int $expectedCount, array $expectedNames): void + { + if ($this->isMysql() || $this->isSqlite()) { + $this->markTestSkipped('Mysql and sqlite use case insensitive LIKE.'); + } + + $this->testStartSearchFilter($url, $expectedCount, $expectedNames); + } + + public static function startSearchFilterCaseSensitiveProvider(): \Generator + { + yield 'filter by prefix "gert"' => [ + '/chickens?nameStart=gert', + 1, + ['Gertrude'], + ]; + + yield 'filter by case sensitive prefix "Gert"' => [ + '/chickens?nameStartSensitive=Gert', + 1, + ['Gertrude'], + ]; + + yield 'filter by case sensitive prefix "gert"' => [ + '/chickens?nameStartSensitive=gert', + 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(); + } +} diff --git a/tests/Functional/Parameters/WordStartSearchFilterTest.php b/tests/Functional/Parameters/WordStartSearchFilterTest.php new file mode 100644 index 0000000000..00edaf0eca --- /dev/null +++ b/tests/Functional/Parameters/WordStartSearchFilterTest.php @@ -0,0 +1,185 @@ + + * + * 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 WordStartSearchFilterTest 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('wordStartSearchFilterProvider')] + public function testWordStartSearchFilter(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 wordStartSearchFilterProvider(): \Generator + { + // Fixtures: "Gertrude the Hen", "Henriette", "Red Rooster" + yield 'matches word at the very start' => [ + '/chickens?nameWordStart=Gert', + 1, + ['Gertrude the Hen'], + ]; + + yield 'matches a word starting in the middle of the string' => [ + '/chickens?nameWordStart=Hen', + 2, + ['Gertrude the Hen', 'Henriette'], + ]; + + yield 'does not match a substring inside a word' => [ + '/chickens?nameWordStart=ette', + 0, + [], + ]; + + yield 'does not match a substring inside a non-leading word' => [ + '/chickens?nameWordStart=ooster', + 0, + [], + ]; + + yield 'matches the leading word of a multi-word value' => [ + '/chickens?nameWordStart=Red', + 1, + ['Red Rooster'], + ]; + + yield 'matches a trailing word' => [ + '/chickens?nameWordStart=Roo', + 1, + ['Red Rooster'], + ]; + + yield 'no match' => [ + '/chickens?nameWordStart=Zebra', + 0, + [], + ]; + + yield 'multiple values "Gert" OR "Red"' => [ + '/chickens?nameWordStart[]=Gert&nameWordStart[]=Red', + 2, + ['Gertrude the Hen', 'Red Rooster'], + ]; + } + + public function testWordStartSearchFilterThrowsExceptionWhenPropertyIsMissing(): void + { + $response = self::createClient()->request('GET', '/chickens?nameWordStartNoProperty=Gert'); + $this->assertResponseStatusCodeSame(400); + + $responseData = $response->toArray(false); + + $this->assertStringContainsString( + 'The filter parameter with key "nameWordStartNoProperty" must specify a property', + $responseData['detail'] + ); + } + + /** + * @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 the Hen'); + $chicken1->setChickenCoop($chickenCoop1); + $chicken1->setOwner($owner1); + + $chicken2 = new $chickenClass(); + $chicken2->setName('Henriette'); + $chicken2->setChickenCoop($chickenCoop1); + $chicken2->setOwner($owner1); + + $chicken3 = new $chickenClass(); + $chicken3->setName('Red Rooster'); + $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(); + } +}