From ba0c60d45f223f190fdf6c45463cd29ec9511aec Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 10 Apr 2025 16:32:44 +0200 Subject: [PATCH 1/9] feat: iri search filter continues the work at #6865 --- src/Doctrine/Orm/Filter/ExactSearchFilter.php | 0 src/Doctrine/Orm/Filter/IriFilter.php | 62 +++++++++++++ .../Orm/Filter/PartialSearchFilter.php | 0 .../IriConverterParameterProvider.php | 52 +++++++++++ .../Resources/config/state/provider.xml | 6 ++ .../Fixtures/TestBundle/Document/Chicken.php | 60 ++++++++++++ .../TestBundle/Document/ChickenCoop.php | 72 +++++++++++++++ .../Fixtures/TestBundle/Document/Company.php | 2 +- tests/Fixtures/TestBundle/Entity/Chicken.php | 63 +++++++++++++ .../TestBundle/Entity/ChickenCoop.php | 73 +++++++++++++++ tests/Fixtures/TestBundle/Entity/Company.php | 2 +- tests/Functional/Parameters/IriFilterTest.php | 92 +++++++++++++++++++ 12 files changed, 482 insertions(+), 2 deletions(-) create mode 100644 src/Doctrine/Orm/Filter/ExactSearchFilter.php create mode 100644 src/Doctrine/Orm/Filter/IriFilter.php create mode 100644 src/Doctrine/Orm/Filter/PartialSearchFilter.php create mode 100644 src/State/Provider/IriConverterParameterProvider.php create mode 100644 tests/Fixtures/TestBundle/Document/Chicken.php create mode 100644 tests/Fixtures/TestBundle/Document/ChickenCoop.php create mode 100644 tests/Fixtures/TestBundle/Entity/Chicken.php create mode 100644 tests/Fixtures/TestBundle/Entity/ChickenCoop.php create mode 100644 tests/Functional/Parameters/IriFilterTest.php diff --git a/src/Doctrine/Orm/Filter/ExactSearchFilter.php b/src/Doctrine/Orm/Filter/ExactSearchFilter.php new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Doctrine/Orm/Filter/IriFilter.php b/src/Doctrine/Orm/Filter/IriFilter.php new file mode 100644 index 00000000000..c13c7aedc77 --- /dev/null +++ b/src/Doctrine/Orm/Filter/IriFilter.php @@ -0,0 +1,62 @@ + + * + * 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\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\ParameterProviderFilterInterface; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; +use ApiPlatform\State\Provider\IriConverterParameterProvider; +use Doctrine\ORM\QueryBuilder; + +class IriFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface +{ + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if (!$parameter = $context['parameter'] ?? null) { + return; + } + + $value = $parameter->getValue(); + if (!\is_array($value)) { + $value = [$value]; + } + + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + $parameterName = $queryNameGenerator->generateParameterName($property); + + $queryBuilder + ->join(\sprintf('%s.%s', $alias, $property), $parameterName) + ->andWhere(\sprintf('%s IN(:%s)', $parameterName, $parameterName)) + ->setParameter($parameterName, $value); + } + + public static function getParameterProvider(): string + { + return IriConverterParameterProvider::class; + } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true); + } + + public function getDescription(string $resourceClass): array + { + return []; + } +} diff --git a/src/Doctrine/Orm/Filter/PartialSearchFilter.php b/src/Doctrine/Orm/Filter/PartialSearchFilter.php new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/State/Provider/IriConverterParameterProvider.php b/src/State/Provider/IriConverterParameterProvider.php new file mode 100644 index 00000000000..a7bc538472c --- /dev/null +++ b/src/State/Provider/IriConverterParameterProvider.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\State\Provider; + +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\State\ParameterNotFound; +use ApiPlatform\State\ParameterProviderInterface; + +/** + * @author Vincent Amstoutz + */ +final readonly class IriConverterParameterProvider implements ParameterProviderInterface +{ + public function __construct( + private IriConverterInterface $iriConverter, + ) { + } + + public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation + { + $operation = $context['operation'] ?? null; + if (!($value = $parameter->getValue()) || $value instanceof ParameterNotFound) { + return $operation; + } + + if (!\is_array($value)) { + $value = [$value]; + } + + $entities = []; + foreach ($value as $v) { + $entities[] = $this->iriConverter->getResourceFromIri($v, ['fetch_data' => false]); + } + + $parameter->setValue($entities); + + return $operation; + } +} diff --git a/src/Symfony/Bundle/Resources/config/state/provider.xml b/src/Symfony/Bundle/Resources/config/state/provider.xml index 52bb2ea23f7..45f19b9eb9d 100644 --- a/src/Symfony/Bundle/Resources/config/state/provider.xml +++ b/src/Symfony/Bundle/Resources/config/state/provider.xml @@ -42,5 +42,11 @@ + + + + + + diff --git a/tests/Fixtures/TestBundle/Document/Chicken.php b/tests/Fixtures/TestBundle/Document/Chicken.php new file mode 100644 index 00000000000..89f79de85c2 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/Chicken.php @@ -0,0 +1,60 @@ + + * + * 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\Metadata\Get; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ODM\Document] +#[Get] +class Chicken +{ + #[ODM\Id] + private string $id; + + #[ODM\Field(type: 'string')] + private string $name; + + #[ODM\ReferenceOne(targetDocument: ChickenCoop::class, inversedBy: 'chickens')] + private ?ChickenCoop $chickenCoop = null; + + public function getId(): ?string + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getChickenCoop(): ?ChickenCoop + { + return $this->chickenCoop; + } + + public function setChickenCoop(?ChickenCoop $chickenCoop): self + { + $this->chickenCoop = $chickenCoop; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Document/ChickenCoop.php b/tests/Fixtures/TestBundle/Document/ChickenCoop.php new file mode 100644 index 00000000000..71ccb5204a8 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/ChickenCoop.php @@ -0,0 +1,72 @@ + + * + * 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\Orm\Filter\IriFilter; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Chicken; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ODM\Document] +#[GetCollection(normalizationContext: ['hydra_prefix' => false], parameters: ['chickens' => new QueryParameter(filter: new IriFilter())])] +class ChickenCoop +{ + #[ODM\Id] + private ?string $id = null; + + #[ODM\ReferenceMany(targetDocument: Chicken::class, mappedBy: 'chickenCoop')] + private Collection $chickens; + + public function __construct() + { + $this->chickens = new ArrayCollection(); + } + + public function getId(): ?string + { + return $this->id; + } + + /** + * @return Collection + */ + public function getChickens(): Collection + { + return $this->chickens; + } + + public function addChicken(Chicken $chicken): self + { + if (!$this->chickens->contains($chicken)) { + $this->chickens[] = $chicken; + $chicken->setChickenCoop($this); + } + + return $this; + } + + public function removeChicken(Chicken $chicken): self + { + if ($this->chickens->removeElement($chicken)) { + if ($chicken->getChickenCoop() === $this) { + $chicken->setChickenCoop(null); + } + } + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Document/Company.php b/tests/Fixtures/TestBundle/Document/Company.php index ca88faa6e44..a878dfad32d 100644 --- a/tests/Fixtures/TestBundle/Document/Company.php +++ b/tests/Fixtures/TestBundle/Document/Company.php @@ -22,7 +22,7 @@ use Symfony\Component\Serializer\Annotation\Groups; #[ApiResource] -#[GetCollection] +#[GetCollection()] #[Get] #[Post] #[ApiResource(uriTemplate: '/employees/{employeeId}/rooms/{roomId}/company/{companyId}', uriVariables: ['employeeId' => ['from_class' => Employee::class, 'from_property' => 'company']])] diff --git a/tests/Fixtures/TestBundle/Entity/Chicken.php b/tests/Fixtures/TestBundle/Entity/Chicken.php new file mode 100644 index 00000000000..dea2bde7a58 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Chicken.php @@ -0,0 +1,63 @@ + + * + * 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\Metadata\Get; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[Get()] +class Chicken +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 255)] + private string $name; + + #[ORM\ManyToOne(targetEntity: ChickenCoop::class, inversedBy: 'chickens')] + #[ORM\JoinColumn(nullable: false)] + private ChickenCoop $chickenCoop; + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getChickenCoop(): ?ChickenCoop + { + return $this->chickenCoop; + } + + public function setChickenCoop(?ChickenCoop $chickenCoop): self + { + $this->chickenCoop = $chickenCoop; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/ChickenCoop.php b/tests/Fixtures/TestBundle/Entity/ChickenCoop.php new file mode 100644 index 00000000000..0c821e660da --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/ChickenCoop.php @@ -0,0 +1,73 @@ + + * + * 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\IriFilter; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[GetCollection(normalizationContext: ['hydra_prefix' => false], parameters: ['chickens' => new QueryParameter(filter: new IriFilter())])] +class ChickenCoop +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\OneToMany(mappedBy: 'chickenCoop', targetEntity: Chicken::class, cascade: ['persist'])] + private Collection $chickens; + + public function __construct() + { + $this->chickens = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + /** + * @return Collection + */ + public function getChickens(): Collection + { + return $this->chickens; + } + + public function addChicken(Chicken $chicken): self + { + if (!$this->chickens->contains($chicken)) { + $this->chickens[] = $chicken; + $chicken->setChickenCoop($this); + } + + return $this; + } + + public function removeChicken(Chicken $chicken): self + { + if ($this->chickens->removeElement($chicken)) { + if ($chicken->getChickenCoop() === $this) { + $chicken->setChickenCoop(null); + } + } + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Company.php b/tests/Fixtures/TestBundle/Entity/Company.php index 26abb965a28..a95a9731da2 100644 --- a/tests/Fixtures/TestBundle/Entity/Company.php +++ b/tests/Fixtures/TestBundle/Entity/Company.php @@ -22,7 +22,7 @@ use Symfony\Component\Serializer\Annotation\Groups; #[ApiResource] -#[GetCollection] +#[GetCollection()] #[Get] #[Post] #[ApiResource( diff --git a/tests/Functional/Parameters/IriFilterTest.php b/tests/Functional/Parameters/IriFilterTest.php new file mode 100644 index 00000000000..a2b32e0bba0 --- /dev/null +++ b/tests/Functional/Parameters/IriFilterTest.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\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\Entity\Chicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ChickenCoop; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; + +final class IriFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ChickenCoop::class, Chicken::class]; + } + + public function testIriFilter(): void + { + $client = $this->createClient(); + $res = $client->request('GET', '/chicken_coops?chickens=/chickens/2')->toArray(); + $this->assertCount(1, $res['member']); + $this->assertEquals(['/chickens/2'], $res['member'][0]['chickens']); + } + + public function testIriFilterMultiple(): void + { + $client = $this->createClient(); + $res = $client->request('GET', '/chicken_coops?chickens[]=/chickens/2&chickens[]=/chickens/1')->toArray(); + $this->assertCount(2, $res['member']); + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $this->recreateSchema([$this->isMongoDB() ? DocumentChicken::class : Chicken::class, $this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class]); + $this->loadFixtures(); + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(): void + { + $manager = $this->getManager(); + + $chickenCoop1 = new ($this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class)(); + $chickenCoop2 = new ($this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class)(); + + $chicken1 = new ($this->isMongoDB() ? DocumentChicken::class : Chicken::class)(); + $chicken1->setName('Gertrude'); + $chicken1->setChickenCoop($chickenCoop1); + + $chicken2 = new ($this->isMongoDB() ? DocumentChicken::class : Chicken::class)(); + $chicken2->setName('Henriette'); + $chicken2->setChickenCoop($chickenCoop2); + + $chickenCoop1->addChicken($chicken1); + $chickenCoop2->addChicken($chicken2); + + $manager->persist($chicken1); + $manager->persist($chicken2); + $manager->persist($chickenCoop1); + $manager->persist($chickenCoop2); + $manager->flush(); + } +} From 69c0e5420bab650ae371e1caf3c51335f0274547 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Fri, 2 May 2025 14:16:27 +0200 Subject: [PATCH 2/9] fix: apply last reviews made in th latest pr Continues the work at #7079 and before at #6865 --- src/Doctrine/Odm/Filter/IriFilter.php | 62 +++++++++++++++++++ src/Doctrine/Orm/Filter/ExactSearchFilter.php | 0 src/Doctrine/Orm/Filter/IriFilter.php | 2 +- .../Orm/Filter/PartialSearchFilter.php | 0 .../Fixtures/TestBundle/Document/Chicken.php | 2 +- .../TestBundle/Document/ChickenCoop.php | 8 ++- .../Fixtures/TestBundle/Document/Company.php | 14 +++-- tests/Fixtures/TestBundle/Entity/Chicken.php | 2 +- .../TestBundle/Entity/ChickenCoop.php | 7 ++- tests/Fixtures/TestBundle/Entity/Company.php | 2 +- 10 files changed, 86 insertions(+), 13 deletions(-) create mode 100644 src/Doctrine/Odm/Filter/IriFilter.php delete mode 100644 src/Doctrine/Orm/Filter/ExactSearchFilter.php delete mode 100644 src/Doctrine/Orm/Filter/PartialSearchFilter.php diff --git a/src/Doctrine/Odm/Filter/IriFilter.php b/src/Doctrine/Odm/Filter/IriFilter.php new file mode 100644 index 00000000000..c2f5a406401 --- /dev/null +++ b/src/Doctrine/Odm/Filter/IriFilter.php @@ -0,0 +1,62 @@ + + * + * 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\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\ParameterProviderFilterInterface; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; +use ApiPlatform\State\Provider\IriConverterParameterProvider; +use Doctrine\ODM\MongoDB\Aggregation\Builder; + +final class IriFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface +{ + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + if (!$parameter = $context['parameter'] ?? null) { + return; + } + + \assert($parameter instanceof Parameter); + + $value = $parameter->getValue(); + if (!\is_array($value)) { + $value = [$value]; + } + + // TODO: do something for nested properties? + $matchField = $parameter->getProperty(); + + $aggregationBuilder + ->match() + ->field($matchField) + ->in($value); + } + + public static function getParameterProvider(): string + { + return IriConverterParameterProvider::class; + } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true); + } + + public function getDescription(string $resourceClass): array + { + return []; + } +} diff --git a/src/Doctrine/Orm/Filter/ExactSearchFilter.php b/src/Doctrine/Orm/Filter/ExactSearchFilter.php deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/Doctrine/Orm/Filter/IriFilter.php b/src/Doctrine/Orm/Filter/IriFilter.php index c13c7aedc77..0dd128c9239 100644 --- a/src/Doctrine/Orm/Filter/IriFilter.php +++ b/src/Doctrine/Orm/Filter/IriFilter.php @@ -22,7 +22,7 @@ use ApiPlatform\State\Provider\IriConverterParameterProvider; use Doctrine\ORM\QueryBuilder; -class IriFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface +final class IriFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface { public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { diff --git a/src/Doctrine/Orm/Filter/PartialSearchFilter.php b/src/Doctrine/Orm/Filter/PartialSearchFilter.php deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/Fixtures/TestBundle/Document/Chicken.php b/tests/Fixtures/TestBundle/Document/Chicken.php index 89f79de85c2..3378194f099 100644 --- a/tests/Fixtures/TestBundle/Document/Chicken.php +++ b/tests/Fixtures/TestBundle/Document/Chicken.php @@ -21,7 +21,7 @@ class Chicken { #[ODM\Id] - private string $id; + private ?string $id = null; #[ODM\Field(type: 'string')] private string $name; diff --git a/tests/Fixtures/TestBundle/Document/ChickenCoop.php b/tests/Fixtures/TestBundle/Document/ChickenCoop.php index 71ccb5204a8..50e925b8314 100644 --- a/tests/Fixtures/TestBundle/Document/ChickenCoop.php +++ b/tests/Fixtures/TestBundle/Document/ChickenCoop.php @@ -13,16 +13,18 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; -use ApiPlatform\Doctrine\Orm\Filter\IriFilter; +use ApiPlatform\Doctrine\Odm\Filter\IriFilter; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Chicken; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; #[ODM\Document] -#[GetCollection(normalizationContext: ['hydra_prefix' => false], parameters: ['chickens' => new QueryParameter(filter: new IriFilter())])] +#[GetCollection( + normalizationContext: ['hydra_prefix' => false], + parameters: ['chickens' => new QueryParameter(filter: new IriFilter())]) +] class ChickenCoop { #[ODM\Id] diff --git a/tests/Fixtures/TestBundle/Document/Company.php b/tests/Fixtures/TestBundle/Document/Company.php index a878dfad32d..6b3ab63fc03 100644 --- a/tests/Fixtures/TestBundle/Document/Company.php +++ b/tests/Fixtures/TestBundle/Document/Company.php @@ -22,16 +22,22 @@ use Symfony\Component\Serializer\Annotation\Groups; #[ApiResource] -#[GetCollection()] +#[GetCollection] #[Get] #[Post] -#[ApiResource(uriTemplate: '/employees/{employeeId}/rooms/{roomId}/company/{companyId}', uriVariables: ['employeeId' => ['from_class' => Employee::class, 'from_property' => 'company']])] +#[ApiResource( + uriTemplate: '/employees/{employeeId}/rooms/{roomId}/company/{companyId}', + uriVariables: ['employeeId' => ['from_class' => Employee::class, 'from_property' => 'company']] +)] #[Get] -#[ApiResource(uriTemplate: '/employees/{employeeId}/company', uriVariables: ['employeeId' => ['from_class' => Employee::class, 'from_property' => 'company']])] +#[ApiResource( + uriTemplate: '/employees/{employeeId}/company', + uriVariables: ['employeeId' => ['from_class' => Employee::class, 'from_property' => 'company']] +)] #[ODM\Document] class Company { - #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] private ?int $id = null; #[ODM\Field] diff --git a/tests/Fixtures/TestBundle/Entity/Chicken.php b/tests/Fixtures/TestBundle/Entity/Chicken.php index dea2bde7a58..603ecf1dba9 100644 --- a/tests/Fixtures/TestBundle/Entity/Chicken.php +++ b/tests/Fixtures/TestBundle/Entity/Chicken.php @@ -17,7 +17,7 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] -#[Get()] +#[Get] class Chicken { #[ORM\Id] diff --git a/tests/Fixtures/TestBundle/Entity/ChickenCoop.php b/tests/Fixtures/TestBundle/Entity/ChickenCoop.php index 0c821e660da..410261adb84 100644 --- a/tests/Fixtures/TestBundle/Entity/ChickenCoop.php +++ b/tests/Fixtures/TestBundle/Entity/ChickenCoop.php @@ -21,7 +21,10 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] -#[GetCollection(normalizationContext: ['hydra_prefix' => false], parameters: ['chickens' => new QueryParameter(filter: new IriFilter())])] +#[GetCollection( + normalizationContext: ['hydra_prefix' => false], + parameters: ['chickens' => new QueryParameter(filter: new IriFilter())] +)] class ChickenCoop { #[ORM\Id] @@ -29,7 +32,7 @@ class ChickenCoop #[ORM\Column(type: 'integer')] private ?int $id = null; - #[ORM\OneToMany(mappedBy: 'chickenCoop', targetEntity: Chicken::class, cascade: ['persist'])] + #[ORM\OneToMany(targetEntity: Chicken::class, mappedBy: 'chickenCoop', cascade: ['persist'])] private Collection $chickens; public function __construct() diff --git a/tests/Fixtures/TestBundle/Entity/Company.php b/tests/Fixtures/TestBundle/Entity/Company.php index a95a9731da2..26abb965a28 100644 --- a/tests/Fixtures/TestBundle/Entity/Company.php +++ b/tests/Fixtures/TestBundle/Entity/Company.php @@ -22,7 +22,7 @@ use Symfony\Component\Serializer\Annotation\Groups; #[ApiResource] -#[GetCollection()] +#[GetCollection] #[Get] #[Post] #[ApiResource( From 0e3e4efc0bbe10b703581b54c22ab69c9c0f4495 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Mon, 5 May 2025 06:39:32 +0200 Subject: [PATCH 3/9] feat(doctrine): add ORM ExactFilter Continues the work at #7079 and before at #6865 --- src/Doctrine/Orm/Filter/ExactFilter.php | 61 ++++++++ .../IriConverterParameterProvider.php | 14 +- .../TestBundle/Entity/DummyAuthorExact.php | 58 +++++++ .../TestBundle/Entity/DummyBookExact.php | 86 +++++++++++ .../Functional/Parameters/ExactFilterTest.php | 141 ++++++++++++++++++ 5 files changed, 355 insertions(+), 5 deletions(-) create mode 100644 src/Doctrine/Orm/Filter/ExactFilter.php create mode 100644 tests/Fixtures/TestBundle/Entity/DummyAuthorExact.php create mode 100644 tests/Fixtures/TestBundle/Entity/DummyBookExact.php create mode 100644 tests/Functional/Parameters/ExactFilterTest.php diff --git a/src/Doctrine/Orm/Filter/ExactFilter.php b/src/Doctrine/Orm/Filter/ExactFilter.php new file mode 100644 index 00000000000..3df9444aa1a --- /dev/null +++ b/src/Doctrine/Orm/Filter/ExactFilter.php @@ -0,0 +1,61 @@ + + * + * 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\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\ParameterProviderFilterInterface; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; +use ApiPlatform\State\Provider\IriConverterParameterProvider; +use Doctrine\ORM\QueryBuilder; + +final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface +{ + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if (!$parameter = $context['parameter'] ?? null) { + return; + } + + $value = $parameter->getValue(); + if (!\is_array($value)) { + $value = [$value]; + } + + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + $parameterName = $queryNameGenerator->generateParameterName($property); + + $queryBuilder + ->andWhere(\sprintf('%s.%s = :%s', $alias, $property, $parameterName)) + ->setParameter($parameterName, $value); + } + + public static function getParameterProvider(): string + { + return IriConverterParameterProvider::class; + } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true); + } + + public function getDescription(string $resourceClass): array + { + return []; + } +} diff --git a/src/State/Provider/IriConverterParameterProvider.php b/src/State/Provider/IriConverterParameterProvider.php index a7bc538472c..41509b54ad6 100644 --- a/src/State/Provider/IriConverterParameterProvider.php +++ b/src/State/Provider/IriConverterParameterProvider.php @@ -13,6 +13,7 @@ namespace ApiPlatform\State\Provider; +use ApiPlatform\Doctrine\Orm\Filter\IriFilter; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Parameter; @@ -32,17 +33,20 @@ public function __construct( public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation { $operation = $context['operation'] ?? null; - if (!($value = $parameter->getValue()) || $value instanceof ParameterNotFound) { + $parameterValue = $parameter->getValue(); + + $isParameterValueNotSet = !$parameterValue || $parameterValue instanceof ParameterNotFound; + if (!$parameter->getFilter() instanceof IriFilter || $isParameterValueNotSet) { return $operation; } - if (!\is_array($value)) { - $value = [$value]; + if (!\is_array($parameterValue)) { + $parameterValue = [$parameterValue]; } $entities = []; - foreach ($value as $v) { - $entities[] = $this->iriConverter->getResourceFromIri($v, ['fetch_data' => false]); + foreach ($parameterValue as $iri) { + $entities[] = $this->iriConverter->getResourceFromIri($iri, ['fetch_data' => false]); } $parameter->setValue($entities); diff --git a/tests/Fixtures/TestBundle/Entity/DummyAuthorExact.php b/tests/Fixtures/TestBundle/Entity/DummyAuthorExact.php new file mode 100644 index 00000000000..ebd5509e21f --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyAuthorExact.php @@ -0,0 +1,58 @@ + + * + * 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\Metadata\GetCollection; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +#[GetCollection] +#[ORM\Entity] +class DummyAuthorExact +{ + public function __construct( + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + #[ORM\Column] + public ?int $id = null, + + #[ORM\Column] + public ?string $name = null, + + #[ORM\OneToMany(targetEntity: DummyBookExact::class, mappedBy: 'dummyAuthorExact')] + public ?Collection $dummyBookExacts = new ArrayCollection(), + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getDummyBookExacts(): Collection + { + return $this->dummyBookExacts; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/DummyBookExact.php b/tests/Fixtures/TestBundle/Entity/DummyBookExact.php new file mode 100644 index 00000000000..145c68d2e3f --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyBookExact.php @@ -0,0 +1,86 @@ + + * + * 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\ExactFilter; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +#[GetCollection( + parameters: [ + 'dummyAuthorExact' => new QueryParameter( + filter: new ExactFilter() + ), + 'title' => new QueryParameter( + filter: new ExactFilter() + ), + ], +)] +#[ORM\Entity] +class DummyBookExact +{ + public function __construct( + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + #[ORM\Column] + public ?int $id = null, + + #[ORM\Column] + public ?string $title = null, + + #[ORM\Column] + public ?string $isbn = null, + + #[ORM\ManyToOne(targetEntity: DummyAuthorExact::class, inversedBy: 'dummyBookExacts')] + #[ORM\JoinColumn(nullable: false)] + public ?DummyAuthorExact $dummyAuthorExact = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getIsbn(): string + { + return $this->isbn; + } + + public function setIsbn(string $isbn): void + { + $this->isbn = $isbn; + } + + public function getDummyAuthorExact(): DummyAuthorExact + { + return $this->dummyAuthorExact; + } + + public function setDummyAuthorExact(DummyAuthorExact $dummyAuthorExact): void + { + $this->dummyAuthorExact = $dummyAuthorExact; + } +} diff --git a/tests/Functional/Parameters/ExactFilterTest.php b/tests/Functional/Parameters/ExactFilterTest.php new file mode 100644 index 00000000000..ffb7c816cff --- /dev/null +++ b/tests/Functional/Parameters/ExactFilterTest.php @@ -0,0 +1,141 @@ + + * + * 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\DummyAuthorExact as DummyAuthorExactDocument; +// use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyBookExact as DummyBookExactDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyAuthorExact; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyBookExact; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; + +final class ExactFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DummyBookExact::class, DummyAuthorExact::class]; + } + + /** + * @throws MongoDBException + * @throws \Throwable + */ + protected function setUp(): void + { + // TODO: implement ODM classes + $authorEntityClass = $this->isMongoDB() ? /* DummyAuthorExactDocument::class */ : DummyAuthorExact::class; + $bookEntityClass = $this->isMongoDB() ? /* DummyBookExactDocument::class */ : DummyBookExact::class; + + $this->recreateSchema([$authorEntityClass, $bookEntityClass]); + $this->loadFixtures($authorEntityClass, $bookEntityClass); + } + + /** + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + * @throws TransportExceptionInterface + */ + #[DataProvider('exactSearchFilterProvider')] + public function testExactSearchFilter(string $url, int $expectedCount, array $expectedTitles): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + + $titles = array_map(fn ($book) => $book['title'], $filteredItems); + sort($titles); + sort($expectedTitles); + + $this->assertSame($expectedTitles, $titles, 'The titles do not match the expected values.'); + } + + public static function exactSearchFilterProvider(): \Generator + { + yield 'filter_by_author_exact_id_1' => [ + '/dummy_book_exacts?dummyAuthorExact=1', + 2, + ['Book 1', 'Book 2'], + ]; + yield 'filter_by_author_exact_id_1_and_title_book_1' => [ + '/dummy_book_exacts?dummyAuthorExact=1&title=Book 1', + 1, + ['Book 1'], + ]; + yield 'filter_by_author_exact_id_1_and_title_book_3' => [ + '/dummy_book_exacts?dummyAuthorExact=1&title=Book 3', + 0, + [], + ]; + yield 'filter_by_author_exact_id_3_and_title_book_3' => [ + '/dummy_book_exacts?dummyAuthorExact=2&title=Book 3', + 1, + ['Book 3'], + ]; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(string $authorEntityClass, string $bookEntityClass): void + { + $manager = $this->getManager(); + + $authors = []; + foreach ([['name' => 'Author 1'], ['name' => 'Author 2']] as $authorData) { + $author = new $authorEntityClass(name: $authorData['name']); + $manager->persist($author); + $authors[] = $author; + } + + $books = [ + ['title' => 'Book 1', 'isbn' => '1234567890123', 'author' => $authors[0]], + ['title' => 'Book 2', 'isbn' => '1234567890124', 'author' => $authors[0]], + ['title' => 'Book 3', 'isbn' => '1234567890125', 'author' => $authors[1]], + ]; + + foreach ($books as $bookData) { + $book = new $bookEntityClass( + title: $bookData['title'], + isbn: $bookData['isbn'], + dummyAuthorExact: $bookData['author'] + ); + + $author->dummyBookExacts->add($book); + $manager->persist($book); + } + + $manager->flush(); + } +} From 5eb597151d4457e3f7a400bc1c306b468d2bca22 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Thu, 5 Jun 2025 16:57:02 +0200 Subject: [PATCH 4/9] feat(doctrine): add ORM PartialSearchFilter Continues the work at #7079 and before at #6865 --- .../Orm/Filter/PartialSearchFilter.php | 65 ++++++++ .../TestBundle/Entity/DummyAuthorPartial.php | 58 +++++++ .../TestBundle/Entity/DummyBookPartial.php | 83 ++++++++++ .../Parameters/PartialSearchFilterTest.php | 142 ++++++++++++++++++ 4 files changed, 348 insertions(+) create mode 100644 src/Doctrine/Orm/Filter/PartialSearchFilter.php create mode 100644 tests/Fixtures/TestBundle/Entity/DummyAuthorPartial.php create mode 100644 tests/Fixtures/TestBundle/Entity/DummyBookPartial.php create mode 100644 tests/Functional/Parameters/PartialSearchFilterTest.php diff --git a/src/Doctrine/Orm/Filter/PartialSearchFilter.php b/src/Doctrine/Orm/Filter/PartialSearchFilter.php new file mode 100644 index 00000000000..5417667d2b7 --- /dev/null +++ b/src/Doctrine/Orm/Filter/PartialSearchFilter.php @@ -0,0 +1,65 @@ + + * + * 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\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\ParameterProviderFilterInterface; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; +use ApiPlatform\State\Provider\IriConverterParameterProvider; +use Doctrine\ORM\QueryBuilder; + +final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface +{ + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if (!$parameter = $context['parameter'] ?? null) { + return; + } + + $value = $parameter->getValue(); + + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + $field = $alias.'.'.$property; + + $parameterName = $queryNameGenerator->generateParameterName($property); + + $likeExpression = $queryBuilder->expr()->like( + 'LOWER('.$field.')', + ':'.$parameterName + ); + + $queryBuilder + ->andWhere($likeExpression) + ->setParameter($parameterName, '%'.strtolower($value).'%'); + } + + public static function getParameterProvider(): string + { + return IriConverterParameterProvider::class; + } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true); + } + + public function getDescription(string $resourceClass): array + { + return []; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/DummyAuthorPartial.php b/tests/Fixtures/TestBundle/Entity/DummyAuthorPartial.php new file mode 100644 index 00000000000..5e620bf8759 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyAuthorPartial.php @@ -0,0 +1,58 @@ + + * + * 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\Metadata\GetCollection; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +#[GetCollection] +#[ORM\Entity] +class DummyAuthorPartial +{ + public function __construct( + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + #[ORM\Column] + public ?int $id = null, + + #[ORM\Column] + public ?string $name = null, + + #[ORM\OneToMany(targetEntity: DummyBookPartial::class, mappedBy: 'dummyAuthorPartial')] + public ?Collection $dummyBookPartials = new ArrayCollection(), + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getDummyBookPartials(): Collection + { + return $this->dummyBookPartials; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/DummyBookPartial.php b/tests/Fixtures/TestBundle/Entity/DummyBookPartial.php new file mode 100644 index 00000000000..55e1768d53f --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyBookPartial.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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +#[GetCollection( + parameters: [ + 'title' => new QueryParameter( + filter: new PartialSearchFilter() + ), + ], +)] +#[ORM\Entity] +class DummyBookPartial +{ + public function __construct( + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + #[ORM\Column] + public ?int $id = null, + + #[ORM\Column] + public ?string $title = null, + + #[ORM\Column] + public ?string $isbn = null, + + #[ORM\ManyToOne(targetEntity: DummyAuthorPartial::class, inversedBy: 'dummyBookPartials')] + #[ORM\JoinColumn(nullable: false)] + public ?DummyAuthorPartial $dummyAuthorPartial = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getIsbn(): string + { + return $this->isbn; + } + + public function setIsbn(string $isbn): void + { + $this->isbn = $isbn; + } + + public function getDummyAuthorPartial(): DummyAuthorPartial + { + return $this->dummyAuthorPartial; + } + + public function setDummyAuthorPartial(DummyAuthorPartial $dummyAuthorPartial): void + { + $this->dummyAuthorPartial = $dummyAuthorPartial; + } +} diff --git a/tests/Functional/Parameters/PartialSearchFilterTest.php b/tests/Functional/Parameters/PartialSearchFilterTest.php new file mode 100644 index 00000000000..d078b15686d --- /dev/null +++ b/tests/Functional/Parameters/PartialSearchFilterTest.php @@ -0,0 +1,142 @@ + + * + * 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\DummyAuthorPartial as DummyAuthorPartialDocument; +// use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyBookPartial as DummyBookPartialDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyAuthorPartial; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyBookPartial; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; + +final class PartialSearchFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DummyBookPartial::class, DummyAuthorPartial::class]; + } + + /** + * @throws MongoDBException + * @throws \Throwable + */ + protected function setUp(): void + { + // TODO: implement ODM classes + $authorEntityClass = $this->isMongoDB() ? DummyAuthorPartialDocument::class : DummyAuthorPartial::class; + $bookEntityClass = $this->isMongoDB() ? DummyBookPartialDocument::class : DummyBookPartial::class; + + $this->recreateSchema([$authorEntityClass, $bookEntityClass]); + $this->loadFixtures($authorEntityClass, $bookEntityClass); + } + + /** + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + * @throws TransportExceptionInterface + */ + #[DataProvider('partialSearchFilterProvider')] + public function testPartialSearchFilter(string $url, int $expectedCount, array $expectedTerms): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + + $titles = array_map(fn ($book) => $book['title'], $filteredItems); + foreach ($titles as $expectedTitle) { + $this->assertContains($expectedTitle, $titles, \sprintf('The title "%s" was not found in the results.', $expectedTitle)); + } + } + + public static function partialSearchFilterProvider(): \Generator + { + yield 'filter_by_partial_title_term_book' => [ + '/dummy_book_partials?title=Book', + 3, + ['Book'], + ]; + yield 'filter_by_partial_title_term_1' => [ + '/dummy_book_partials?title=1', + 1, + ['Book 1'], + ]; + yield 'filter_by_partial_title_term_3' => [ + '/dummy_book_partials?title=3', + 1, + ['Book 3'], + ]; + yield 'filter_by_partial_title_with_no_matching_entities' => [ + '/dummy_book_partials?title=99', + 0, + [], + ]; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(string $authorEntityClass, string $bookEntityClass): void + { + $manager = $this->getManager(); + + $authors = []; + foreach ([['name' => 'Author 1'], ['name' => 'Author 2']] as $authorData) { + /** @var DummyAuthorPartial|DummyAuthorPartialDocument $author */ + $author = new $authorEntityClass(name: $authorData['name']); + $manager->persist($author); + $authors[] = $author; + } + + $books = [ + ['title' => 'Book 1', 'isbn' => '1234567890123', 'author' => $authors[0]], + ['title' => 'Book 2', 'isbn' => '1234567890124', 'author' => $authors[0]], + ['title' => 'Book 3', 'isbn' => '1234567890125', 'author' => $authors[1]], + ]; + + foreach ($books as $bookData) { + /** @var DummyBookPartial|DummyBookPartialDocument $book */ + $book = new $bookEntityClass( + title: $bookData['title'], + isbn: $bookData['isbn'], + dummyAuthorPartial: $bookData['author'] + ); + + $author->dummyBookPartials->add($book); + $manager->persist($book); + } + + $manager->flush(); + } +} From cbdfa9311d4c23a3f00ed030f4b93824eaaa61d4 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Sun, 8 Jun 2025 08:36:42 +0200 Subject: [PATCH 5/9] refactor(test): unifies fixtures for filter Continues the work at #7079 and before at #6865 --- .../Fixtures/TestBundle/Document/Chicken.php | 16 ++- tests/Fixtures/TestBundle/Entity/Chicken.php | 16 ++- .../TestBundle/Entity/DummyAuthorExact.php | 58 ---------- .../TestBundle/Entity/DummyAuthorPartial.php | 58 ---------- .../TestBundle/Entity/DummyBookExact.php | 86 -------------- .../TestBundle/Entity/DummyBookPartial.php | 83 -------------- .../Functional/Parameters/ExactFilterTest.php | 106 ++++++++++-------- .../Parameters/PartialSearchFilterTest.php | 103 ++++++++--------- 8 files changed, 137 insertions(+), 389 deletions(-) delete mode 100644 tests/Fixtures/TestBundle/Entity/DummyAuthorExact.php delete mode 100644 tests/Fixtures/TestBundle/Entity/DummyAuthorPartial.php delete mode 100644 tests/Fixtures/TestBundle/Entity/DummyBookExact.php delete mode 100644 tests/Fixtures/TestBundle/Entity/DummyBookPartial.php diff --git a/tests/Fixtures/TestBundle/Document/Chicken.php b/tests/Fixtures/TestBundle/Document/Chicken.php index 3378194f099..e556219d098 100644 --- a/tests/Fixtures/TestBundle/Document/Chicken.php +++ b/tests/Fixtures/TestBundle/Document/Chicken.php @@ -13,11 +13,23 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; -use ApiPlatform\Metadata\Get; +use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; #[ODM\Document] -#[Get] +#[GetCollection( + parameters: [ + 'chickenCoop' => new QueryParameter(filter: new ExactFilter()), + 'name' => new QueryParameter(filter: new ExactFilter()), + 'namePartial' => new QueryParameter( + filter: new PartialSearchFilter(), + property: 'name', + ), + ], +)] class Chicken { #[ODM\Id] diff --git a/tests/Fixtures/TestBundle/Entity/Chicken.php b/tests/Fixtures/TestBundle/Entity/Chicken.php index 603ecf1dba9..9d497676608 100644 --- a/tests/Fixtures/TestBundle/Entity/Chicken.php +++ b/tests/Fixtures/TestBundle/Entity/Chicken.php @@ -13,11 +13,23 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; -use ApiPlatform\Metadata\Get; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] -#[Get] +#[GetCollection( + parameters: [ + 'chickenCoop' => new QueryParameter(filter: new ExactFilter()), + 'name' => new QueryParameter(filter: new ExactFilter()), + 'namePartial' => new QueryParameter( + filter: new PartialSearchFilter(), + property: 'name', + ), + ], +)] class Chicken { #[ORM\Id] diff --git a/tests/Fixtures/TestBundle/Entity/DummyAuthorExact.php b/tests/Fixtures/TestBundle/Entity/DummyAuthorExact.php deleted file mode 100644 index ebd5509e21f..00000000000 --- a/tests/Fixtures/TestBundle/Entity/DummyAuthorExact.php +++ /dev/null @@ -1,58 +0,0 @@ - - * - * 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\Metadata\GetCollection; -use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\Common\Collections\Collection; -use Doctrine\ORM\Mapping as ORM; - -#[GetCollection] -#[ORM\Entity] -class DummyAuthorExact -{ - public function __construct( - #[ORM\Id] - #[ORM\GeneratedValue(strategy: 'AUTO')] - #[ORM\Column] - public ?int $id = null, - - #[ORM\Column] - public ?string $name = null, - - #[ORM\OneToMany(targetEntity: DummyBookExact::class, mappedBy: 'dummyAuthorExact')] - public ?Collection $dummyBookExacts = new ArrayCollection(), - ) { - } - - public function getId(): ?int - { - return $this->id; - } - - public function getName(): string - { - return $this->name; - } - - public function setName(string $name): void - { - $this->name = $name; - } - - public function getDummyBookExacts(): Collection - { - return $this->dummyBookExacts; - } -} diff --git a/tests/Fixtures/TestBundle/Entity/DummyAuthorPartial.php b/tests/Fixtures/TestBundle/Entity/DummyAuthorPartial.php deleted file mode 100644 index 5e620bf8759..00000000000 --- a/tests/Fixtures/TestBundle/Entity/DummyAuthorPartial.php +++ /dev/null @@ -1,58 +0,0 @@ - - * - * 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\Metadata\GetCollection; -use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\Common\Collections\Collection; -use Doctrine\ORM\Mapping as ORM; - -#[GetCollection] -#[ORM\Entity] -class DummyAuthorPartial -{ - public function __construct( - #[ORM\Id] - #[ORM\GeneratedValue(strategy: 'AUTO')] - #[ORM\Column] - public ?int $id = null, - - #[ORM\Column] - public ?string $name = null, - - #[ORM\OneToMany(targetEntity: DummyBookPartial::class, mappedBy: 'dummyAuthorPartial')] - public ?Collection $dummyBookPartials = new ArrayCollection(), - ) { - } - - public function getId(): ?int - { - return $this->id; - } - - public function getName(): string - { - return $this->name; - } - - public function setName(string $name): void - { - $this->name = $name; - } - - public function getDummyBookPartials(): Collection - { - return $this->dummyBookPartials; - } -} diff --git a/tests/Fixtures/TestBundle/Entity/DummyBookExact.php b/tests/Fixtures/TestBundle/Entity/DummyBookExact.php deleted file mode 100644 index 145c68d2e3f..00000000000 --- a/tests/Fixtures/TestBundle/Entity/DummyBookExact.php +++ /dev/null @@ -1,86 +0,0 @@ - - * - * 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\ExactFilter; -use ApiPlatform\Metadata\GetCollection; -use ApiPlatform\Metadata\QueryParameter; -use Doctrine\ORM\Mapping as ORM; - -#[GetCollection( - parameters: [ - 'dummyAuthorExact' => new QueryParameter( - filter: new ExactFilter() - ), - 'title' => new QueryParameter( - filter: new ExactFilter() - ), - ], -)] -#[ORM\Entity] -class DummyBookExact -{ - public function __construct( - #[ORM\Id] - #[ORM\GeneratedValue(strategy: 'AUTO')] - #[ORM\Column] - public ?int $id = null, - - #[ORM\Column] - public ?string $title = null, - - #[ORM\Column] - public ?string $isbn = null, - - #[ORM\ManyToOne(targetEntity: DummyAuthorExact::class, inversedBy: 'dummyBookExacts')] - #[ORM\JoinColumn(nullable: false)] - public ?DummyAuthorExact $dummyAuthorExact = null, - ) { - } - - public function getId(): ?int - { - return $this->id; - } - - public function getTitle(): string - { - return $this->title; - } - - public function setTitle(string $title): void - { - $this->title = $title; - } - - public function getIsbn(): string - { - return $this->isbn; - } - - public function setIsbn(string $isbn): void - { - $this->isbn = $isbn; - } - - public function getDummyAuthorExact(): DummyAuthorExact - { - return $this->dummyAuthorExact; - } - - public function setDummyAuthorExact(DummyAuthorExact $dummyAuthorExact): void - { - $this->dummyAuthorExact = $dummyAuthorExact; - } -} diff --git a/tests/Fixtures/TestBundle/Entity/DummyBookPartial.php b/tests/Fixtures/TestBundle/Entity/DummyBookPartial.php deleted file mode 100644 index 55e1768d53f..00000000000 --- a/tests/Fixtures/TestBundle/Entity/DummyBookPartial.php +++ /dev/null @@ -1,83 +0,0 @@ - - * - * 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\PartialSearchFilter; -use ApiPlatform\Metadata\GetCollection; -use ApiPlatform\Metadata\QueryParameter; -use Doctrine\ORM\Mapping as ORM; - -#[GetCollection( - parameters: [ - 'title' => new QueryParameter( - filter: new PartialSearchFilter() - ), - ], -)] -#[ORM\Entity] -class DummyBookPartial -{ - public function __construct( - #[ORM\Id] - #[ORM\GeneratedValue(strategy: 'AUTO')] - #[ORM\Column] - public ?int $id = null, - - #[ORM\Column] - public ?string $title = null, - - #[ORM\Column] - public ?string $isbn = null, - - #[ORM\ManyToOne(targetEntity: DummyAuthorPartial::class, inversedBy: 'dummyBookPartials')] - #[ORM\JoinColumn(nullable: false)] - public ?DummyAuthorPartial $dummyAuthorPartial = null, - ) { - } - - public function getId(): ?int - { - return $this->id; - } - - public function getTitle(): string - { - return $this->title; - } - - public function setTitle(string $title): void - { - $this->title = $title; - } - - public function getIsbn(): string - { - return $this->isbn; - } - - public function setIsbn(string $isbn): void - { - $this->isbn = $isbn; - } - - public function getDummyAuthorPartial(): DummyAuthorPartial - { - return $this->dummyAuthorPartial; - } - - public function setDummyAuthorPartial(DummyAuthorPartial $dummyAuthorPartial): void - { - $this->dummyAuthorPartial = $dummyAuthorPartial; - } -} diff --git a/tests/Functional/Parameters/ExactFilterTest.php b/tests/Functional/Parameters/ExactFilterTest.php index ffb7c816cff..c312c6db15a 100644 --- a/tests/Functional/Parameters/ExactFilterTest.php +++ b/tests/Functional/Parameters/ExactFilterTest.php @@ -14,10 +14,10 @@ namespace ApiPlatform\Tests\Functional\Parameters; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; -// use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyAuthorExact as DummyAuthorExactDocument; -// use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyBookExact as DummyBookExactDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyAuthorExact; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyBookExact; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Chicken as DocumentChicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ChickenCoop as DocumentChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Chicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ChickenCoop; use ApiPlatform\Tests\RecreateSchemaTrait; use ApiPlatform\Tests\SetupClassResourcesTrait; use Doctrine\ODM\MongoDB\MongoDBException; @@ -38,21 +38,20 @@ final class ExactFilterTest extends ApiTestCase */ public static function getResources(): array { - return [DummyBookExact::class, DummyAuthorExact::class]; + return [Chicken::class, ChickenCoop::class]; } /** - * @throws MongoDBException * @throws \Throwable */ protected function setUp(): void { - // TODO: implement ODM classes - $authorEntityClass = $this->isMongoDB() ? /* DummyAuthorExactDocument::class */ : DummyAuthorExact::class; - $bookEntityClass = $this->isMongoDB() ? /* DummyBookExactDocument::class */ : DummyBookExact::class; + $entities = $this->isMongoDB() + ? [DocumentChicken::class, DocumentChickenCoop::class] + : [Chicken::class, ChickenCoop::class]; - $this->recreateSchema([$authorEntityClass, $bookEntityClass]); - $this->loadFixtures($authorEntityClass, $bookEntityClass); + $this->recreateSchema($entities); + $this->loadFixtures(); } /** @@ -63,7 +62,7 @@ protected function setUp(): void * @throws TransportExceptionInterface */ #[DataProvider('exactSearchFilterProvider')] - public function testExactSearchFilter(string $url, int $expectedCount, array $expectedTitles): void + public function testExactSearchFilter(string $url, int $expectedCount, array $expectedNames): void { $response = self::createClient()->request('GET', $url); $this->assertResponseIsSuccessful(); @@ -73,34 +72,43 @@ public function testExactSearchFilter(string $url, int $expectedCount, array $ex $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); - $titles = array_map(fn ($book) => $book['title'], $filteredItems); - sort($titles); - sort($expectedTitles); + $names = array_map(fn ($chicken) => $chicken['name'], $filteredItems); + sort($names); + sort($expectedNames); - $this->assertSame($expectedTitles, $titles, 'The titles do not match the expected values.'); + $this->assertSame($expectedNames, $names, 'The names do not match the expected values.'); } public static function exactSearchFilterProvider(): \Generator { - yield 'filter_by_author_exact_id_1' => [ - '/dummy_book_exacts?dummyAuthorExact=1', - 2, - ['Book 1', 'Book 2'], - ]; - yield 'filter_by_author_exact_id_1_and_title_book_1' => [ - '/dummy_book_exacts?dummyAuthorExact=1&title=Book 1', + yield 'filter by exact name "Gertrude"' => [ + '/chickens?name=Gertrude', 1, - ['Book 1'], + ['Gertrude'], ]; - yield 'filter_by_author_exact_id_1_and_title_book_3' => [ - '/dummy_book_exacts?dummyAuthorExact=1&title=Book 3', + + yield 'filter by a non-existent name' => [ + '/chickens?name=Kevin', 0, [], ]; - yield 'filter_by_author_exact_id_3_and_title_book_3' => [ - '/dummy_book_exacts?dummyAuthorExact=2&title=Book 3', + + yield 'filter by exact coop id' => [ + '/chickens?chickenCoop=1', + 1, + ['Gertrude'], + ]; + + yield 'filter by coop id and correct name' => [ + '/chickens?chickenCoop=1&name=Gertrude', 1, - ['Book 3'], + ['Gertrude'], + ]; + + yield 'filter by coop id and incorrect name' => [ + '/chickens?chickenCoop=1&name=Henriette', + 0, + [], ]; } @@ -108,34 +116,34 @@ public static function exactSearchFilterProvider(): \Generator * @throws \Throwable * @throws MongoDBException */ - private function loadFixtures(string $authorEntityClass, string $bookEntityClass): void + private function loadFixtures(): void { $manager = $this->getManager(); - $authors = []; - foreach ([['name' => 'Author 1'], ['name' => 'Author 2']] as $authorData) { - $author = new $authorEntityClass(name: $authorData['name']); - $manager->persist($author); - $authors[] = $author; - } + $chickenClass = $this->isMongoDB() ? DocumentChicken::class : Chicken::class; + $coopClass = $this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class; - $books = [ - ['title' => 'Book 1', 'isbn' => '1234567890123', 'author' => $authors[0]], - ['title' => 'Book 2', 'isbn' => '1234567890124', 'author' => $authors[0]], - ['title' => 'Book 3', 'isbn' => '1234567890125', 'author' => $authors[1]], - ]; + $chickenCoop1 = new $coopClass(); + $chickenCoop2 = new $coopClass(); + + $chicken1 = new $chickenClass(); + $chicken1->setName('Gertrude'); + $chicken1->setChickenCoop($chickenCoop1); - foreach ($books as $bookData) { - $book = new $bookEntityClass( - title: $bookData['title'], - isbn: $bookData['isbn'], - dummyAuthorExact: $bookData['author'] - ); + $chicken2 = new $chickenClass(); + $chicken2->setName('Henriette'); + $chicken2->setChickenCoop($chickenCoop2); - $author->dummyBookExacts->add($book); - $manager->persist($book); + if (method_exists($chickenCoop1, 'addChicken')) { + $chickenCoop1->addChicken($chicken1); + $chickenCoop2->addChicken($chicken2); } + $manager->persist($chickenCoop1); + $manager->persist($chickenCoop2); + $manager->persist($chicken1); + $manager->persist($chicken2); + $manager->flush(); } } diff --git a/tests/Functional/Parameters/PartialSearchFilterTest.php b/tests/Functional/Parameters/PartialSearchFilterTest.php index d078b15686d..8c235fcfcba 100644 --- a/tests/Functional/Parameters/PartialSearchFilterTest.php +++ b/tests/Functional/Parameters/PartialSearchFilterTest.php @@ -14,10 +14,10 @@ namespace ApiPlatform\Tests\Functional\Parameters; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; -// use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyAuthorPartial as DummyAuthorPartialDocument; -// use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyBookPartial as DummyBookPartialDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyAuthorPartial; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyBookPartial; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Chicken as DocumentChicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ChickenCoop as DocumentChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Chicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ChickenCoop; use ApiPlatform\Tests\RecreateSchemaTrait; use ApiPlatform\Tests\SetupClassResourcesTrait; use Doctrine\ODM\MongoDB\MongoDBException; @@ -38,21 +38,20 @@ final class PartialSearchFilterTest extends ApiTestCase */ public static function getResources(): array { - return [DummyBookPartial::class, DummyAuthorPartial::class]; + return [Chicken::class, ChickenCoop::class]; } /** - * @throws MongoDBException * @throws \Throwable */ protected function setUp(): void { - // TODO: implement ODM classes - $authorEntityClass = $this->isMongoDB() ? DummyAuthorPartialDocument::class : DummyAuthorPartial::class; - $bookEntityClass = $this->isMongoDB() ? DummyBookPartialDocument::class : DummyBookPartial::class; + $entities = $this->isMongoDB() + ? [DocumentChicken::class, DocumentChickenCoop::class] + : [Chicken::class, ChickenCoop::class]; - $this->recreateSchema([$authorEntityClass, $bookEntityClass]); - $this->loadFixtures($authorEntityClass, $bookEntityClass); + $this->recreateSchema($entities); + $this->loadFixtures(); } /** @@ -63,7 +62,7 @@ protected function setUp(): void * @throws TransportExceptionInterface */ #[DataProvider('partialSearchFilterProvider')] - public function testPartialSearchFilter(string $url, int $expectedCount, array $expectedTerms): void + public function testPartialSearchFilter(string $url, int $expectedCount, array $expectedNames): void { $response = self::createClient()->request('GET', $url); $this->assertResponseIsSuccessful(); @@ -73,31 +72,35 @@ public function testPartialSearchFilter(string $url, int $expectedCount, array $ $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); - $titles = array_map(fn ($book) => $book['title'], $filteredItems); - foreach ($titles as $expectedTitle) { - $this->assertContains($expectedTitle, $titles, \sprintf('The title "%s" was not found in the results.', $expectedTitle)); - } + $names = array_map(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 partialSearchFilterProvider(): \Generator { - yield 'filter_by_partial_title_term_book' => [ - '/dummy_book_partials?title=Book', - 3, - ['Book'], - ]; - yield 'filter_by_partial_title_term_1' => [ - '/dummy_book_partials?title=1', + yield 'filter by partial name "ertrude"' => [ + '/chickens?namePartial=ertrude', 1, - ['Book 1'], + ['Gertrude'], ]; - yield 'filter_by_partial_title_term_3' => [ - '/dummy_book_partials?title=3', + + yield 'filter by partial name "riette"' => [ + '/chickens?namePartial=riette', 1, - ['Book 3'], + ['Henriette'], + ]; + + yield 'filter by partial name "e" (should match both)' => [ + '/chickens?namePartial=e', + 2, + ['Gertrude', 'Henriette'], ]; - yield 'filter_by_partial_title_with_no_matching_entities' => [ - '/dummy_book_partials?title=99', + + yield 'filter by partial name with no matching entities' => [ + '/chickens?namePartial=Zebra', 0, [], ]; @@ -107,36 +110,34 @@ public static function partialSearchFilterProvider(): \Generator * @throws \Throwable * @throws MongoDBException */ - private function loadFixtures(string $authorEntityClass, string $bookEntityClass): void + private function loadFixtures(): void { $manager = $this->getManager(); - $authors = []; - foreach ([['name' => 'Author 1'], ['name' => 'Author 2']] as $authorData) { - /** @var DummyAuthorPartial|DummyAuthorPartialDocument $author */ - $author = new $authorEntityClass(name: $authorData['name']); - $manager->persist($author); - $authors[] = $author; - } + $chickenClass = $this->isMongoDB() ? DocumentChicken::class : Chicken::class; + $coopClass = $this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class; - $books = [ - ['title' => 'Book 1', 'isbn' => '1234567890123', 'author' => $authors[0]], - ['title' => 'Book 2', 'isbn' => '1234567890124', 'author' => $authors[0]], - ['title' => 'Book 3', 'isbn' => '1234567890125', 'author' => $authors[1]], - ]; + $chickenCoop1 = new $coopClass(); + $chickenCoop2 = new $coopClass(); + + $chicken1 = new $chickenClass(); + $chicken1->setName('Gertrude'); + $chicken1->setChickenCoop($chickenCoop1); - foreach ($books as $bookData) { - /** @var DummyBookPartial|DummyBookPartialDocument $book */ - $book = new $bookEntityClass( - title: $bookData['title'], - isbn: $bookData['isbn'], - dummyAuthorPartial: $bookData['author'] - ); + $chicken2 = new $chickenClass(); + $chicken2->setName('Henriette'); + $chicken2->setChickenCoop($chickenCoop2); - $author->dummyBookPartials->add($book); - $manager->persist($book); + if (method_exists($chickenCoop1, 'addChicken')) { + $chickenCoop1->addChicken($chicken1); + $chickenCoop2->addChicken($chicken2); } + $manager->persist($chickenCoop1); + $manager->persist($chickenCoop2); + $manager->persist($chicken1); + $manager->persist($chicken2); + $manager->flush(); } } From 2dbf7a84b6012579ec82ab4ed539246db9b875af Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Sun, 8 Jun 2025 14:58:15 +0200 Subject: [PATCH 6/9] feat(doctrine): finish ODM IriFilter Continues the work at #7079 and before at #6865 --- src/Doctrine/Odm/Filter/IriFilter.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Doctrine/Odm/Filter/IriFilter.php b/src/Doctrine/Odm/Filter/IriFilter.php index c2f5a406401..67d8f2be221 100644 --- a/src/Doctrine/Odm/Filter/IriFilter.php +++ b/src/Doctrine/Odm/Filter/IriFilter.php @@ -36,7 +36,6 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera $value = [$value]; } - // TODO: do something for nested properties? $matchField = $parameter->getProperty(); $aggregationBuilder From fa71e6ed348fc8b147972de59c647ea8f2f922f6 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Sun, 8 Jun 2025 14:59:06 +0200 Subject: [PATCH 7/9] feat(doctrine): add ODM ExactFilter Continues the work at #7079 and before at #6865 --- src/Doctrine/Odm/Filter/ExactFilter.php | 56 +++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/Doctrine/Odm/Filter/ExactFilter.php diff --git a/src/Doctrine/Odm/Filter/ExactFilter.php b/src/Doctrine/Odm/Filter/ExactFilter.php new file mode 100644 index 00000000000..13ab138684f --- /dev/null +++ b/src/Doctrine/Odm/Filter/ExactFilter.php @@ -0,0 +1,56 @@ + + * + * 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\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; +use Doctrine\ODM\MongoDB\Aggregation\Builder; + +final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + if (!$parameter = $context['parameter'] ?? null) { + return; + } + + \assert($parameter instanceof Parameter); + + $values = (array) $parameter->getValue(); + if ([] === $values) { + return; + } + + $matchField = $parameter->getProperty(); + $fieldQuery = $aggregationBuilder->match()->field($matchField); + + if (\count($values) > 1) { + $fieldQuery->in($values); + } else { + $fieldQuery->equals(reset($values)); + } + } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true); + } + + public function getDescription(string $resourceClass): array + { + return []; + } +} From 8cd123665fb6439fc0d4b95868b9fbb7d78a6cfb Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Sun, 8 Jun 2025 14:59:15 +0200 Subject: [PATCH 8/9] feat(doctrine): add ODM PartialSearchFilter Continues the work at #7079 and before at #6865 --- .../Odm/Filter/PartialSearchFilter.php | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/Doctrine/Odm/Filter/PartialSearchFilter.php diff --git a/src/Doctrine/Odm/Filter/PartialSearchFilter.php b/src/Doctrine/Odm/Filter/PartialSearchFilter.php new file mode 100644 index 00000000000..2f08198211f --- /dev/null +++ b/src/Doctrine/Odm/Filter/PartialSearchFilter.php @@ -0,0 +1,56 @@ + + * + * 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\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use MongoDB\BSON\Regex; + +final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + if (!$parameter = $context['parameter'] ?? null) { + return; + } + + \assert($parameter instanceof Parameter); + + $value = $parameter->getValue(); + if (!\is_string($value) || '' === $value) { + return; + } + + $matchField = $parameter->getProperty(); + $escapedValue = preg_quote($value, '/'); + + $aggregationBuilder + ->match() + ->field($matchField) + ->equals(new Regex($escapedValue, 'i')); + } + + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true); + } + + public function getDescription(string $resourceClass): array + { + return []; + } +} From 434be519f9cd61d97001665e7ca1d0a85b37c4cb Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Sun, 8 Jun 2025 15:00:19 +0200 Subject: [PATCH 9/9] refactor(doctrine): remove dead code Continues the work at #7079 and before at #6865 --- src/Doctrine/Orm/Filter/ExactFilter.php | 9 +-------- src/Doctrine/Orm/Filter/PartialSearchFilter.php | 9 +-------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/src/Doctrine/Orm/Filter/ExactFilter.php b/src/Doctrine/Orm/Filter/ExactFilter.php index 3df9444aa1a..89e4c368381 100644 --- a/src/Doctrine/Orm/Filter/ExactFilter.php +++ b/src/Doctrine/Orm/Filter/ExactFilter.php @@ -17,12 +17,10 @@ use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Parameter; -use ApiPlatform\Metadata\ParameterProviderFilterInterface; use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; -use ApiPlatform\State\Provider\IriConverterParameterProvider; use Doctrine\ORM\QueryBuilder; -final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface +final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface { public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { @@ -44,11 +42,6 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q ->setParameter($parameterName, $value); } - public static function getParameterProvider(): string - { - return IriConverterParameterProvider::class; - } - public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null { return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true); diff --git a/src/Doctrine/Orm/Filter/PartialSearchFilter.php b/src/Doctrine/Orm/Filter/PartialSearchFilter.php index 5417667d2b7..948d0835ea4 100644 --- a/src/Doctrine/Orm/Filter/PartialSearchFilter.php +++ b/src/Doctrine/Orm/Filter/PartialSearchFilter.php @@ -17,12 +17,10 @@ use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Parameter; -use ApiPlatform\Metadata\ParameterProviderFilterInterface; use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; -use ApiPlatform\State\Provider\IriConverterParameterProvider; use Doctrine\ORM\QueryBuilder; -final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface +final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilterInterface { public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { @@ -48,11 +46,6 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q ->setParameter($parameterName, '%'.strtolower($value).'%'); } - public static function getParameterProvider(): string - { - return IriConverterParameterProvider::class; - } - public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null { return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true);