From e04c3c08d3ae924dce8a4d9bb6d591f6234b5f4b Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 10 Apr 2025 16:32:44 +0200 Subject: [PATCH 01/25] 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 7a261fc7bbc..c190a68946c 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 976e23d3380..b2027f941d2 100644 --- a/tests/Fixtures/TestBundle/Entity/Company.php +++ b/tests/Fixtures/TestBundle/Entity/Company.php @@ -24,7 +24,7 @@ use Symfony\Component\Serializer\Annotation\Groups; #[ApiResource] -#[GetCollection] +#[GetCollection()] #[Get] #[NotExposed( uriTemplate: '/company-by-name/{name}', 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 cab6bd647c3760d39b3f81956853a5ee55b5a6f4 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Fri, 2 May 2025 14:16:27 +0200 Subject: [PATCH 02/25] 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 b2027f941d2..976e23d3380 100644 --- a/tests/Fixtures/TestBundle/Entity/Company.php +++ b/tests/Fixtures/TestBundle/Entity/Company.php @@ -24,7 +24,7 @@ use Symfony\Component\Serializer\Annotation\Groups; #[ApiResource] -#[GetCollection()] +#[GetCollection] #[Get] #[NotExposed( uriTemplate: '/company-by-name/{name}', From afd57e3c943cd7fe9a1e64d2c01e7774ab7393e1 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Mon, 5 May 2025 06:39:32 +0200 Subject: [PATCH 03/25] 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 ed57ace13ba53d98130b6b878bae3247a8de3926 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Thu, 5 Jun 2025 16:57:02 +0200 Subject: [PATCH 04/25] 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 79bfc31a1b13bcfb3ad9739396ebc03266e5c2b9 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Sun, 8 Jun 2025 08:36:42 +0200 Subject: [PATCH 05/25] 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 e1a676d72b1472b033da1581d58d954766465f40 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Sun, 8 Jun 2025 14:58:15 +0200 Subject: [PATCH 06/25] 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 fff2121efe2862bd8c20d0eef23483855b3699cb Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Sun, 8 Jun 2025 14:59:06 +0200 Subject: [PATCH 07/25] 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 8b6d41f523331f3c77d331243d1ab22947b9d9d1 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Sun, 8 Jun 2025 14:59:15 +0200 Subject: [PATCH 08/25] 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 d9911c28c22ca6df8b8eed8ee12abcb23091a976 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Sun, 8 Jun 2025 15:00:19 +0200 Subject: [PATCH 09/25] 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); From 7943174c79260ff2a430815274b7ec2a41f39173 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Thu, 12 Jun 2025 13:15:44 +0200 Subject: [PATCH 10/25] fix(test): deprecation on kernel will not always be booted Continues the work at #7079 and before at #6865 See https://github.com/api-platform/core/issues/6971. --- tests/Functional/Parameters/ExactFilterTest.php | 2 ++ tests/Functional/Parameters/IriFilterTest.php | 2 ++ tests/Functional/Parameters/PartialSearchFilterTest.php | 2 ++ 3 files changed, 6 insertions(+) diff --git a/tests/Functional/Parameters/ExactFilterTest.php b/tests/Functional/Parameters/ExactFilterTest.php index c312c6db15a..4c9e6aaedcc 100644 --- a/tests/Functional/Parameters/ExactFilterTest.php +++ b/tests/Functional/Parameters/ExactFilterTest.php @@ -46,6 +46,8 @@ public static function getResources(): array */ protected function setUp(): void { + self::$alwaysBootKernel = false; + $entities = $this->isMongoDB() ? [DocumentChicken::class, DocumentChickenCoop::class] : [Chicken::class, ChickenCoop::class]; diff --git a/tests/Functional/Parameters/IriFilterTest.php b/tests/Functional/Parameters/IriFilterTest.php index a2b32e0bba0..451d0e241d8 100644 --- a/tests/Functional/Parameters/IriFilterTest.php +++ b/tests/Functional/Parameters/IriFilterTest.php @@ -57,6 +57,8 @@ public function testIriFilterMultiple(): void */ protected function setUp(): void { + self::$alwaysBootKernel = false; + $this->recreateSchema([$this->isMongoDB() ? DocumentChicken::class : Chicken::class, $this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class]); $this->loadFixtures(); } diff --git a/tests/Functional/Parameters/PartialSearchFilterTest.php b/tests/Functional/Parameters/PartialSearchFilterTest.php index 8c235fcfcba..f28799ee54b 100644 --- a/tests/Functional/Parameters/PartialSearchFilterTest.php +++ b/tests/Functional/Parameters/PartialSearchFilterTest.php @@ -46,6 +46,8 @@ public static function getResources(): array */ protected function setUp(): void { + self::$alwaysBootKernel = false; + $entities = $this->isMongoDB() ? [DocumentChicken::class, DocumentChickenCoop::class] : [Chicken::class, ChickenCoop::class]; From 0c304f31ce47ca5dae86b65341634922e36742c6 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Thu, 12 Jun 2025 15:47:17 +0200 Subject: [PATCH 11/25] feat(mongodb): ODM support for new filters Continues the work at #7079 and before at #6865 See https://github.com/api-platform/core/issues/6971. --- src/Doctrine/Odm/Filter/ExactFilter.php | 16 ++++++---------- src/Doctrine/Odm/Filter/IriFilter.php | 5 +++-- src/Doctrine/Odm/Filter/PartialSearchFilter.php | 5 +++-- tests/Fixtures/TestBundle/Document/Chicken.php | 4 ++-- .../Fixtures/TestBundle/Document/ChickenCoop.php | 6 +++--- 5 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/Doctrine/Odm/Filter/ExactFilter.php b/src/Doctrine/Odm/Filter/ExactFilter.php index 13ab138684f..35b886b8f53 100644 --- a/src/Doctrine/Odm/Filter/ExactFilter.php +++ b/src/Doctrine/Odm/Filter/ExactFilter.php @@ -30,18 +30,14 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera \assert($parameter instanceof Parameter); $values = (array) $parameter->getValue(); - if ([] === $values) { - return; - } - $matchField = $parameter->getProperty(); - $fieldQuery = $aggregationBuilder->match()->field($matchField); + //TODO: handle nested properties + $property = $parameter->getProperty(); - if (\count($values) > 1) { - $fieldQuery->in($values); - } else { - $fieldQuery->equals(reset($values)); - } + $aggregationBuilder + ->match() + ->field($property) + ->in($values); } public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null diff --git a/src/Doctrine/Odm/Filter/IriFilter.php b/src/Doctrine/Odm/Filter/IriFilter.php index 67d8f2be221..29e29e8bdf7 100644 --- a/src/Doctrine/Odm/Filter/IriFilter.php +++ b/src/Doctrine/Odm/Filter/IriFilter.php @@ -36,11 +36,12 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera $value = [$value]; } - $matchField = $parameter->getProperty(); + //TODO: handle nested properties + $property = $parameter->getProperty(); $aggregationBuilder ->match() - ->field($matchField) + ->field($property) ->in($value); } diff --git a/src/Doctrine/Odm/Filter/PartialSearchFilter.php b/src/Doctrine/Odm/Filter/PartialSearchFilter.php index 2f08198211f..bed39f7e77d 100644 --- a/src/Doctrine/Odm/Filter/PartialSearchFilter.php +++ b/src/Doctrine/Odm/Filter/PartialSearchFilter.php @@ -35,12 +35,13 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera return; } - $matchField = $parameter->getProperty(); + //TODO: handle nested properties + $property = $parameter->getProperty(); $escapedValue = preg_quote($value, '/'); $aggregationBuilder ->match() - ->field($matchField) + ->field($property) ->equals(new Regex($escapedValue, 'i')); } diff --git a/tests/Fixtures/TestBundle/Document/Chicken.php b/tests/Fixtures/TestBundle/Document/Chicken.php index e556219d098..aff8edd81a8 100644 --- a/tests/Fixtures/TestBundle/Document/Chicken.php +++ b/tests/Fixtures/TestBundle/Document/Chicken.php @@ -32,7 +32,7 @@ )] class Chicken { - #[ODM\Id] + #[ODM\Id(type: 'string', strategy: 'INCREMENT')] private ?string $id = null; #[ODM\Field(type: 'string')] @@ -41,7 +41,7 @@ class Chicken #[ODM\ReferenceOne(targetDocument: ChickenCoop::class, inversedBy: 'chickens')] private ?ChickenCoop $chickenCoop = null; - public function getId(): ?string + public function getId(): ?int { return $this->id; } diff --git a/tests/Fixtures/TestBundle/Document/ChickenCoop.php b/tests/Fixtures/TestBundle/Document/ChickenCoop.php index 50e925b8314..bea4f8aeade 100644 --- a/tests/Fixtures/TestBundle/Document/ChickenCoop.php +++ b/tests/Fixtures/TestBundle/Document/ChickenCoop.php @@ -27,8 +27,8 @@ ] class ChickenCoop { - #[ODM\Id] - private ?string $id = null; + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + private ?int $id = null; #[ODM\ReferenceMany(targetDocument: Chicken::class, mappedBy: 'chickenCoop')] private Collection $chickens; @@ -38,7 +38,7 @@ public function __construct() $this->chickens = new ArrayCollection(); } - public function getId(): ?string + public function getId(): ?int { return $this->id; } From cbab08bbebea2fd6383195d4539c0482b2d2cdb5 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Wed, 2 Jul 2025 11:03:31 +0200 Subject: [PATCH 12/25] refactor(state): remove duplicate symbols Continues the work at #7079 and before at #6865 See https://github.com/api-platform/core/issues/6971. --- src/Doctrine/Odm/Filter/IriFilter.php | 2 +- src/Doctrine/Orm/Filter/IriFilter.php | 2 +- .../IriConverterParameterProvider.php | 56 ------------------- .../Resources/config/state/provider.xml | 6 -- 4 files changed, 2 insertions(+), 64 deletions(-) delete mode 100644 src/State/Provider/IriConverterParameterProvider.php diff --git a/src/Doctrine/Odm/Filter/IriFilter.php b/src/Doctrine/Odm/Filter/IriFilter.php index 29e29e8bdf7..32a7ced5a59 100644 --- a/src/Doctrine/Odm/Filter/IriFilter.php +++ b/src/Doctrine/Odm/Filter/IriFilter.php @@ -18,7 +18,7 @@ use ApiPlatform\Metadata\Parameter; use ApiPlatform\Metadata\ParameterProviderFilterInterface; use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; -use ApiPlatform\State\Provider\IriConverterParameterProvider; +use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; use Doctrine\ODM\MongoDB\Aggregation\Builder; final class IriFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface diff --git a/src/Doctrine/Orm/Filter/IriFilter.php b/src/Doctrine/Orm/Filter/IriFilter.php index 0dd128c9239..372a9bf3953 100644 --- a/src/Doctrine/Orm/Filter/IriFilter.php +++ b/src/Doctrine/Orm/Filter/IriFilter.php @@ -19,7 +19,7 @@ use ApiPlatform\Metadata\Parameter; use ApiPlatform\Metadata\ParameterProviderFilterInterface; use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; -use ApiPlatform\State\Provider\IriConverterParameterProvider; +use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; use Doctrine\ORM\QueryBuilder; final class IriFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface diff --git a/src/State/Provider/IriConverterParameterProvider.php b/src/State/Provider/IriConverterParameterProvider.php deleted file mode 100644 index 41509b54ad6..00000000000 --- a/src/State/Provider/IriConverterParameterProvider.php +++ /dev/null @@ -1,56 +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\State\Provider; - -use ApiPlatform\Doctrine\Orm\Filter\IriFilter; -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; - $parameterValue = $parameter->getValue(); - - $isParameterValueNotSet = !$parameterValue || $parameterValue instanceof ParameterNotFound; - if (!$parameter->getFilter() instanceof IriFilter || $isParameterValueNotSet) { - return $operation; - } - - if (!\is_array($parameterValue)) { - $parameterValue = [$parameterValue]; - } - - $entities = []; - foreach ($parameterValue as $iri) { - $entities[] = $this->iriConverter->getResourceFromIri($iri, ['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 c190a68946c..7a261fc7bbc 100644 --- a/src/Symfony/Bundle/Resources/config/state/provider.xml +++ b/src/Symfony/Bundle/Resources/config/state/provider.xml @@ -42,11 +42,5 @@ - - - - - - From 98109cf2c02325f7d29c78b6b35a73470013af3e Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Wed, 2 Jul 2025 11:28:40 +0200 Subject: [PATCH 13/25] cs(state): phpdoc rather than asserting Continues the work at #7079 and before at #6865 See https://github.com/api-platform/core/issues/6971. --- src/Doctrine/Odm/Filter/ExactFilter.php | 4 +--- src/Doctrine/Odm/Filter/FilterInterface.php | 3 +++ src/Doctrine/Odm/Filter/IriFilter.php | 4 +--- src/Doctrine/Odm/Filter/PartialSearchFilter.php | 4 +--- src/Doctrine/Orm/Filter/FilterInterface.php | 3 +++ 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Doctrine/Odm/Filter/ExactFilter.php b/src/Doctrine/Odm/Filter/ExactFilter.php index 35b886b8f53..58dfed88db6 100644 --- a/src/Doctrine/Odm/Filter/ExactFilter.php +++ b/src/Doctrine/Odm/Filter/ExactFilter.php @@ -27,11 +27,9 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera return; } - \assert($parameter instanceof Parameter); - $values = (array) $parameter->getValue(); - //TODO: handle nested properties + // TODO: handle nested properties $property = $parameter->getProperty(); $aggregationBuilder diff --git a/src/Doctrine/Odm/Filter/FilterInterface.php b/src/Doctrine/Odm/Filter/FilterInterface.php index 11e006abb8f..199d5d57d66 100644 --- a/src/Doctrine/Odm/Filter/FilterInterface.php +++ b/src/Doctrine/Odm/Filter/FilterInterface.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\FilterInterface as BaseFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; use Doctrine\ODM\MongoDB\Aggregation\Builder; /** @@ -26,6 +27,8 @@ interface FilterInterface extends BaseFilterInterface { /** * Applies the filter. + * + * @param array{'parameter'?: Parameter, ...} $context */ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void; } diff --git a/src/Doctrine/Odm/Filter/IriFilter.php b/src/Doctrine/Odm/Filter/IriFilter.php index 32a7ced5a59..6e9ebd36648 100644 --- a/src/Doctrine/Odm/Filter/IriFilter.php +++ b/src/Doctrine/Odm/Filter/IriFilter.php @@ -29,14 +29,12 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera return; } - \assert($parameter instanceof Parameter); - $value = $parameter->getValue(); if (!\is_array($value)) { $value = [$value]; } - //TODO: handle nested properties + // TODO: handle nested properties $property = $parameter->getProperty(); $aggregationBuilder diff --git a/src/Doctrine/Odm/Filter/PartialSearchFilter.php b/src/Doctrine/Odm/Filter/PartialSearchFilter.php index bed39f7e77d..274c5d27496 100644 --- a/src/Doctrine/Odm/Filter/PartialSearchFilter.php +++ b/src/Doctrine/Odm/Filter/PartialSearchFilter.php @@ -28,14 +28,12 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera return; } - \assert($parameter instanceof Parameter); - $value = $parameter->getValue(); if (!\is_string($value) || '' === $value) { return; } - //TODO: handle nested properties + // TODO: handle nested properties $property = $parameter->getProperty(); $escapedValue = preg_quote($value, '/'); diff --git a/src/Doctrine/Orm/Filter/FilterInterface.php b/src/Doctrine/Orm/Filter/FilterInterface.php index 4cfa337dfb6..9876a69572b 100644 --- a/src/Doctrine/Orm/Filter/FilterInterface.php +++ b/src/Doctrine/Orm/Filter/FilterInterface.php @@ -16,6 +16,7 @@ use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\FilterInterface as BaseFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; use Doctrine\ORM\QueryBuilder; /** @@ -27,6 +28,8 @@ interface FilterInterface extends BaseFilterInterface { /** * Applies the filter. + * + * @param array{'parameter'?: Parameter} $context */ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void; } From a471dfa282ccd3179df99c1a6bc6ec1adbe63f6a Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Thu, 3 Jul 2025 11:44:32 +0200 Subject: [PATCH 14/25] refactor(doctrine): introduce OpenApiFilterTrait.php --- .../Common/Filter/OpenApiFilterTrait.php | 30 +++++++++++++++++++ src/Doctrine/Odm/Filter/ExactFilter.php | 15 ++-------- src/Doctrine/Odm/Filter/IriFilter.php | 15 ++-------- .../Odm/Filter/PartialSearchFilter.php | 15 ++-------- src/Doctrine/Orm/Filter/ExactFilter.php | 15 ++-------- src/Doctrine/Orm/Filter/IriFilter.php | 15 ++-------- .../Orm/Filter/PartialSearchFilter.php | 15 ++-------- 7 files changed, 48 insertions(+), 72 deletions(-) create mode 100644 src/Doctrine/Common/Filter/OpenApiFilterTrait.php diff --git a/src/Doctrine/Common/Filter/OpenApiFilterTrait.php b/src/Doctrine/Common/Filter/OpenApiFilterTrait.php new file mode 100644 index 00000000000..06004e08d51 --- /dev/null +++ b/src/Doctrine/Common/Filter/OpenApiFilterTrait.php @@ -0,0 +1,30 @@ + + * + * 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\Common\Filter; + +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; + +trait OpenApiFilterTrait +{ + 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/Odm/Filter/ExactFilter.php b/src/Doctrine/Odm/Filter/ExactFilter.php index 58dfed88db6..6d6031065e7 100644 --- a/src/Doctrine/Odm/Filter/ExactFilter.php +++ b/src/Doctrine/Odm/Filter/ExactFilter.php @@ -13,14 +13,15 @@ namespace ApiPlatform\Doctrine\Odm\Filter; +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; 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 { + use OpenApiFilterTrait; + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { if (!$parameter = $context['parameter'] ?? null) { @@ -37,14 +38,4 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera ->field($property) ->in($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 []; - } } diff --git a/src/Doctrine/Odm/Filter/IriFilter.php b/src/Doctrine/Odm/Filter/IriFilter.php index 6e9ebd36648..bb2eac78e0b 100644 --- a/src/Doctrine/Odm/Filter/IriFilter.php +++ b/src/Doctrine/Odm/Filter/IriFilter.php @@ -13,16 +13,17 @@ namespace ApiPlatform\Doctrine\Odm\Filter; +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; 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\ParameterProvider\IriConverterParameterProvider; use Doctrine\ODM\MongoDB\Aggregation\Builder; final class IriFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface { + use OpenApiFilterTrait; + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { if (!$parameter = $context['parameter'] ?? null) { @@ -47,14 +48,4 @@ 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/Odm/Filter/PartialSearchFilter.php b/src/Doctrine/Odm/Filter/PartialSearchFilter.php index 274c5d27496..e00d99acdba 100644 --- a/src/Doctrine/Odm/Filter/PartialSearchFilter.php +++ b/src/Doctrine/Odm/Filter/PartialSearchFilter.php @@ -13,15 +13,16 @@ namespace ApiPlatform\Doctrine\Odm\Filter; +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; 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 { + use OpenApiFilterTrait; + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { if (!$parameter = $context['parameter'] ?? null) { @@ -42,14 +43,4 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera ->field($property) ->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 []; - } } diff --git a/src/Doctrine/Orm/Filter/ExactFilter.php b/src/Doctrine/Orm/Filter/ExactFilter.php index 89e4c368381..81f853e57cd 100644 --- a/src/Doctrine/Orm/Filter/ExactFilter.php +++ b/src/Doctrine/Orm/Filter/ExactFilter.php @@ -13,15 +13,16 @@ namespace ApiPlatform\Doctrine\Orm\Filter; +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; -use ApiPlatform\Metadata\Parameter; -use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Doctrine\ORM\QueryBuilder; final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface { + use OpenApiFilterTrait; + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if (!$parameter = $context['parameter'] ?? null) { @@ -41,14 +42,4 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q ->andWhere(\sprintf('%s.%s = :%s', $alias, $property, $parameterName)) ->setParameter($parameterName, $value); } - - 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/IriFilter.php b/src/Doctrine/Orm/Filter/IriFilter.php index 372a9bf3953..b52512ac7d5 100644 --- a/src/Doctrine/Orm/Filter/IriFilter.php +++ b/src/Doctrine/Orm/Filter/IriFilter.php @@ -13,17 +13,18 @@ namespace ApiPlatform\Doctrine\Orm\Filter; +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; 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\ParameterProvider\IriConverterParameterProvider; use Doctrine\ORM\QueryBuilder; final class IriFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface { + use OpenApiFilterTrait; + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if (!$parameter = $context['parameter'] ?? null) { @@ -49,14 +50,4 @@ 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 index 948d0835ea4..0496732b72e 100644 --- a/src/Doctrine/Orm/Filter/PartialSearchFilter.php +++ b/src/Doctrine/Orm/Filter/PartialSearchFilter.php @@ -13,15 +13,16 @@ namespace ApiPlatform\Doctrine\Orm\Filter; +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; -use ApiPlatform\Metadata\Parameter; -use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Doctrine\ORM\QueryBuilder; final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilterInterface { + use OpenApiFilterTrait; + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { if (!$parameter = $context['parameter'] ?? null) { @@ -45,14 +46,4 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q ->andWhere($likeExpression) ->setParameter($parameterName, '%'.strtolower($value).'%'); } - - 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 b5e6ac8acaec4c51ac433c3f760b0650654ad5aa Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Fri, 4 Jul 2025 09:54:48 +0200 Subject: [PATCH 15/25] feat(doctrine): add OrFilter for ORM and ODM Continues the work at #7079 and before at #6865 --- src/Doctrine/Odm/Filter/OrFilter.php | 40 +++++ src/Doctrine/Orm/Filter/OrFilter.php | 49 ++++++ .../IriConverterParameterProvider.php | 22 ++- .../Fixtures/TestBundle/Document/Chicken.php | 8 +- tests/Fixtures/TestBundle/Entity/Chicken.php | 6 + tests/Functional/Parameters/OrFilterTest.php | 139 ++++++++++++++++++ 6 files changed, 255 insertions(+), 9 deletions(-) create mode 100644 src/Doctrine/Odm/Filter/OrFilter.php create mode 100644 src/Doctrine/Orm/Filter/OrFilter.php create mode 100644 tests/Functional/Parameters/OrFilterTest.php diff --git a/src/Doctrine/Odm/Filter/OrFilter.php b/src/Doctrine/Odm/Filter/OrFilter.php new file mode 100644 index 00000000000..52b814e2a40 --- /dev/null +++ b/src/Doctrine/Odm/Filter/OrFilter.php @@ -0,0 +1,40 @@ + + * + * 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\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ODM\MongoDB\Aggregation\Builder; + +final class OrFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use OpenApiFilterTrait; + /** + * @var array + */ + private readonly array $filters; + + public function __construct(FilterInterface ...$filters) + { + $this->filters = $filters; + } + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + foreach ($this->filters as $filter) { + $filter->apply($aggregationBuilder, $resourceClass, $operation, $context); + } + } +} diff --git a/src/Doctrine/Orm/Filter/OrFilter.php b/src/Doctrine/Orm/Filter/OrFilter.php new file mode 100644 index 00000000000..ae0ca1eac63 --- /dev/null +++ b/src/Doctrine/Orm/Filter/OrFilter.php @@ -0,0 +1,49 @@ + + * + * 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\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\ParameterProviderFilterInterface; +use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; +use Doctrine\ORM\QueryBuilder; + +final class OrFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface +{ + use OpenApiFilterTrait; + + /** + * @var array + */ + private readonly array $filters; + + public function __construct(FilterInterface ...$filters) + { + $this->filters = $filters; + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + foreach ($this->filters as $filter) { + $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + } + } + + public static function getParameterProvider(): string + { + return IriConverterParameterProvider::class; + } +} diff --git a/src/State/ParameterProvider/IriConverterParameterProvider.php b/src/State/ParameterProvider/IriConverterParameterProvider.php index 2f817f8e7ab..8867643ecaf 100644 --- a/src/State/ParameterProvider/IriConverterParameterProvider.php +++ b/src/State/ParameterProvider/IriConverterParameterProvider.php @@ -13,6 +13,8 @@ namespace ApiPlatform\State\ParameterProvider; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Parameter; @@ -40,19 +42,23 @@ public function provide(Parameter $parameter, array $parameters = [], array $con $iriConverterContext = ['fetch_data' => $parameter->getExtraProperties()['fetch_data'] ?? false]; - if (\is_array($value)) { - $entities = []; - foreach ($value as $v) { - $entities[] = $this->iriConverter->getResourceFromIri($v, $iriConverterContext); - } + try { + if (\is_array($value)) { + $entities = []; + foreach ($value as $v) { + $entities[] = $this->iriConverter->getResourceFromIri($v, $iriConverterContext); + } + + $parameter->setValue($entities); - $parameter->setValue($entities); + return $operation; + } + $parameter->setValue($this->iriConverter->getResourceFromIri($value, $iriConverterContext)); + } catch (InvalidArgumentException|ItemNotFoundException) { return $operation; } - $parameter->setValue($this->iriConverter->getResourceFromIri($value, $iriConverterContext)); - return $operation; } } diff --git a/tests/Fixtures/TestBundle/Document/Chicken.php b/tests/Fixtures/TestBundle/Document/Chicken.php index aff8edd81a8..4132c57ac82 100644 --- a/tests/Fixtures/TestBundle/Document/Chicken.php +++ b/tests/Fixtures/TestBundle/Document/Chicken.php @@ -14,6 +14,8 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Odm\Filter\IriFilter; +use ApiPlatform\Doctrine\Odm\Filter\OrFilter; use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; @@ -28,6 +30,10 @@ filter: new PartialSearchFilter(), property: 'name', ), + 'relation' => new QueryParameter( + filter: new OrFilter(new IriFilter(), new ExactFilter()), + property: 'chickenCoop' + ), ], )] class Chicken @@ -41,7 +47,7 @@ class Chicken #[ODM\ReferenceOne(targetDocument: ChickenCoop::class, inversedBy: 'chickens')] private ?ChickenCoop $chickenCoop = null; - public function getId(): ?int + public function getId(): ?string { return $this->id; } diff --git a/tests/Fixtures/TestBundle/Entity/Chicken.php b/tests/Fixtures/TestBundle/Entity/Chicken.php index 9d497676608..22f5b6afce8 100644 --- a/tests/Fixtures/TestBundle/Entity/Chicken.php +++ b/tests/Fixtures/TestBundle/Entity/Chicken.php @@ -14,6 +14,8 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Orm\Filter\IriFilter; +use ApiPlatform\Doctrine\Orm\Filter\OrFilter; use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; @@ -28,6 +30,10 @@ filter: new PartialSearchFilter(), property: 'name', ), + 'relation' => new QueryParameter( + filter: new OrFilter(new IriFilter(), new ExactFilter()), + property: 'chickenCoop' + ), ], )] class Chicken diff --git a/tests/Functional/Parameters/OrFilterTest.php b/tests/Functional/Parameters/OrFilterTest.php new file mode 100644 index 00000000000..3603e70dcff --- /dev/null +++ b/tests/Functional/Parameters/OrFilterTest.php @@ -0,0 +1,139 @@ + + * + * 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; +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 OrFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + public static function getResources(): array + { + return [Chicken::class, ChickenCoop::class]; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + protected function setUp(): void + { + $entities = $this->isMongoDB() + ? [DocumentChicken::class, DocumentChickenCoop::class] + : [Chicken::class, ChickenCoop::class]; + + $this->recreateSchema($entities); + $this->loadFixtures(); + } + + /** + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + */ + #[DataProvider('filterDataProvider')] + public function testOrFilter(string $url, int $expectedCount, array $expectedNames): void + { + $client = self::createClient(); + $client->request('GET', $url); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains(['hydra:totalItems' => $expectedCount]); + + if ($expectedCount > 0) { + $names = array_column($client->getResponse()->toArray()['hydra:member'], 'name'); + sort($names); + sort($expectedNames); + $this->assertSame($expectedNames, $names); + } + } + + public static function filterDataProvider(): \Generator + { + yield 'filtre par ID du poulailler de Gertrude' => [ + 'url' => '/chickens?relation=1', + 'expectedCount' => 1, + 'expectedNames' => ['Gertrude'], + ]; + + yield 'filtre par IRI du poulailler de Gertrude' => [ + 'url' => '/chickens?relation=/chicken_coops/1', + 'expectedCount' => 1, + 'expectedNames' => ['Gertrude'], + ]; + + yield 'filtre par ID du poulailler de Henriette' => [ + 'url' => '/chickens?relation=2', + 'expectedCount' => 1, + 'expectedNames' => ['Henriette'], + ]; + + yield 'filtre par IRI du poulailler de Henriette' => [ + 'url' => '/chickens?relation=/chicken_coops/2', + 'expectedCount' => 1, + 'expectedNames' => ['Henriette'], + ]; + + yield 'filtre avec un ID inexistant' => [ + 'url' => '/chickens?relation=999', + 'expectedCount' => 0, + 'expectedNames' => [], + ]; + } + + /** + * @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; + + $chickenCoop1 = new $coopClass(); + $chickenCoop2 = new $coopClass(); + $manager->persist($chickenCoop1); + $manager->persist($chickenCoop2); + + $chicken1 = new $chickenClass(); + $chicken1->setName('Gertrude'); + $chicken1->setChickenCoop($chickenCoop1); + $manager->persist($chicken1); + + $chicken2 = new $chickenClass(); + $chicken2->setName('Henriette'); + $chicken2->setChickenCoop($chickenCoop2); + $manager->persist($chicken2); + + $manager->flush(); + } +} From 11f9298ab50c41ee432622ef20cb06ca928c793e Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Fri, 4 Jul 2025 09:55:31 +0200 Subject: [PATCH 16/25] cs(doctrine): fix typing issues --- src/Doctrine/Odm/Filter/FilterInterface.php | 2 +- src/Doctrine/Orm/Filter/ExistsFilter.php | 7 +++++-- src/Doctrine/Orm/Filter/FilterInterface.php | 2 +- tests/Functional/Parameters/ExactFilterTest.php | 6 ++---- tests/Functional/Parameters/PartialSearchFilterTest.php | 6 ++---- 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/Doctrine/Odm/Filter/FilterInterface.php b/src/Doctrine/Odm/Filter/FilterInterface.php index 199d5d57d66..4395a25df55 100644 --- a/src/Doctrine/Odm/Filter/FilterInterface.php +++ b/src/Doctrine/Odm/Filter/FilterInterface.php @@ -28,7 +28,7 @@ interface FilterInterface extends BaseFilterInterface /** * Applies the filter. * - * @param array{'parameter'?: Parameter, ...} $context + * @param array|array{filters?: array|array, parameter?: Parameter, mongodb_odm_sort_fields?: array, ...} $context */ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void; } diff --git a/src/Doctrine/Orm/Filter/ExistsFilter.php b/src/Doctrine/Orm/Filter/ExistsFilter.php index 7dbb046630e..a42ee38d4d8 100644 --- a/src/Doctrine/Orm/Filter/ExistsFilter.php +++ b/src/Doctrine/Orm/Filter/ExistsFilter.php @@ -142,8 +142,11 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q return; } - foreach ($context['filters'][$this->existsParameterName] ?? [] as $property => $value) { - $this->filterProperty($this->denormalizePropertyName($property), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + $properties = $context['filters'][$this->existsParameterName]; + if ([] !== $properties) { + foreach ($properties as $property => $value) { + $this->filterProperty($this->denormalizePropertyName($property), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + } } } diff --git a/src/Doctrine/Orm/Filter/FilterInterface.php b/src/Doctrine/Orm/Filter/FilterInterface.php index 9876a69572b..7539574ff37 100644 --- a/src/Doctrine/Orm/Filter/FilterInterface.php +++ b/src/Doctrine/Orm/Filter/FilterInterface.php @@ -29,7 +29,7 @@ interface FilterInterface extends BaseFilterInterface /** * Applies the filter. * - * @param array{'parameter'?: Parameter} $context + * @param array{filters?: array|array, parameter?: Parameter, ...} $context */ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void; } diff --git a/tests/Functional/Parameters/ExactFilterTest.php b/tests/Functional/Parameters/ExactFilterTest.php index 4c9e6aaedcc..0e58a3e48bb 100644 --- a/tests/Functional/Parameters/ExactFilterTest.php +++ b/tests/Functional/Parameters/ExactFilterTest.php @@ -136,10 +136,8 @@ private function loadFixtures(): void $chicken2->setName('Henriette'); $chicken2->setChickenCoop($chickenCoop2); - if (method_exists($chickenCoop1, 'addChicken')) { - $chickenCoop1->addChicken($chicken1); - $chickenCoop2->addChicken($chicken2); - } + $chickenCoop1->addChicken($chicken1); + $chickenCoop2->addChicken($chicken2); $manager->persist($chickenCoop1); $manager->persist($chickenCoop2); diff --git a/tests/Functional/Parameters/PartialSearchFilterTest.php b/tests/Functional/Parameters/PartialSearchFilterTest.php index f28799ee54b..a772eaa89a7 100644 --- a/tests/Functional/Parameters/PartialSearchFilterTest.php +++ b/tests/Functional/Parameters/PartialSearchFilterTest.php @@ -130,10 +130,8 @@ private function loadFixtures(): void $chicken2->setName('Henriette'); $chicken2->setChickenCoop($chickenCoop2); - if (method_exists($chickenCoop1, 'addChicken')) { - $chickenCoop1->addChicken($chicken1); - $chickenCoop2->addChicken($chicken2); - } + $chickenCoop1->addChicken($chicken1); + $chickenCoop2->addChicken($chicken2); $manager->persist($chickenCoop1); $manager->persist($chickenCoop2); From ec42ce26c8b1539c19ca6563f239c5077abe13b6 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Fri, 4 Jul 2025 10:04:25 +0200 Subject: [PATCH 17/25] fix(doctrine): add authorship --- src/Doctrine/Common/Filter/OpenApiFilterTrait.php | 3 +++ src/Doctrine/Odm/Filter/ExactFilter.php | 3 +++ src/Doctrine/Odm/Filter/IriFilter.php | 3 +++ src/Doctrine/Odm/Filter/OrFilter.php | 3 +++ src/Doctrine/Odm/Filter/PartialSearchFilter.php | 3 +++ src/Doctrine/Orm/Filter/ExactFilter.php | 3 +++ src/Doctrine/Orm/Filter/IriFilter.php | 3 +++ src/Doctrine/Orm/Filter/OrFilter.php | 3 +++ src/Doctrine/Orm/Filter/PartialSearchFilter.php | 3 +++ src/State/ParameterProvider/IriConverterParameterProvider.php | 2 +- tests/Functional/Parameters/ExactFilterTest.php | 3 +++ tests/Functional/Parameters/ExistsFilterTest.php | 3 +++ tests/Functional/Parameters/OrFilterTest.php | 3 +++ tests/Functional/Parameters/PartialSearchFilterTest.php | 3 +++ 14 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/Doctrine/Common/Filter/OpenApiFilterTrait.php b/src/Doctrine/Common/Filter/OpenApiFilterTrait.php index 06004e08d51..2fb06bf0105 100644 --- a/src/Doctrine/Common/Filter/OpenApiFilterTrait.php +++ b/src/Doctrine/Common/Filter/OpenApiFilterTrait.php @@ -16,6 +16,9 @@ use ApiPlatform\Metadata\Parameter; use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; +/** + * @author Vincent Amstoutz + */ trait OpenApiFilterTrait { public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null diff --git a/src/Doctrine/Odm/Filter/ExactFilter.php b/src/Doctrine/Odm/Filter/ExactFilter.php index 6d6031065e7..ac5a890a11c 100644 --- a/src/Doctrine/Odm/Filter/ExactFilter.php +++ b/src/Doctrine/Odm/Filter/ExactFilter.php @@ -18,6 +18,9 @@ use ApiPlatform\Metadata\Operation; use Doctrine\ODM\MongoDB\Aggregation\Builder; +/** + * @author Vincent Amstoutz + */ final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface { use OpenApiFilterTrait; diff --git a/src/Doctrine/Odm/Filter/IriFilter.php b/src/Doctrine/Odm/Filter/IriFilter.php index bb2eac78e0b..be35187578c 100644 --- a/src/Doctrine/Odm/Filter/IriFilter.php +++ b/src/Doctrine/Odm/Filter/IriFilter.php @@ -20,6 +20,9 @@ use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; use Doctrine\ODM\MongoDB\Aggregation\Builder; +/** + * @author Vincent Amstoutz + */ final class IriFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface { use OpenApiFilterTrait; diff --git a/src/Doctrine/Odm/Filter/OrFilter.php b/src/Doctrine/Odm/Filter/OrFilter.php index 52b814e2a40..f2ba7dd4a47 100644 --- a/src/Doctrine/Odm/Filter/OrFilter.php +++ b/src/Doctrine/Odm/Filter/OrFilter.php @@ -18,6 +18,9 @@ use ApiPlatform\Metadata\Operation; use Doctrine\ODM\MongoDB\Aggregation\Builder; +/** + * @author Vincent Amstoutz + */ final class OrFilter implements FilterInterface, OpenApiParameterFilterInterface { use OpenApiFilterTrait; diff --git a/src/Doctrine/Odm/Filter/PartialSearchFilter.php b/src/Doctrine/Odm/Filter/PartialSearchFilter.php index e00d99acdba..9390831a1c0 100644 --- a/src/Doctrine/Odm/Filter/PartialSearchFilter.php +++ b/src/Doctrine/Odm/Filter/PartialSearchFilter.php @@ -19,6 +19,9 @@ use Doctrine\ODM\MongoDB\Aggregation\Builder; use MongoDB\BSON\Regex; +/** + * @author Vincent Amstoutz + */ final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilterInterface { use OpenApiFilterTrait; diff --git a/src/Doctrine/Orm/Filter/ExactFilter.php b/src/Doctrine/Orm/Filter/ExactFilter.php index 81f853e57cd..723ad161a3f 100644 --- a/src/Doctrine/Orm/Filter/ExactFilter.php +++ b/src/Doctrine/Orm/Filter/ExactFilter.php @@ -19,6 +19,9 @@ use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; +/** + * @author Vincent Amstoutz + */ final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface { use OpenApiFilterTrait; diff --git a/src/Doctrine/Orm/Filter/IriFilter.php b/src/Doctrine/Orm/Filter/IriFilter.php index b52512ac7d5..de8aaf85178 100644 --- a/src/Doctrine/Orm/Filter/IriFilter.php +++ b/src/Doctrine/Orm/Filter/IriFilter.php @@ -21,6 +21,9 @@ use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; use Doctrine\ORM\QueryBuilder; +/** + * @author Vincent Amstoutz + */ final class IriFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface { use OpenApiFilterTrait; diff --git a/src/Doctrine/Orm/Filter/OrFilter.php b/src/Doctrine/Orm/Filter/OrFilter.php index ae0ca1eac63..f60573fb0a7 100644 --- a/src/Doctrine/Orm/Filter/OrFilter.php +++ b/src/Doctrine/Orm/Filter/OrFilter.php @@ -21,6 +21,9 @@ use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; use Doctrine\ORM\QueryBuilder; +/** + * @author Vincent Amstoutz + */ final class OrFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface { use OpenApiFilterTrait; diff --git a/src/Doctrine/Orm/Filter/PartialSearchFilter.php b/src/Doctrine/Orm/Filter/PartialSearchFilter.php index 0496732b72e..b658a7509f7 100644 --- a/src/Doctrine/Orm/Filter/PartialSearchFilter.php +++ b/src/Doctrine/Orm/Filter/PartialSearchFilter.php @@ -19,6 +19,9 @@ use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; +/** + * @author Vincent Amstoutz + */ final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilterInterface { use OpenApiFilterTrait; diff --git a/src/State/ParameterProvider/IriConverterParameterProvider.php b/src/State/ParameterProvider/IriConverterParameterProvider.php index 8867643ecaf..a81faa8bfda 100644 --- a/src/State/ParameterProvider/IriConverterParameterProvider.php +++ b/src/State/ParameterProvider/IriConverterParameterProvider.php @@ -24,7 +24,7 @@ /** * @experimental * - * @author Vincent Amstoutz + * @author Vincent Amstoutz */ final readonly class IriConverterParameterProvider implements ParameterProviderInterface { diff --git a/tests/Functional/Parameters/ExactFilterTest.php b/tests/Functional/Parameters/ExactFilterTest.php index 0e58a3e48bb..91fb1a936f5 100644 --- a/tests/Functional/Parameters/ExactFilterTest.php +++ b/tests/Functional/Parameters/ExactFilterTest.php @@ -28,6 +28,9 @@ use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +/** + * @author Vincent Amstoutz + */ final class ExactFilterTest extends ApiTestCase { use RecreateSchemaTrait; diff --git a/tests/Functional/Parameters/ExistsFilterTest.php b/tests/Functional/Parameters/ExistsFilterTest.php index b6e68b7522a..e2bc02c10ec 100644 --- a/tests/Functional/Parameters/ExistsFilterTest.php +++ b/tests/Functional/Parameters/ExistsFilterTest.php @@ -26,6 +26,9 @@ use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +/** + * @author Vincent Amstoutz + */ final class ExistsFilterTest extends ApiTestCase { use RecreateSchemaTrait; diff --git a/tests/Functional/Parameters/OrFilterTest.php b/tests/Functional/Parameters/OrFilterTest.php index 3603e70dcff..70806f11014 100644 --- a/tests/Functional/Parameters/OrFilterTest.php +++ b/tests/Functional/Parameters/OrFilterTest.php @@ -28,6 +28,9 @@ use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +/** + * @author Vincent Amstoutz + */ final class OrFilterTest extends ApiTestCase { use RecreateSchemaTrait; diff --git a/tests/Functional/Parameters/PartialSearchFilterTest.php b/tests/Functional/Parameters/PartialSearchFilterTest.php index a772eaa89a7..f9531c90b7b 100644 --- a/tests/Functional/Parameters/PartialSearchFilterTest.php +++ b/tests/Functional/Parameters/PartialSearchFilterTest.php @@ -28,6 +28,9 @@ use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +/** + * @author Vincent Amstoutz + */ final class PartialSearchFilterTest extends ApiTestCase { use RecreateSchemaTrait; From d0653808515bc04c8ca300fda5a4e96cbba575ae Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Wed, 20 Aug 2025 16:30:12 +0200 Subject: [PATCH 18/25] fix(test): apply review requested changes --- .../Common/Filter/OpenApiFilterTrait.php | 5 -- src/Doctrine/Odm/Filter/ExactFilter.php | 13 ++--- src/Doctrine/Odm/Filter/IriFilter.php | 14 ++--- src/Doctrine/Odm/Filter/OrFilter.php | 11 ++-- .../Odm/Filter/PartialSearchFilter.php | 10 +--- src/Doctrine/Orm/Filter/ExactFilter.php | 22 +++---- src/Doctrine/Orm/Filter/ExistsFilter.php | 7 +-- src/Doctrine/Orm/Filter/IriFilter.php | 24 ++++---- src/Doctrine/Orm/Filter/OrFilter.php | 26 ++++----- .../Orm/Filter/PartialSearchFilter.php | 33 ++++++----- ...ckwardCompatibleFilterDescriptionTrait.php | 27 +++++++++ src/Metadata/FilterInterface.php | 2 + .../IriConverterParameterProvider.php | 29 ++++++---- .../config/state/parameter_provider.xml | 1 + .../Fixtures/TestBundle/Document/Chicken.php | 6 -- .../TestBundle/Document/ChickenCoop.php | 10 +++- .../Fixtures/TestBundle/Document/Company.php | 2 +- tests/Fixtures/TestBundle/Entity/Chicken.php | 6 -- .../TestBundle/Entity/ChickenCoop.php | 10 +++- .../Functional/Parameters/ExactFilterTest.php | 4 +- tests/Functional/Parameters/IriFilterTest.php | 2 - tests/Functional/Parameters/OrFilterTest.php | 57 +++++-------------- .../Parameters/PartialSearchFilterTest.php | 22 ++++++- 23 files changed, 183 insertions(+), 160 deletions(-) create mode 100644 src/Metadata/BackwardCompatibleFilterDescriptionTrait.php diff --git a/src/Doctrine/Common/Filter/OpenApiFilterTrait.php b/src/Doctrine/Common/Filter/OpenApiFilterTrait.php index 2fb06bf0105..7df7e8b9cfb 100644 --- a/src/Doctrine/Common/Filter/OpenApiFilterTrait.php +++ b/src/Doctrine/Common/Filter/OpenApiFilterTrait.php @@ -25,9 +25,4 @@ public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|arr { return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true); } - - public function getDescription(string $resourceClass): array - { - return []; - } } diff --git a/src/Doctrine/Odm/Filter/ExactFilter.php b/src/Doctrine/Odm/Filter/ExactFilter.php index ac5a890a11c..7d874f79b68 100644 --- a/src/Doctrine/Odm/Filter/ExactFilter.php +++ b/src/Doctrine/Odm/Filter/ExactFilter.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Doctrine\Odm\Filter; use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ODM\MongoDB\Aggregation\Builder; @@ -23,22 +24,18 @@ */ final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface { + use BackwardCompatibleFilterDescriptionTrait; use OpenApiFilterTrait; public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { - if (!$parameter = $context['parameter'] ?? null) { - return; - } - - $values = (array) $parameter->getValue(); - - // TODO: handle nested properties + $parameter = $context['parameter']; + $value = $parameter->getValue(); $property = $parameter->getProperty(); $aggregationBuilder ->match() ->field($property) - ->in($values); + ->{(is_iterable($value)) ? 'in' : 'equals'}($value); } } diff --git a/src/Doctrine/Odm/Filter/IriFilter.php b/src/Doctrine/Odm/Filter/IriFilter.php index be35187578c..8a3f1c95505 100644 --- a/src/Doctrine/Odm/Filter/IriFilter.php +++ b/src/Doctrine/Odm/Filter/IriFilter.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Doctrine\Odm\Filter; use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\ParameterProviderFilterInterface; @@ -25,26 +26,19 @@ */ final class IriFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface { + use BackwardCompatibleFilterDescriptionTrait; use OpenApiFilterTrait; public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { - if (!$parameter = $context['parameter'] ?? null) { - return; - } - + $parameter = $context['parameter']; $value = $parameter->getValue(); - if (!\is_array($value)) { - $value = [$value]; - } - - // TODO: handle nested properties $property = $parameter->getProperty(); $aggregationBuilder ->match() ->field($property) - ->in($value); + ->{(is_iterable($value)) ? 'in' : 'equals'}($value); } public static function getParameterProvider(): string diff --git a/src/Doctrine/Odm/Filter/OrFilter.php b/src/Doctrine/Odm/Filter/OrFilter.php index f2ba7dd4a47..fab1ac460f7 100644 --- a/src/Doctrine/Odm/Filter/OrFilter.php +++ b/src/Doctrine/Odm/Filter/OrFilter.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Doctrine\Odm\Filter; use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ODM\MongoDB\Aggregation\Builder; @@ -23,20 +24,20 @@ */ final class OrFilter implements FilterInterface, OpenApiParameterFilterInterface { + use BackwardCompatibleFilterDescriptionTrait; use OpenApiFilterTrait; + /** - * @var array + * @param array $filters */ - private readonly array $filters; - - public function __construct(FilterInterface ...$filters) + public function __construct(private readonly array $filters) { - $this->filters = $filters; } public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { foreach ($this->filters as $filter) { + $context = ['whereClause' => 'orWhere'] + $context; $filter->apply($aggregationBuilder, $resourceClass, $operation, $context); } } diff --git a/src/Doctrine/Odm/Filter/PartialSearchFilter.php b/src/Doctrine/Odm/Filter/PartialSearchFilter.php index 9390831a1c0..554b6ef2880 100644 --- a/src/Doctrine/Odm/Filter/PartialSearchFilter.php +++ b/src/Doctrine/Odm/Filter/PartialSearchFilter.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Doctrine\Odm\Filter; use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ODM\MongoDB\Aggregation\Builder; @@ -24,18 +25,13 @@ */ final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilterInterface { + use BackwardCompatibleFilterDescriptionTrait; use OpenApiFilterTrait; public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { - if (!$parameter = $context['parameter'] ?? null) { - return; - } - + $parameter = $context['parameter']; $value = $parameter->getValue(); - if (!\is_string($value) || '' === $value) { - return; - } // TODO: handle nested properties $property = $parameter->getProperty(); diff --git a/src/Doctrine/Orm/Filter/ExactFilter.php b/src/Doctrine/Orm/Filter/ExactFilter.php index 723ad161a3f..ec567ecb82d 100644 --- a/src/Doctrine/Orm/Filter/ExactFilter.php +++ b/src/Doctrine/Orm/Filter/ExactFilter.php @@ -15,6 +15,7 @@ use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; @@ -24,25 +25,26 @@ */ final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface { + use BackwardCompatibleFilterDescriptionTrait; use OpenApiFilterTrait; public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { - if (!$parameter = $context['parameter'] ?? null) { - return; - } - + $parameter = $context['parameter']; $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); + if (\is_array($value)) { + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s IN (:%s)', $alias, $property, $parameterName)); + } else { + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s = :%s', $alias, $property, $parameterName)); + } + + $queryBuilder->setParameter($parameterName, $value); } } diff --git a/src/Doctrine/Orm/Filter/ExistsFilter.php b/src/Doctrine/Orm/Filter/ExistsFilter.php index a42ee38d4d8..7dbb046630e 100644 --- a/src/Doctrine/Orm/Filter/ExistsFilter.php +++ b/src/Doctrine/Orm/Filter/ExistsFilter.php @@ -142,11 +142,8 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q return; } - $properties = $context['filters'][$this->existsParameterName]; - if ([] !== $properties) { - foreach ($properties as $property => $value) { - $this->filterProperty($this->denormalizePropertyName($property), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); - } + foreach ($context['filters'][$this->existsParameterName] ?? [] as $property => $value) { + $this->filterProperty($this->denormalizePropertyName($property), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); } } diff --git a/src/Doctrine/Orm/Filter/IriFilter.php b/src/Doctrine/Orm/Filter/IriFilter.php index de8aaf85178..628562edb3c 100644 --- a/src/Doctrine/Orm/Filter/IriFilter.php +++ b/src/Doctrine/Orm/Filter/IriFilter.php @@ -15,6 +15,7 @@ use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\ParameterProviderFilterInterface; @@ -26,27 +27,30 @@ */ final class IriFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface { + use BackwardCompatibleFilterDescriptionTrait; use OpenApiFilterTrait; public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { - if (!$parameter = $context['parameter'] ?? null) { - return; - } + $parameter = $context['parameter']; $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); + $queryBuilder->join(\sprintf('%s.%s', $alias, $property), $parameterName); + + if (\is_array($value)) { + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s IN (:%s)', $parameterName, $parameterName)); + } else { + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s = :%s', $parameterName, $parameterName)); + } + + $queryBuilder->setParameter($parameterName, $value); } public static function getParameterProvider(): string diff --git a/src/Doctrine/Orm/Filter/OrFilter.php b/src/Doctrine/Orm/Filter/OrFilter.php index f60573fb0a7..cc49546e4d8 100644 --- a/src/Doctrine/Orm/Filter/OrFilter.php +++ b/src/Doctrine/Orm/Filter/OrFilter.php @@ -15,38 +15,36 @@ use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; -use ApiPlatform\Metadata\ParameterProviderFilterInterface; -use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; use Doctrine\ORM\QueryBuilder; /** * @author Vincent Amstoutz */ -final class OrFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface +final class OrFilter implements FilterInterface, OpenApiParameterFilterInterface { + use BackwardCompatibleFilterDescriptionTrait; use OpenApiFilterTrait; /** - * @var array + * @param array $filters */ - private readonly array $filters; - - public function __construct(FilterInterface ...$filters) + public function __construct(private readonly array $filters) { - $this->filters = $filters; } public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { foreach ($this->filters as $filter) { - $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + $filter->apply( + $queryBuilder, + $queryNameGenerator, + $resourceClass, + $operation, + ['whereClause' => 'orWhere'] + $context + ); } } - - public static function getParameterProvider(): string - { - return IriConverterParameterProvider::class; - } } diff --git a/src/Doctrine/Orm/Filter/PartialSearchFilter.php b/src/Doctrine/Orm/Filter/PartialSearchFilter.php index b658a7509f7..4e5fd3681d0 100644 --- a/src/Doctrine/Orm/Filter/PartialSearchFilter.php +++ b/src/Doctrine/Orm/Filter/PartialSearchFilter.php @@ -15,6 +15,7 @@ use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; @@ -24,29 +25,35 @@ */ final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilterInterface { + use BackwardCompatibleFilterDescriptionTrait; use OpenApiFilterTrait; public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { - if (!$parameter = $context['parameter'] ?? null) { - return; - } - - $value = $parameter->getValue(); + $parameter = $context['parameter']; + $values = (array) $parameter->getValue(); $property = $parameter->getProperty(); $alias = $queryBuilder->getRootAliases()[0]; $field = $alias.'.'.$property; - $parameterName = $queryNameGenerator->generateParameterName($property); + $likeExpressions = []; + foreach ($values as $val) { + $parameterName = $queryNameGenerator->generateParameterName($property); + $likeExpressions[] = $queryBuilder->expr()->like( + 'LOWER('.$field.')', + ':'.$parameterName + ); - $likeExpression = $queryBuilder->expr()->like( - 'LOWER('.$field.')', - ':'.$parameterName - ); + $queryBuilder->setParameter($parameterName, '%'.strtolower($val).'%'); + } - $queryBuilder - ->andWhere($likeExpression) - ->setParameter($parameterName, '%'.strtolower($value).'%'); + if (1 === \count($likeExpressions)) { + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}($likeExpressions[0]); + } else { + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( + $queryBuilder->expr()->orX(...$likeExpressions) + ); + } } } diff --git a/src/Metadata/BackwardCompatibleFilterDescriptionTrait.php b/src/Metadata/BackwardCompatibleFilterDescriptionTrait.php new file mode 100644 index 00000000000..ace2d185f39 --- /dev/null +++ b/src/Metadata/BackwardCompatibleFilterDescriptionTrait.php @@ -0,0 +1,27 @@ + + * + * 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\Metadata; + +/** + * @author Vincent Amstoutz + * + * @internal + */ +trait BackwardCompatibleFilterDescriptionTrait +{ + public function getDescription(string $resourceClass): array + { + return []; + } +} diff --git a/src/Metadata/FilterInterface.php b/src/Metadata/FilterInterface.php index 509ef4ed8eb..72a8db12f97 100644 --- a/src/Metadata/FilterInterface.php +++ b/src/Metadata/FilterInterface.php @@ -50,6 +50,8 @@ interface FilterInterface * @param class-string $resourceClass * * @return array}> + * + * @deprecated in 4.2, to be removed in 5.0 */ public function getDescription(string $resourceClass): array; } diff --git a/src/State/ParameterProvider/IriConverterParameterProvider.php b/src/State/ParameterProvider/IriConverterParameterProvider.php index a81faa8bfda..597c32d2fb5 100644 --- a/src/State/ParameterProvider/IriConverterParameterProvider.php +++ b/src/State/ParameterProvider/IriConverterParameterProvider.php @@ -20,6 +20,7 @@ use ApiPlatform\Metadata\Parameter; use ApiPlatform\State\ParameterNotFound; use ApiPlatform\State\ParameterProviderInterface; +use Psr\Log\LoggerInterface; /** * @experimental @@ -30,6 +31,7 @@ { public function __construct( private IriConverterInterface $iriConverter, + private LoggerInterface $logger, ) { } @@ -42,23 +44,30 @@ public function provide(Parameter $parameter, array $parameters = [], array $con $iriConverterContext = ['fetch_data' => $parameter->getExtraProperties()['fetch_data'] ?? false]; - try { - if (\is_array($value)) { - $entities = []; - foreach ($value as $v) { + if (\is_array($value)) { + $entities = []; + foreach ($value as $v) { + try { $entities[] = $this->iriConverter->getResourceFromIri($v, $iriConverterContext); - } - - $parameter->setValue($entities); + } catch (InvalidArgumentException|ItemNotFoundException $exception) { + $this->logger->error( + message: 'Operation failed due to an invalid argument or a missing item', + context: [ + 'exception' => $exception->getMessage(), + ] + ); - return $operation; + break; + } } - $parameter->setValue($this->iriConverter->getResourceFromIri($value, $iriConverterContext)); - } catch (InvalidArgumentException|ItemNotFoundException) { + $parameter->setValue($entities); + return $operation; } + $parameter->setValue($this->iriConverter->getResourceFromIri($value, $iriConverterContext)); + return $operation; } } diff --git a/src/Symfony/Bundle/Resources/config/state/parameter_provider.xml b/src/Symfony/Bundle/Resources/config/state/parameter_provider.xml index d92a1a9aba5..bb5c12e204b 100644 --- a/src/Symfony/Bundle/Resources/config/state/parameter_provider.xml +++ b/src/Symfony/Bundle/Resources/config/state/parameter_provider.xml @@ -6,6 +6,7 @@ + diff --git a/tests/Fixtures/TestBundle/Document/Chicken.php b/tests/Fixtures/TestBundle/Document/Chicken.php index 4132c57ac82..1b9921a56c7 100644 --- a/tests/Fixtures/TestBundle/Document/Chicken.php +++ b/tests/Fixtures/TestBundle/Document/Chicken.php @@ -14,8 +14,6 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; -use ApiPlatform\Doctrine\Odm\Filter\IriFilter; -use ApiPlatform\Doctrine\Odm\Filter\OrFilter; use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; @@ -30,10 +28,6 @@ filter: new PartialSearchFilter(), property: 'name', ), - 'relation' => new QueryParameter( - filter: new OrFilter(new IriFilter(), new ExactFilter()), - property: 'chickenCoop' - ), ], )] class Chicken diff --git a/tests/Fixtures/TestBundle/Document/ChickenCoop.php b/tests/Fixtures/TestBundle/Document/ChickenCoop.php index bea4f8aeade..4107f4c98bb 100644 --- a/tests/Fixtures/TestBundle/Document/ChickenCoop.php +++ b/tests/Fixtures/TestBundle/Document/ChickenCoop.php @@ -13,7 +13,9 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; +use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; use ApiPlatform\Doctrine\Odm\Filter\IriFilter; +use ApiPlatform\Doctrine\Odm\Filter\OrFilter; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; use Doctrine\Common\Collections\ArrayCollection; @@ -23,7 +25,13 @@ #[ODM\Document] #[GetCollection( normalizationContext: ['hydra_prefix' => false], - parameters: ['chickens' => new QueryParameter(filter: new IriFilter())]) + parameters: [ + 'chickens' => new QueryParameter(filter: new IriFilter()), + 'relation' => new QueryParameter( + filter: new OrFilter([new IriFilter(), new ExactFilter()]), + property: 'chickens' + ), + ]) ] class ChickenCoop { diff --git a/tests/Fixtures/TestBundle/Document/Company.php b/tests/Fixtures/TestBundle/Document/Company.php index 6b3ab63fc03..ca4d697201f 100644 --- a/tests/Fixtures/TestBundle/Document/Company.php +++ b/tests/Fixtures/TestBundle/Document/Company.php @@ -37,7 +37,7 @@ #[ODM\Document] class Company { - #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] private ?int $id = null; #[ODM\Field] diff --git a/tests/Fixtures/TestBundle/Entity/Chicken.php b/tests/Fixtures/TestBundle/Entity/Chicken.php index 22f5b6afce8..9d497676608 100644 --- a/tests/Fixtures/TestBundle/Entity/Chicken.php +++ b/tests/Fixtures/TestBundle/Entity/Chicken.php @@ -14,8 +14,6 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; -use ApiPlatform\Doctrine\Orm\Filter\IriFilter; -use ApiPlatform\Doctrine\Orm\Filter\OrFilter; use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; @@ -30,10 +28,6 @@ filter: new PartialSearchFilter(), property: 'name', ), - 'relation' => new QueryParameter( - filter: new OrFilter(new IriFilter(), new ExactFilter()), - property: 'chickenCoop' - ), ], )] class Chicken diff --git a/tests/Fixtures/TestBundle/Entity/ChickenCoop.php b/tests/Fixtures/TestBundle/Entity/ChickenCoop.php index 410261adb84..5d4d812c62b 100644 --- a/tests/Fixtures/TestBundle/Entity/ChickenCoop.php +++ b/tests/Fixtures/TestBundle/Entity/ChickenCoop.php @@ -13,7 +13,9 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Doctrine\Orm\Filter\IriFilter; +use ApiPlatform\Doctrine\Orm\Filter\OrFilter; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; use Doctrine\Common\Collections\ArrayCollection; @@ -23,7 +25,13 @@ #[ORM\Entity] #[GetCollection( normalizationContext: ['hydra_prefix' => false], - parameters: ['chickens' => new QueryParameter(filter: new IriFilter())] + parameters: [ + 'chickens' => new QueryParameter(filter: new IriFilter()), + 'relation' => new QueryParameter( + filter: new OrFilter([new IriFilter(), new ExactFilter()]), + property: 'chickens' + ), + ] )] class ChickenCoop { diff --git a/tests/Functional/Parameters/ExactFilterTest.php b/tests/Functional/Parameters/ExactFilterTest.php index 91fb1a936f5..ca1434d6ae8 100644 --- a/tests/Functional/Parameters/ExactFilterTest.php +++ b/tests/Functional/Parameters/ExactFilterTest.php @@ -36,6 +36,8 @@ final class ExactFilterTest extends ApiTestCase use RecreateSchemaTrait; use SetupClassResourcesTrait; + protected static ?bool $alwaysBootKernel = false; + /** * @return class-string[] */ @@ -49,8 +51,6 @@ public static function getResources(): array */ protected function setUp(): void { - self::$alwaysBootKernel = false; - $entities = $this->isMongoDB() ? [DocumentChicken::class, DocumentChickenCoop::class] : [Chicken::class, ChickenCoop::class]; diff --git a/tests/Functional/Parameters/IriFilterTest.php b/tests/Functional/Parameters/IriFilterTest.php index 451d0e241d8..a2b32e0bba0 100644 --- a/tests/Functional/Parameters/IriFilterTest.php +++ b/tests/Functional/Parameters/IriFilterTest.php @@ -57,8 +57,6 @@ public function testIriFilterMultiple(): void */ protected function setUp(): void { - self::$alwaysBootKernel = false; - $this->recreateSchema([$this->isMongoDB() ? DocumentChicken::class : Chicken::class, $this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class]); $this->loadFixtures(); } diff --git a/tests/Functional/Parameters/OrFilterTest.php b/tests/Functional/Parameters/OrFilterTest.php index 70806f11014..65534b52e6f 100644 --- a/tests/Functional/Parameters/OrFilterTest.php +++ b/tests/Functional/Parameters/OrFilterTest.php @@ -36,6 +36,8 @@ final class OrFilterTest extends ApiTestCase use RecreateSchemaTrait; use SetupClassResourcesTrait; + protected static ?bool $alwaysBootKernel = false; + public static function getResources(): array { return [Chicken::class, ChickenCoop::class]; @@ -62,53 +64,21 @@ protected function setUp(): void * @throws TransportExceptionInterface * @throws ServerExceptionInterface */ - #[DataProvider('filterDataProvider')] - public function testOrFilter(string $url, int $expectedCount, array $expectedNames): void + #[DataProvider('orFilterDataProvider')] + public function testOrFilter(string $url, int $expectedCount): void { $client = self::createClient(); $client->request('GET', $url); $this->assertResponseIsSuccessful(); $this->assertJsonContains(['hydra:totalItems' => $expectedCount]); - - if ($expectedCount > 0) { - $names = array_column($client->getResponse()->toArray()['hydra:member'], 'name'); - sort($names); - sort($expectedNames); - $this->assertSame($expectedNames, $names); - } } - public static function filterDataProvider(): \Generator + public static function orFilterDataProvider(): \Generator { - yield 'filtre par ID du poulailler de Gertrude' => [ - 'url' => '/chickens?relation=1', - 'expectedCount' => 1, - 'expectedNames' => ['Gertrude'], - ]; - - yield 'filtre par IRI du poulailler de Gertrude' => [ - 'url' => '/chickens?relation=/chicken_coops/1', - 'expectedCount' => 1, - 'expectedNames' => ['Gertrude'], - ]; - - yield 'filtre par ID du poulailler de Henriette' => [ - 'url' => '/chickens?relation=2', - 'expectedCount' => 1, - 'expectedNames' => ['Henriette'], - ]; - - yield 'filtre par IRI du poulailler de Henriette' => [ - 'url' => '/chickens?relation=/chicken_coops/2', - 'expectedCount' => 1, - 'expectedNames' => ['Henriette'], - ]; - - yield 'filtre avec un ID inexistant' => [ - 'url' => '/chickens?relation=999', - 'expectedCount' => 0, - 'expectedNames' => [], + yield 'filter by coop 1 OR coop 2 using IRIs' => [ + 'url' => '/chickens?relation[]=/chickens/1&relation[]=/chickens/2', + 'expectedCount' => 2, ]; } @@ -124,19 +94,22 @@ private function loadFixtures(): void $chickenCoop1 = new $coopClass(); $chickenCoop2 = new $coopClass(); - $manager->persist($chickenCoop1); - $manager->persist($chickenCoop2); $chicken1 = new $chickenClass(); $chicken1->setName('Gertrude'); $chicken1->setChickenCoop($chickenCoop1); - $manager->persist($chicken1); $chicken2 = new $chickenClass(); $chicken2->setName('Henriette'); $chicken2->setChickenCoop($chickenCoop2); - $manager->persist($chicken2); + $chickenCoop1->addChicken($chicken1); + $chickenCoop2->addChicken($chicken2); + + $manager->persist($chicken1); + $manager->persist($chicken2); + $manager->persist($chickenCoop1); + $manager->persist($chickenCoop2); $manager->flush(); } } diff --git a/tests/Functional/Parameters/PartialSearchFilterTest.php b/tests/Functional/Parameters/PartialSearchFilterTest.php index f9531c90b7b..7134a312a48 100644 --- a/tests/Functional/Parameters/PartialSearchFilterTest.php +++ b/tests/Functional/Parameters/PartialSearchFilterTest.php @@ -36,6 +36,8 @@ final class PartialSearchFilterTest extends ApiTestCase use RecreateSchemaTrait; use SetupClassResourcesTrait; + protected static ?bool $alwaysBootKernel = false; + /** * @return class-string[] */ @@ -49,8 +51,6 @@ public static function getResources(): array */ protected function setUp(): void { - self::$alwaysBootKernel = false; - $entities = $this->isMongoDB() ? [DocumentChicken::class, DocumentChickenCoop::class] : [Chicken::class, ChickenCoop::class]; @@ -109,6 +109,24 @@ public static function partialSearchFilterProvider(): \Generator 0, [], ]; + + yield 'filter with multiple partial names "rude" OR "iette"' => [ + '/chickens?namePartial[]=rude&namePartial[]=iette', + 2, + ['Gertrude', 'Henriette'], + ]; + + yield 'filter with multiple partial names, one matching "Gert," the other not matching "Zebra"' => [ + '/chickens?namePartial[]=Gert&namePartial[]=Zebra', + 1, + ['Gertrude'], + ]; + + yield 'filter with multiple partial names without matches' => [ + '/chickens?namePartial[]=Toto&namePartial[]=Match', + 0, + [], + ]; } /** From 9f82c5ad5c1114b4afd2811e2301899d90401b9a Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Fri, 22 Aug 2025 14:18:52 +0200 Subject: [PATCH 19/25] feat(mongodb): handle aggregation for PartialSearchFilter --- .../Odm/Filter/PartialSearchFilter.php | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/Doctrine/Odm/Filter/PartialSearchFilter.php b/src/Doctrine/Odm/Filter/PartialSearchFilter.php index 554b6ef2880..3e868da925f 100644 --- a/src/Doctrine/Odm/Filter/PartialSearchFilter.php +++ b/src/Doctrine/Odm/Filter/PartialSearchFilter.php @@ -31,15 +31,28 @@ final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilt public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { $parameter = $context['parameter']; - $value = $parameter->getValue(); - - // TODO: handle nested properties $property = $parameter->getProperty(); - $escapedValue = preg_quote($value, '/'); - - $aggregationBuilder - ->match() - ->field($property) - ->equals(new Regex($escapedValue, 'i')); + $values = (array) $parameter->getValue(); + + if (1 === \count($values)) { + $escapedValue = preg_quote((string) $values[0], '/'); + $aggregationBuilder + ->match() + ->field($property) + ->equals(new Regex($escapedValue, 'i')); + + return; + } + + $match = $aggregationBuilder->match(); + foreach ($values as $value) { + $escapedValue = preg_quote((string) $value, '/'); + + $match->addOr( + $match->expr() + ->field($property) + ->equals(new Regex($escapedValue, 'i')) + ); + } } } From 74a21ef57f6efe0449b043a185adaade48103db9 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Fri, 22 Aug 2025 15:13:03 +0200 Subject: [PATCH 20/25] fix(test): apply review requested changes --- .../Odm/Filter/PartialSearchFilter.php | 8 +++--- .../Orm/Filter/PartialSearchFilter.php | 26 ++++++++++++------- .../TestBundle/Document/ChickenCoop.php | 7 ++++- .../TestBundle/Entity/ChickenCoop.php | 7 ++++- tests/Functional/Parameters/OrFilterTest.php | 15 +++++++++++ 5 files changed, 47 insertions(+), 16 deletions(-) diff --git a/src/Doctrine/Odm/Filter/PartialSearchFilter.php b/src/Doctrine/Odm/Filter/PartialSearchFilter.php index 3e868da925f..a9d7b09f9d7 100644 --- a/src/Doctrine/Odm/Filter/PartialSearchFilter.php +++ b/src/Doctrine/Odm/Filter/PartialSearchFilter.php @@ -32,10 +32,10 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera { $parameter = $context['parameter']; $property = $parameter->getProperty(); - $values = (array) $parameter->getValue(); + $values = $parameter->getValue(); - if (1 === \count($values)) { - $escapedValue = preg_quote((string) $values[0], '/'); + if (!is_iterable($values)) { + $escapedValue = preg_quote($values, '/'); $aggregationBuilder ->match() ->field($property) @@ -46,7 +46,7 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera $match = $aggregationBuilder->match(); foreach ($values as $value) { - $escapedValue = preg_quote((string) $value, '/'); + $escapedValue = preg_quote($value, '/'); $match->addOr( $match->expr() diff --git a/src/Doctrine/Orm/Filter/PartialSearchFilter.php b/src/Doctrine/Orm/Filter/PartialSearchFilter.php index 4e5fd3681d0..90cde75c3fa 100644 --- a/src/Doctrine/Orm/Filter/PartialSearchFilter.php +++ b/src/Doctrine/Orm/Filter/PartialSearchFilter.php @@ -31,11 +31,22 @@ final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilt public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { $parameter = $context['parameter']; - $values = (array) $parameter->getValue(); - $property = $parameter->getProperty(); $alias = $queryBuilder->getRootAliases()[0]; $field = $alias.'.'.$property; + $parameterName = $queryNameGenerator->generateParameterName($property); + $values = $parameter->getValue(); + + if (!is_iterable($values)) { + $queryBuilder->setParameter($parameterName, '%'.strtolower($values).'%'); + + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}($queryBuilder->expr()->like( + 'LOWER('.$field.')', + ':'.$parameterName + )); + + return; + } $likeExpressions = []; foreach ($values as $val) { @@ -44,16 +55,11 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q 'LOWER('.$field.')', ':'.$parameterName ); - $queryBuilder->setParameter($parameterName, '%'.strtolower($val).'%'); } - if (1 === \count($likeExpressions)) { - $queryBuilder->{$context['whereClause'] ?? 'andWhere'}($likeExpressions[0]); - } else { - $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( - $queryBuilder->expr()->orX(...$likeExpressions) - ); - } + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( + $queryBuilder->expr()->orX(...$likeExpressions) + ); } } diff --git a/tests/Fixtures/TestBundle/Document/ChickenCoop.php b/tests/Fixtures/TestBundle/Document/ChickenCoop.php index 4107f4c98bb..1e8b251d53a 100644 --- a/tests/Fixtures/TestBundle/Document/ChickenCoop.php +++ b/tests/Fixtures/TestBundle/Document/ChickenCoop.php @@ -16,6 +16,7 @@ use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; use ApiPlatform\Doctrine\Odm\Filter\IriFilter; use ApiPlatform\Doctrine\Odm\Filter\OrFilter; +use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; use Doctrine\Common\Collections\ArrayCollection; @@ -29,7 +30,11 @@ 'chickens' => new QueryParameter(filter: new IriFilter()), 'relation' => new QueryParameter( filter: new OrFilter([new IriFilter(), new ExactFilter()]), - property: 'chickens' + property: 'chickens', + ), + 'relationBis' => new QueryParameter( + filter: new OrFilter([new ExactFilter(), new PartialSearchFilter()]), + property: 'chickens', ), ]) ] diff --git a/tests/Fixtures/TestBundle/Entity/ChickenCoop.php b/tests/Fixtures/TestBundle/Entity/ChickenCoop.php index 5d4d812c62b..fca21828f90 100644 --- a/tests/Fixtures/TestBundle/Entity/ChickenCoop.php +++ b/tests/Fixtures/TestBundle/Entity/ChickenCoop.php @@ -16,6 +16,7 @@ use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Doctrine\Orm\Filter\IriFilter; use ApiPlatform\Doctrine\Orm\Filter\OrFilter; +use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; use Doctrine\Common\Collections\ArrayCollection; @@ -29,7 +30,11 @@ 'chickens' => new QueryParameter(filter: new IriFilter()), 'relation' => new QueryParameter( filter: new OrFilter([new IriFilter(), new ExactFilter()]), - property: 'chickens' + property: 'chickens', + ), + 'relationBis' => new QueryParameter( + filter: new OrFilter([new ExactFilter(), new PartialSearchFilter()]), + property: 'chickens', ), ] )] diff --git a/tests/Functional/Parameters/OrFilterTest.php b/tests/Functional/Parameters/OrFilterTest.php index 65534b52e6f..d51270e4559 100644 --- a/tests/Functional/Parameters/OrFilterTest.php +++ b/tests/Functional/Parameters/OrFilterTest.php @@ -80,6 +80,21 @@ public static function orFilterDataProvider(): \Generator 'url' => '/chickens?relation[]=/chickens/1&relation[]=/chickens/2', 'expectedCount' => 2, ]; + + yield 'relationBis: filter by coop 1 (IRI) OR coop 2 (partial name)' => [ + 'url' => '/chickens?relationBis[]=/chicken_coops/1&relationBis[]=Henri', + 'expectedCount' => 2, + ]; + + yield 'relationBis: filter by coop 1 (partial name) OR coop 2 (partial name)' => [ + 'url' => '/chickens?relationBis[]=Gertrude&relationBis[]=Henri', + 'expectedCount' => 2, + ]; + + yield 'relationBis: filter by partial name matching both coops' => [ + 'url' => '/chickens?relationBis[]=Coop', + 'expectedCount' => 2, + ]; } /** From a27de223372bac868d2ec1a4e3757387e8a99fb6 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Mon, 25 Aug 2025 16:25:56 +0200 Subject: [PATCH 21/25] fix(mongodb): start fixing filters when relations --- src/Doctrine/Odm/Filter/ExactFilter.php | 6 +++++- src/Doctrine/Odm/Filter/IriFilter.php | 12 +++++++++--- tests/Functional/Parameters/IriFilterTest.php | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/Doctrine/Odm/Filter/ExactFilter.php b/src/Doctrine/Odm/Filter/ExactFilter.php index 7d874f79b68..b549c17480c 100644 --- a/src/Doctrine/Odm/Filter/ExactFilter.php +++ b/src/Doctrine/Odm/Filter/ExactFilter.php @@ -30,9 +30,13 @@ final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterf public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { $parameter = $context['parameter']; - $value = $parameter->getValue(); $property = $parameter->getProperty(); + $value = $parameter->getValue(); + if (is_numeric($value)) { + $property .= '.id'; + } + $aggregationBuilder ->match() ->field($property) diff --git a/src/Doctrine/Odm/Filter/IriFilter.php b/src/Doctrine/Odm/Filter/IriFilter.php index 8a3f1c95505..ee122e80e07 100644 --- a/src/Doctrine/Odm/Filter/IriFilter.php +++ b/src/Doctrine/Odm/Filter/IriFilter.php @@ -33,12 +33,18 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera { $parameter = $context['parameter']; $value = $parameter->getValue(); - $property = $parameter->getProperty(); + + $isIterable = is_iterable($value); + if ($isIterable) { + $ids = array_map(static fn (object $object) => $object->getId(), iterator_to_array($value)); + } else { + $ids = \is_object($value) ? $value->getId() : $value; + } $aggregationBuilder ->match() - ->field($property) - ->{(is_iterable($value)) ? 'in' : 'equals'}($value); + ->field('id') + ->{$isIterable ? 'in' : 'equals'}($ids); } public static function getParameterProvider(): string diff --git a/tests/Functional/Parameters/IriFilterTest.php b/tests/Functional/Parameters/IriFilterTest.php index a2b32e0bba0..71970988802 100644 --- a/tests/Functional/Parameters/IriFilterTest.php +++ b/tests/Functional/Parameters/IriFilterTest.php @@ -21,6 +21,11 @@ use ApiPlatform\Tests\RecreateSchemaTrait; use ApiPlatform\Tests\SetupClassResourcesTrait; use Doctrine\ODM\MongoDB\MongoDBException; +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 IriFilterTest extends ApiTestCase { @@ -37,6 +42,13 @@ public static function getResources(): array return [ChickenCoop::class, Chicken::class]; } + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + */ public function testIriFilter(): void { $client = $this->createClient(); @@ -45,6 +57,13 @@ public function testIriFilter(): void $this->assertEquals(['/chickens/2'], $res['member'][0]['chickens']); } + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + */ public function testIriFilterMultiple(): void { $client = $this->createClient(); From da4c493ded8996bf39b03fb98d5d0b803f202e25 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 25 Aug 2025 20:47:05 +0200 Subject: [PATCH 22/25] fix(mongodb): add LoggerAwareTrait and ManagerRegistryAwareTrait, improve logging support for filters --- .../Common/Filter/LoggerAwareInterface.php | 25 +++++++++++ .../Common/Filter/LoggerAwareTrait.php | 37 +++++++++++++++++ .../Filter/ManagerRegistryAwareTrait.php | 41 +++++++++++++++++++ .../Odm/Extension/ParameterExtension.php | 7 ++++ src/Doctrine/Odm/Filter/IriFilter.php | 41 +++++++++++++++---- .../Orm/Extension/ParameterExtension.php | 7 ++++ .../Resources/config/doctrine_mongodb_odm.xml | 1 + .../Bundle/Resources/config/doctrine_orm.xml | 1 + 8 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 src/Doctrine/Common/Filter/LoggerAwareInterface.php create mode 100644 src/Doctrine/Common/Filter/LoggerAwareTrait.php create mode 100644 src/Doctrine/Common/Filter/ManagerRegistryAwareTrait.php diff --git a/src/Doctrine/Common/Filter/LoggerAwareInterface.php b/src/Doctrine/Common/Filter/LoggerAwareInterface.php new file mode 100644 index 00000000000..aa765210b46 --- /dev/null +++ b/src/Doctrine/Common/Filter/LoggerAwareInterface.php @@ -0,0 +1,25 @@ + + * + * 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\Common\Filter; + +use Psr\Log\LoggerInterface; + +interface LoggerAwareInterface +{ + public function hasLogger(): bool; + + public function getLogger(): LoggerInterface; + + public function setLogger(LoggerInterface $logger): void; +} diff --git a/src/Doctrine/Common/Filter/LoggerAwareTrait.php b/src/Doctrine/Common/Filter/LoggerAwareTrait.php new file mode 100644 index 00000000000..9ed56d1ba6e --- /dev/null +++ b/src/Doctrine/Common/Filter/LoggerAwareTrait.php @@ -0,0 +1,37 @@ + + * + * 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\Common\Filter; + +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; + +trait LoggerAwareTrait +{ + private ?LoggerInterface $logger = null; + + public function hasLogger(): bool + { + return $this->logger instanceof LoggerInterface; + } + + public function getLogger(): LoggerInterface + { + return $this->logger ??= new NullLogger(); + } + + public function setLogger(LoggerInterface $logger): void + { + $this->logger = $logger; + } +} diff --git a/src/Doctrine/Common/Filter/ManagerRegistryAwareTrait.php b/src/Doctrine/Common/Filter/ManagerRegistryAwareTrait.php new file mode 100644 index 00000000000..c7547ac71c2 --- /dev/null +++ b/src/Doctrine/Common/Filter/ManagerRegistryAwareTrait.php @@ -0,0 +1,41 @@ + + * + * 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\Common\Filter; + +use ApiPlatform\Metadata\Exception\RuntimeException; +use Doctrine\Persistence\ManagerRegistry; + +trait ManagerRegistryAwareTrait +{ + private ?ManagerRegistry $managerRegistry = null; + + public function hasManagerRegistry(): bool + { + return $this->managerRegistry instanceof ManagerRegistry; + } + + public function getManagerRegistry(): ManagerRegistry + { + if (!$this->hasManagerRegistry()) { + throw new RuntimeException('ManagerRegistry must be initialized before accessing it.'); + } + + return $this->managerRegistry; + } + + public function setManagerRegistry(ManagerRegistry $managerRegistry): void + { + $this->managerRegistry = $managerRegistry; + } +} diff --git a/src/Doctrine/Odm/Extension/ParameterExtension.php b/src/Doctrine/Odm/Extension/ParameterExtension.php index 9420003e453..02b2f61d95d 100644 --- a/src/Doctrine/Odm/Extension/ParameterExtension.php +++ b/src/Doctrine/Odm/Extension/ParameterExtension.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Doctrine\Odm\Extension; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; use ApiPlatform\Doctrine\Odm\Filter\AbstractFilter; @@ -22,6 +23,7 @@ use Doctrine\Bundle\MongoDBBundle\ManagerRegistry; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; /** * Reads operation parameters and execute its filter. @@ -35,6 +37,7 @@ final class ParameterExtension implements AggregationCollectionExtensionInterfac public function __construct( private readonly ContainerInterface $filterLocator, private readonly ?ManagerRegistry $managerRegistry = null, + private readonly ?LoggerInterface $logger = null, ) { } @@ -67,6 +70,10 @@ private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass $filter->setManagerRegistry($this->managerRegistry); } + if ($this->logger && $filter instanceof LoggerAwareInterface && !$filter->hasLogger()) { + $filter->setLogger($this->logger); + } + if ($filter instanceof AbstractFilter && !$filter->getProperties()) { $propertyKey = $parameter->getProperty() ?? $parameter->getKey(); diff --git a/src/Doctrine/Odm/Filter/IriFilter.php b/src/Doctrine/Odm/Filter/IriFilter.php index ee122e80e07..96f33da0109 100644 --- a/src/Doctrine/Odm/Filter/IriFilter.php +++ b/src/Doctrine/Odm/Filter/IriFilter.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Doctrine\Odm\Filter; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; @@ -20,31 +22,54 @@ use ApiPlatform\Metadata\ParameterProviderFilterInterface; use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; use Doctrine\ODM\MongoDB\Aggregation\Builder; +use Doctrine\ODM\MongoDB\DocumentManager; /** * @author Vincent Amstoutz */ -final class IriFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface +final class IriFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface, ManagerRegistryAwareInterface { use BackwardCompatibleFilterDescriptionTrait; + use ManagerRegistryAwareTrait; use OpenApiFilterTrait; public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { $parameter = $context['parameter']; + $property = $parameter->getProperty(); $value = $parameter->getValue(); - $isIterable = is_iterable($value); - if ($isIterable) { - $ids = array_map(static fn (object $object) => $object->getId(), iterator_to_array($value)); - } else { - $ids = \is_object($value) ? $value->getId() : $value; + $documentManager = $this->getManagerRegistry()?->getManagerForClass($resourceClass); + + if (!$documentManager instanceof DocumentManager) { + return; + } + + $classMetadata = $documentManager->getClassMetadata($resourceClass); + + if (!$classMetadata->hasReference($property)) { + return; + } + + $method = $classMetadata->isCollectionValuedAssociation($property) ? 'includesReferenceTo' : 'references'; + + if (is_iterable($value)) { + $match = $aggregationBuilder->match(); + $or = $match->expr(); + + foreach ($value as $v) { + $or->addOr($match->expr()->field($property)->{$method}($v)); + } + + $match->addAnd($or); + + return; } $aggregationBuilder ->match() - ->field('id') - ->{$isIterable ? 'in' : 'equals'}($ids); + ->field($property) + ->{$method}($value); } public static function getParameterProvider(): string diff --git a/src/Doctrine/Orm/Extension/ParameterExtension.php b/src/Doctrine/Orm/Extension/ParameterExtension.php index 17d02352fd4..70533ad584d 100644 --- a/src/Doctrine/Orm/Extension/ParameterExtension.php +++ b/src/Doctrine/Orm/Extension/ParameterExtension.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Doctrine\Orm\Extension; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; @@ -23,6 +24,7 @@ use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; /** * Reads operation parameters and execute its filter. @@ -36,6 +38,7 @@ final class ParameterExtension implements QueryCollectionExtensionInterface, Que public function __construct( private readonly ContainerInterface $filterLocator, private readonly ?ManagerRegistry $managerRegistry = null, + private readonly ?LoggerInterface $logger = null, ) { } @@ -68,6 +71,10 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter $filter->setManagerRegistry($this->managerRegistry); } + if ($this->logger && $filter instanceof LoggerAwareInterface && !$filter->hasLogger()) { + $filter->setLogger($this->logger); + } + if ($filter instanceof AbstractFilter && !$filter->getProperties()) { $propertyKey = $parameter->getProperty() ?? $parameter->getKey(); diff --git a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml index 8b39e43a9c8..449d61f172f 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml @@ -138,6 +138,7 @@ + diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml index 7f34ecacc27..0e0e9e3d5c5 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml @@ -151,6 +151,7 @@ + From 61e1b9ab3a2eb7aa04486c5b01c5aeb2fa0e6320 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Tue, 26 Aug 2025 12:04:40 +0200 Subject: [PATCH 23/25] fix(mongodb): enhance ExactFilter and IriFilter to better handle relations --- src/Doctrine/Odm/Filter/ExactFilter.php | 57 ++++++++++++++++++++++--- src/Doctrine/Odm/Filter/IriFilter.php | 36 ++++++++++++++-- 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/src/Doctrine/Odm/Filter/ExactFilter.php b/src/Doctrine/Odm/Filter/ExactFilter.php index b549c17480c..2859880b11b 100644 --- a/src/Doctrine/Odm/Filter/ExactFilter.php +++ b/src/Doctrine/Odm/Filter/ExactFilter.php @@ -13,33 +13,80 @@ namespace ApiPlatform\Doctrine\Odm\Filter; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ODM\MongoDB\Aggregation\Builder; +use Doctrine\ODM\MongoDB\DocumentManager; +use Doctrine\ODM\MongoDB\LockException; +use Doctrine\ODM\MongoDB\Mapping\MappingException; /** * @author Vincent Amstoutz */ -final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface +final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface, ManagerRegistryAwareInterface { use BackwardCompatibleFilterDescriptionTrait; + use ManagerRegistryAwareTrait; use OpenApiFilterTrait; + /** + * @throws MappingException + * @throws LockException + */ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { $parameter = $context['parameter']; $property = $parameter->getProperty(); - $value = $parameter->getValue(); - if (is_numeric($value)) { - $property .= '.id'; + + $documentManager = $this->getManagerRegistry()->getManagerForClass($resourceClass); + if (!$documentManager instanceof DocumentManager) { + return; + } + + $classMetadata = $documentManager->getClassMetadata($resourceClass); + + if (!$classMetadata->hasReference($property)) { + $aggregationBuilder + ->match() + ->field($property) + ->{is_iterable($value) ? 'in' : 'equals'}($value); + + return; } + $mapping = $classMetadata->getFieldMapping($property); + $targetDocument = $mapping['targetDocument']; + $repository = $documentManager->getRepository($targetDocument); + $method = $classMetadata->isCollectionValuedAssociation($property) ? 'includesReferenceTo' : 'references'; + + if (is_iterable($value)) { + $documents = []; + foreach ($value as $v) { + if ($doc = $repository->find($v)) { + $documents[] = $doc; + } + } + + $match = $aggregationBuilder->match(); + $or = $match->expr(); + foreach ($documents as $doc) { + $or->addOr($match->expr()->field($property)->{$method}($doc)); + } + $match->addAnd($or); + + return; + } + + $referencedDoc = $repository->find($value); + $aggregationBuilder ->match() ->field($property) - ->{(is_iterable($value)) ? 'in' : 'equals'}($value); + ->{$method}($referencedDoc); } } diff --git a/src/Doctrine/Odm/Filter/IriFilter.php b/src/Doctrine/Odm/Filter/IriFilter.php index 96f33da0109..3847b0a3232 100644 --- a/src/Doctrine/Odm/Filter/IriFilter.php +++ b/src/Doctrine/Odm/Filter/IriFilter.php @@ -23,6 +23,8 @@ use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\ODM\MongoDB\DocumentManager; +use Doctrine\ODM\MongoDB\Mapping\MappingException; +use Symfony\Component\PropertyAccess\PropertyAccess; /** * @author Vincent Amstoutz @@ -33,24 +35,52 @@ final class IriFilter implements FilterInterface, OpenApiParameterFilterInterfac use ManagerRegistryAwareTrait; use OpenApiFilterTrait; + /** + * @throws MappingException + */ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { $parameter = $context['parameter']; $property = $parameter->getProperty(); $value = $parameter->getValue(); - $documentManager = $this->getManagerRegistry()?->getManagerForClass($resourceClass); - + $documentManager = $this->getManagerRegistry()->getManagerForClass($resourceClass); if (!$documentManager instanceof DocumentManager) { return; } $classMetadata = $documentManager->getClassMetadata($resourceClass); - if (!$classMetadata->hasReference($property)) { return; } + $mapping = $classMetadata->getFieldMapping($property); + + if (isset($mapping['mappedBy'])) { + $propertyAccessor = PropertyAccess::createPropertyAccessor(); + $mappedByProperty = $mapping['mappedBy']; + $identifier = '_id'; + + if (is_iterable($value)) { + $ids = []; + foreach ($value as $v) { + if ($relatedDoc = $propertyAccessor->getValue($v, $mappedByProperty)) { + $ids[] = $propertyAccessor->getValue($relatedDoc, 'id'); + } + } + + $aggregationBuilder->match()->field($identifier)->in($ids); + + return; + } + + if ($relatedDoc = $propertyAccessor->getValue($value, $mappedByProperty)) { + $aggregationBuilder->match()->field($identifier)->equals($propertyAccessor->getValue($relatedDoc, 'id')); + } + + return; + } + $method = $classMetadata->isCollectionValuedAssociation($property) ? 'includesReferenceTo' : 'references'; if (is_iterable($value)) { From 57ed37e3c9434f7f0900caea038cbd227cecab04 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 26 Aug 2025 17:42:08 +0200 Subject: [PATCH 24/25] fixes --- src/Doctrine/Odm/Filter/ExactFilter.php | 21 ++--- .../Odm/Filter/FreeTextQueryFilter.php | 58 ++++++++++++ src/Doctrine/Odm/Filter/IriFilter.php | 32 +------ src/Doctrine/Odm/Filter/OrFilter.php | 23 ++++- src/Doctrine/Orm/Filter/ExactFilter.php | 1 - .../Orm/Filter/FreeTextQueryFilter.php | 59 +++++++++++++ src/Doctrine/Orm/Filter/IriFilter.php | 8 +- src/Doctrine/Orm/Filter/OrFilter.php | 37 +++++--- .../IriConverterParameterProvider.php | 22 ++++- .../Fixtures/TestBundle/Document/Chicken.php | 23 ++++- .../TestBundle/Document/ChickenCoop.php | 20 +---- tests/Fixtures/TestBundle/Entity/Chicken.php | 23 ++++- .../TestBundle/Entity/ChickenCoop.php | 16 ---- .../NotSkipNullToOneRelationTest.php | 28 ------ .../Parameters/BooleanFilterTest.php | 19 ---- .../Functional/Parameters/DateFilterTest.php | 19 ---- .../Functional/Parameters/ExactFilterTest.php | 14 +-- .../Parameters/ExistsFilterTest.php | 12 --- .../Parameters/FreeTextQueryFilterTest.php | 88 +++++++++++++++++++ tests/Functional/Parameters/IriFilterTest.php | 25 +----- .../Parameters/NumericFilterTest.php | 19 ---- tests/Functional/Parameters/OrFilterTest.php | 38 ++------ .../Functional/Parameters/OrderFilterTest.php | 12 --- .../Parameters/PartialSearchFilterTest.php | 14 +-- .../Functional/Parameters/RangeFilterTest.php | 19 ---- 25 files changed, 340 insertions(+), 310 deletions(-) create mode 100644 src/Doctrine/Odm/Filter/FreeTextQueryFilter.php create mode 100644 src/Doctrine/Orm/Filter/FreeTextQueryFilter.php create mode 100644 tests/Functional/Parameters/FreeTextQueryFilterTest.php diff --git a/src/Doctrine/Odm/Filter/ExactFilter.php b/src/Doctrine/Odm/Filter/ExactFilter.php index 2859880b11b..12c01826af5 100644 --- a/src/Doctrine/Odm/Filter/ExactFilter.php +++ b/src/Doctrine/Odm/Filter/ExactFilter.php @@ -60,33 +60,24 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera } $mapping = $classMetadata->getFieldMapping($property); - $targetDocument = $mapping['targetDocument']; - $repository = $documentManager->getRepository($targetDocument); - $method = $classMetadata->isCollectionValuedAssociation($property) ? 'includesReferenceTo' : 'references'; + $method = $classMetadata->isSingleValuedAssociation($property) ? 'references' : 'includesReferenceTo'; if (is_iterable($value)) { - $documents = []; - foreach ($value as $v) { - if ($doc = $repository->find($v)) { - $documents[] = $doc; - } - } - $match = $aggregationBuilder->match(); $or = $match->expr(); - foreach ($documents as $doc) { - $or->addOr($match->expr()->field($property)->{$method}($doc)); + + foreach ($value as $v) { + $or->addOr($match->expr()->field($property)->{$method}($documentManager->getPartialReference($mapping['targetDocument'], $v))); } + $match->addAnd($or); return; } - $referencedDoc = $repository->find($value); - $aggregationBuilder ->match() ->field($property) - ->{$method}($referencedDoc); + ->{$method}($documentManager->getPartialReference($mapping['targetDocument'], $value)); } } diff --git a/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php b/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php new file mode 100644 index 00000000000..f247f3d2c92 --- /dev/null +++ b/src/Doctrine/Odm/Filter/FreeTextQueryFilter.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\Doctrine\Odm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Operation; +use Doctrine\ODM\MongoDB\Aggregation\Builder; + +final class FreeTextQueryFilter implements FilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use LoggerAwareTrait; + use ManagerRegistryAwareTrait; + + /** + * @param list $properties an array of properties, defaults to `parameter->getProperties()` + */ + public function __construct(private readonly FilterInterface $filter, private readonly ?array $properties = null) + { + } + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + if ($this->filter instanceof ManagerRegistryAwareInterface) { + $this->filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($this->filter instanceof LoggerAwareInterface) { + $this->filter->setLogger($this->getLogger()); + } + + $parameter = $context['parameter']; + foreach ($this->properties ?? $parameter->getProperties() ?? [] as $property) { + $newContext = ['parameter' => $parameter->withProperty($property)] + $context; + $this->filter->apply( + $aggregationBuilder, + $resourceClass, + $operation, + $newContext, + ); + } + } +} diff --git a/src/Doctrine/Odm/Filter/IriFilter.php b/src/Doctrine/Odm/Filter/IriFilter.php index 3847b0a3232..0621ad49e83 100644 --- a/src/Doctrine/Odm/Filter/IriFilter.php +++ b/src/Doctrine/Odm/Filter/IriFilter.php @@ -24,7 +24,6 @@ use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Mapping\MappingException; -use Symfony\Component\PropertyAccess\PropertyAccess; /** * @author Vincent Amstoutz @@ -41,7 +40,6 @@ final class IriFilter implements FilterInterface, OpenApiParameterFilterInterfac public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { $parameter = $context['parameter']; - $property = $parameter->getProperty(); $value = $parameter->getValue(); $documentManager = $this->getManagerRegistry()->getManagerForClass($resourceClass); @@ -50,38 +48,12 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera } $classMetadata = $documentManager->getClassMetadata($resourceClass); + $property = $parameter->getProperty(); if (!$classMetadata->hasReference($property)) { return; } - $mapping = $classMetadata->getFieldMapping($property); - - if (isset($mapping['mappedBy'])) { - $propertyAccessor = PropertyAccess::createPropertyAccessor(); - $mappedByProperty = $mapping['mappedBy']; - $identifier = '_id'; - - if (is_iterable($value)) { - $ids = []; - foreach ($value as $v) { - if ($relatedDoc = $propertyAccessor->getValue($v, $mappedByProperty)) { - $ids[] = $propertyAccessor->getValue($relatedDoc, 'id'); - } - } - - $aggregationBuilder->match()->field($identifier)->in($ids); - - return; - } - - if ($relatedDoc = $propertyAccessor->getValue($value, $mappedByProperty)) { - $aggregationBuilder->match()->field($identifier)->equals($propertyAccessor->getValue($relatedDoc, 'id')); - } - - return; - } - - $method = $classMetadata->isCollectionValuedAssociation($property) ? 'includesReferenceTo' : 'references'; + $method = $classMetadata->isSingleValuedAssociation($property) ? 'references' : 'includesReferenceTo'; if (is_iterable($value)) { $match = $aggregationBuilder->match(); diff --git a/src/Doctrine/Odm/Filter/OrFilter.php b/src/Doctrine/Odm/Filter/OrFilter.php index fab1ac460f7..cf3d50da566 100644 --- a/src/Doctrine/Odm/Filter/OrFilter.php +++ b/src/Doctrine/Odm/Filter/OrFilter.php @@ -13,6 +13,10 @@ namespace ApiPlatform\Doctrine\Odm\Filter; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; @@ -22,21 +26,34 @@ /** * @author Vincent Amstoutz */ -final class OrFilter implements FilterInterface, OpenApiParameterFilterInterface +final class OrFilter implements FilterInterface, OpenApiParameterFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface { use BackwardCompatibleFilterDescriptionTrait; + use LoggerAwareTrait; + use ManagerRegistryAwareTrait; use OpenApiFilterTrait; /** - * @param array $filters + * @param FilterInterface[] $filters */ - public function __construct(private readonly array $filters) + private array $filters; + + public function __construct(FilterInterface ...$filters) { + $this->filters = $filters; } public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { foreach ($this->filters as $filter) { + if ($filter instanceof ManagerRegistryAwareInterface) { + $filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($filter instanceof LoggerAwareInterface) { + $filter->setLogger($this->getLogger()); + } + $context = ['whereClause' => 'orWhere'] + $context; $filter->apply($aggregationBuilder, $resourceClass, $operation, $context); } diff --git a/src/Doctrine/Orm/Filter/ExactFilter.php b/src/Doctrine/Orm/Filter/ExactFilter.php index ec567ecb82d..37956151713 100644 --- a/src/Doctrine/Orm/Filter/ExactFilter.php +++ b/src/Doctrine/Orm/Filter/ExactFilter.php @@ -32,7 +32,6 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q { $parameter = $context['parameter']; $value = $parameter->getValue(); - $property = $parameter->getProperty(); $alias = $queryBuilder->getRootAliases()[0]; $parameterName = $queryNameGenerator->generateParameterName($property); diff --git a/src/Doctrine/Orm/Filter/FreeTextQueryFilter.php b/src/Doctrine/Orm/Filter/FreeTextQueryFilter.php new file mode 100644 index 00000000000..c73504c597a --- /dev/null +++ b/src/Doctrine/Orm/Filter/FreeTextQueryFilter.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; + +final class FreeTextQueryFilter implements FilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use LoggerAwareTrait; + use ManagerRegistryAwareTrait; + + /** + * @param list $properties an array of properties, defaults to `parameter->getProperties()` + */ + public function __construct(private readonly FilterInterface $filter, private readonly ?array $properties = null) + { + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if ($this->filter instanceof ManagerRegistryAwareInterface) { + $this->filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($this->filter instanceof LoggerAwareInterface) { + $this->filter->setLogger($this->getLogger()); + } + + $parameter = $context['parameter']; + foreach ($this->properties ?? $parameter->getProperties() ?? [] as $property) { + $this->filter->apply( + $queryBuilder, + $queryNameGenerator, + $resourceClass, + $operation, + ['parameter' => $parameter->withProperty($property)] + $context + ); + } + } +} diff --git a/src/Doctrine/Orm/Filter/IriFilter.php b/src/Doctrine/Orm/Filter/IriFilter.php index 628562edb3c..32acf8f59f1 100644 --- a/src/Doctrine/Orm/Filter/IriFilter.php +++ b/src/Doctrine/Orm/Filter/IriFilter.php @@ -33,24 +33,22 @@ final class IriFilter implements FilterInterface, OpenApiParameterFilterInterfac public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { $parameter = $context['parameter']; - $value = $parameter->getValue(); - $property = $parameter->getProperty(); $alias = $queryBuilder->getRootAliases()[0]; $parameterName = $queryNameGenerator->generateParameterName($property); $queryBuilder->join(\sprintf('%s.%s', $alias, $property), $parameterName); - if (\is_array($value)) { + if (is_iterable($value)) { $queryBuilder ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s IN (:%s)', $parameterName, $parameterName)); + $queryBuilder->setParameter($parameterName, $value); } else { $queryBuilder ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s = :%s', $parameterName, $parameterName)); + $queryBuilder->setParameter($parameterName, $value); } - - $queryBuilder->setParameter($parameterName, $value); } public static function getParameterProvider(): string diff --git a/src/Doctrine/Orm/Filter/OrFilter.php b/src/Doctrine/Orm/Filter/OrFilter.php index cc49546e4d8..d8e020221a7 100644 --- a/src/Doctrine/Orm/Filter/OrFilter.php +++ b/src/Doctrine/Orm/Filter/OrFilter.php @@ -13,6 +13,10 @@ namespace ApiPlatform\Doctrine\Orm\Filter; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; @@ -22,29 +26,36 @@ /** * @author Vincent Amstoutz + * + * @experimental */ -final class OrFilter implements FilterInterface, OpenApiParameterFilterInterface +final class OrFilter implements FilterInterface, OpenApiParameterFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface { use BackwardCompatibleFilterDescriptionTrait; + use LoggerAwareTrait; + use ManagerRegistryAwareTrait; use OpenApiFilterTrait; - /** - * @param array $filters - */ - public function __construct(private readonly array $filters) + public function __construct(private readonly FilterInterface $filter) { } public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { - foreach ($this->filters as $filter) { - $filter->apply( - $queryBuilder, - $queryNameGenerator, - $resourceClass, - $operation, - ['whereClause' => 'orWhere'] + $context - ); + if ($this->filter instanceof ManagerRegistryAwareInterface) { + $this->filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($this->filter instanceof LoggerAwareInterface) { + $this->filter->setLogger($this->getLogger()); } + + $this->filter->apply( + $queryBuilder, + $queryNameGenerator, + $resourceClass, + $operation, + ['whereClause' => 'orWhere'] + $context + ); } } diff --git a/src/State/ParameterProvider/IriConverterParameterProvider.php b/src/State/ParameterProvider/IriConverterParameterProvider.php index 597c32d2fb5..3d28f5be729 100644 --- a/src/State/ParameterProvider/IriConverterParameterProvider.php +++ b/src/State/ParameterProvider/IriConverterParameterProvider.php @@ -42,7 +42,8 @@ public function provide(Parameter $parameter, array $parameters = [], array $con return $operation; } - $iriConverterContext = ['fetch_data' => $parameter->getExtraProperties()['fetch_data'] ?? false]; + $extraProperties = $parameter->getExtraProperties(); + $iriConverterContext = ['fetch_data' => $extraProperties['fetch_data'] ?? false]; if (\is_array($value)) { $entities = []; @@ -50,6 +51,10 @@ public function provide(Parameter $parameter, array $parameters = [], array $con try { $entities[] = $this->iriConverter->getResourceFromIri($v, $iriConverterContext); } catch (InvalidArgumentException|ItemNotFoundException $exception) { + if ($exception instanceof ItemNotFoundException && true === ($extraProperties['throw_not_found'] ?? false)) { + throw $exception; + } + $this->logger->error( message: 'Operation failed due to an invalid argument or a missing item', context: [ @@ -66,7 +71,20 @@ public function provide(Parameter $parameter, array $parameters = [], array $con return $operation; } - $parameter->setValue($this->iriConverter->getResourceFromIri($value, $iriConverterContext)); + try { + $parameter->setValue($this->iriConverter->getResourceFromIri($value, $iriConverterContext)); + } catch (InvalidArgumentException|ItemNotFoundException $exception) { + if ($exception instanceof ItemNotFoundException && true === ($extraProperties['throw_not_found'] ?? false)) { + throw $exception; + } + + $this->logger->error( + message: 'Operation failed due to an invalid argument or a missing item', + context: [ + 'exception' => $exception->getMessage(), + ] + ); + } return $operation; } diff --git a/tests/Fixtures/TestBundle/Document/Chicken.php b/tests/Fixtures/TestBundle/Document/Chicken.php index 1b9921a56c7..831487a3ac2 100644 --- a/tests/Fixtures/TestBundle/Document/Chicken.php +++ b/tests/Fixtures/TestBundle/Document/Chicken.php @@ -14,6 +14,9 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Odm\Filter\FreeTextQueryFilter; +use ApiPlatform\Doctrine\Odm\Filter\IriFilter; +use ApiPlatform\Doctrine\Odm\Filter\OrFilter; use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; @@ -21,13 +24,16 @@ #[ODM\Document] #[GetCollection( + normalizationContext: ['hydra_prefix' => false], parameters: [ - 'chickenCoop' => new QueryParameter(filter: new ExactFilter()), + 'chickenCoop' => new QueryParameter(filter: new IriFilter()), 'name' => new QueryParameter(filter: new ExactFilter()), 'namePartial' => new QueryParameter( filter: new PartialSearchFilter(), property: 'name', ), + 'autocomplete' => new QueryParameter(filter: new FreeTextQueryFilter(new OrFilter(new ExactFilter())), properties: ['name', 'ean']), + 'q' => new QueryParameter(filter: new FreeTextQueryFilter(new PartialSearchFilter()), properties: ['name', 'ean']), ], )] class Chicken @@ -38,6 +44,9 @@ class Chicken #[ODM\Field(type: 'string')] private string $name; + #[ODM\Field(type: 'string', nullable: true)] + private ?string $ean; + #[ODM\ReferenceOne(targetDocument: ChickenCoop::class, inversedBy: 'chickens')] private ?ChickenCoop $chickenCoop = null; @@ -58,6 +67,18 @@ public function setName(string $name): self return $this; } + public function getEan(): ?string + { + return $this->ean; + } + + public function setEan(string $ean): self + { + $this->ean = $ean; + + return $this; + } + public function getChickenCoop(): ?ChickenCoop { return $this->chickenCoop; diff --git a/tests/Fixtures/TestBundle/Document/ChickenCoop.php b/tests/Fixtures/TestBundle/Document/ChickenCoop.php index 1e8b251d53a..df1a0a8a7fa 100644 --- a/tests/Fixtures/TestBundle/Document/ChickenCoop.php +++ b/tests/Fixtures/TestBundle/Document/ChickenCoop.php @@ -13,31 +13,15 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; -use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; -use ApiPlatform\Doctrine\Odm\Filter\IriFilter; -use ApiPlatform\Doctrine\Odm\Filter\OrFilter; -use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter; use ApiPlatform\Metadata\GetCollection; -use ApiPlatform\Metadata\QueryParameter; 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()), - 'relation' => new QueryParameter( - filter: new OrFilter([new IriFilter(), new ExactFilter()]), - property: 'chickens', - ), - 'relationBis' => new QueryParameter( - filter: new OrFilter([new ExactFilter(), new PartialSearchFilter()]), - property: 'chickens', - ), - ]) -] + normalizationContext: ['hydra_prefix' => false] +)] class ChickenCoop { #[ODM\Id(type: 'int', strategy: 'INCREMENT')] diff --git a/tests/Fixtures/TestBundle/Entity/Chicken.php b/tests/Fixtures/TestBundle/Entity/Chicken.php index 9d497676608..d22a777a57b 100644 --- a/tests/Fixtures/TestBundle/Entity/Chicken.php +++ b/tests/Fixtures/TestBundle/Entity/Chicken.php @@ -14,6 +14,9 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Orm\Filter\FreeTextQueryFilter; +use ApiPlatform\Doctrine\Orm\Filter\IriFilter; +use ApiPlatform\Doctrine\Orm\Filter\OrFilter; use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; @@ -21,13 +24,16 @@ #[ORM\Entity] #[GetCollection( + normalizationContext: ['hydra_prefix' => false], parameters: [ - 'chickenCoop' => new QueryParameter(filter: new ExactFilter()), + 'chickenCoop' => new QueryParameter(filter: new IriFilter()), 'name' => new QueryParameter(filter: new ExactFilter()), 'namePartial' => new QueryParameter( filter: new PartialSearchFilter(), property: 'name', ), + 'autocomplete' => new QueryParameter(filter: new FreeTextQueryFilter(new OrFilter(new ExactFilter())), properties: ['name', 'ean']), + 'q' => new QueryParameter(filter: new FreeTextQueryFilter(new PartialSearchFilter()), properties: ['name', 'ean']), ], )] class Chicken @@ -40,6 +46,9 @@ class Chicken #[ORM\Column(type: 'string', length: 255)] private string $name; + #[ORM\Column(type: 'string', length: 255, nullable: true)] + private ?string $ean; + #[ORM\ManyToOne(targetEntity: ChickenCoop::class, inversedBy: 'chickens')] #[ORM\JoinColumn(nullable: false)] private ChickenCoop $chickenCoop; @@ -61,6 +70,18 @@ public function setName(string $name): self return $this; } + public function getEan(): ?string + { + return $this->ean; + } + + public function setEan(string $ean): self + { + $this->ean = $ean; + + return $this; + } + public function getChickenCoop(): ?ChickenCoop { return $this->chickenCoop; diff --git a/tests/Fixtures/TestBundle/Entity/ChickenCoop.php b/tests/Fixtures/TestBundle/Entity/ChickenCoop.php index fca21828f90..49b52c98b18 100644 --- a/tests/Fixtures/TestBundle/Entity/ChickenCoop.php +++ b/tests/Fixtures/TestBundle/Entity/ChickenCoop.php @@ -13,12 +13,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; -use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; -use ApiPlatform\Doctrine\Orm\Filter\IriFilter; -use ApiPlatform\Doctrine\Orm\Filter\OrFilter; -use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; use ApiPlatform\Metadata\GetCollection; -use ApiPlatform\Metadata\QueryParameter; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -26,17 +21,6 @@ #[ORM\Entity] #[GetCollection( normalizationContext: ['hydra_prefix' => false], - parameters: [ - 'chickens' => new QueryParameter(filter: new IriFilter()), - 'relation' => new QueryParameter( - filter: new OrFilter([new IriFilter(), new ExactFilter()]), - property: 'chickens', - ), - 'relationBis' => new QueryParameter( - filter: new OrFilter([new ExactFilter(), new PartialSearchFilter()]), - property: 'chickens', - ), - ] )] class ChickenCoop { diff --git a/tests/Functional/NotSkipNullToOneRelationTest.php b/tests/Functional/NotSkipNullToOneRelationTest.php index 9a0b0db26ef..c6943383d0c 100644 --- a/tests/Functional/NotSkipNullToOneRelationTest.php +++ b/tests/Functional/NotSkipNullToOneRelationTest.php @@ -17,11 +17,6 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue4372\RelatedEntity; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue4372\ToOneRelationPropertyMayBeNull; use ApiPlatform\Tests\SetupClassResourcesTrait; -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; class NotSkipNullToOneRelationTest extends ApiTestCase { @@ -37,14 +32,6 @@ public static function getResources(): array return [ToOneRelationPropertyMayBeNull::class, RelatedEntity::class]; } - /** - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - * @throws \JsonException - */ public function testNullRelationsAreNotSkippedWhenConfigured(): void { if ($this->isMongoDB()) { @@ -114,14 +101,6 @@ public function testNullRelationsAreNotSkippedWhenConfigured(): void ); } - /** - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - * @throws \JsonException - */ public function testNullRelationsAreSkippedByDefault(): void { if ($this->isMongoDB()) { @@ -189,13 +168,6 @@ public function testNullRelationsAreSkippedByDefault(): void ); } - /** - * @throws ClientExceptionInterface - * @throws DecodingExceptionInterface - * @throws RedirectionExceptionInterface - * @throws ServerExceptionInterface - * @throws TransportExceptionInterface - */ private function checkRoutesAreCorrectlySetUp(): void { self::createClient()->request( diff --git a/tests/Functional/Parameters/BooleanFilterTest.php b/tests/Functional/Parameters/BooleanFilterTest.php index 7c7c02b8f6f..6a2a9e553a2 100644 --- a/tests/Functional/Parameters/BooleanFilterTest.php +++ b/tests/Functional/Parameters/BooleanFilterTest.php @@ -20,11 +20,6 @@ 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 BooleanFilterTest extends ApiTestCase { @@ -53,13 +48,6 @@ protected function setUp(): void $this->loadFixtures($entityClass); } - /** - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - * @throws TransportExceptionInterface - */ #[DataProvider('booleanFilterScenariosProvider')] public function testBooleanFilterResponses(string $url, int $expectedActiveItemCount, bool $expectedActiveStatus): void { @@ -88,13 +76,6 @@ public static function booleanFilterScenariosProvider(): \Generator yield 'enabled_alias_numeric_0' => ['/filtered_boolean_parameters?enabled=0', 1, false]; } - /** - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - */ #[DataProvider('booleanFilterNullAndEmptyScenariosProvider')] public function testBooleanFilterWithNullAndEmptyValues(string $url): void { diff --git a/tests/Functional/Parameters/DateFilterTest.php b/tests/Functional/Parameters/DateFilterTest.php index 2187e758a87..77174da8c58 100644 --- a/tests/Functional/Parameters/DateFilterTest.php +++ b/tests/Functional/Parameters/DateFilterTest.php @@ -20,11 +20,6 @@ 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 DateFilterTest extends ApiTestCase { @@ -52,13 +47,6 @@ protected function setUp(): void $this->loadFixtures($entityClass); } - /** - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - */ #[DataProvider('dateFilterScenariosProvider')] public function testDateFilterResponses(string $url, int $expectedCount): void { @@ -90,13 +78,6 @@ public static function dateFilterScenariosProvider(): \Generator yield 'date_alias_old_way_after_last_one' => ['/filtered_date_parameters?date_old_way[after]=2024-12-31', 1]; } - /** - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - */ #[DataProvider('dateFilterNullAndEmptyScenariosProvider')] public function testDateFilterWithNullAndEmptyValues(string $url, int $expectedCount): void { diff --git a/tests/Functional/Parameters/ExactFilterTest.php b/tests/Functional/Parameters/ExactFilterTest.php index ca1434d6ae8..2daf0b34b9d 100644 --- a/tests/Functional/Parameters/ExactFilterTest.php +++ b/tests/Functional/Parameters/ExactFilterTest.php @@ -22,11 +22,6 @@ 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; /** * @author Vincent Amstoutz @@ -59,13 +54,6 @@ protected function setUp(): void $this->loadFixtures(); } - /** - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - * @throws TransportExceptionInterface - */ #[DataProvider('exactSearchFilterProvider')] public function testExactSearchFilter(string $url, int $expectedCount, array $expectedNames): void { @@ -73,7 +61,7 @@ public function testExactSearchFilter(string $url, int $expectedCount, array $ex $this->assertResponseIsSuccessful(); $responseData = $response->toArray(); - $filteredItems = $responseData['hydra:member']; + $filteredItems = $responseData['member']; $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); diff --git a/tests/Functional/Parameters/ExistsFilterTest.php b/tests/Functional/Parameters/ExistsFilterTest.php index e2bc02c10ec..122854154bf 100644 --- a/tests/Functional/Parameters/ExistsFilterTest.php +++ b/tests/Functional/Parameters/ExistsFilterTest.php @@ -20,11 +20,6 @@ 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; /** * @author Vincent Amstoutz @@ -55,13 +50,6 @@ protected function setUp(): void $this->loadFixtures($entityClass); } - /** - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - */ #[DataProvider('existsFilterScenariosProvider')] public function testExistsFilterResponses(string $url, int $expectedCount): void { diff --git a/tests/Functional/Parameters/FreeTextQueryFilterTest.php b/tests/Functional/Parameters/FreeTextQueryFilterTest.php new file mode 100644 index 00000000000..8f07a653fd3 --- /dev/null +++ b/tests/Functional/Parameters/FreeTextQueryFilterTest.php @@ -0,0 +1,88 @@ + + * + * 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 FreeTextQueryFilterTest 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 testFreeTextQueryFilter(): void + { + $client = $this->createClient(); + $res = $client->request('GET', '/chickens?q=9780')->toArray(); + $this->assertJsonContains(['totalItems' => 1]); + } + + /** + * @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(); + $chickenClass = $this->isMongoDB() ? DocumentChicken::class : Chicken::class; + $coopClass = $this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class; + + $chickenCoop1 = new $coopClass(); + $chickenCoop2 = new $coopClass(); + + $chicken1 = new $chickenClass(); + $chicken1->setName('978020137962'); + $chicken1->setEan('978020137962'); + $chicken1->setChickenCoop($chickenCoop1); + + $chicken2 = new $chickenClass(); + $chicken2->setName('Henriette'); + $chicken2->setEan('978020137963'); + $chicken2->setChickenCoop($chickenCoop2); + + $chickenCoop1->addChicken($chicken1); + $chickenCoop2->addChicken($chicken2); + + $manager->persist($chicken1); + $manager->persist($chicken2); + $manager->persist($chickenCoop1); + $manager->persist($chickenCoop2); + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/IriFilterTest.php b/tests/Functional/Parameters/IriFilterTest.php index 71970988802..8cd6ead2074 100644 --- a/tests/Functional/Parameters/IriFilterTest.php +++ b/tests/Functional/Parameters/IriFilterTest.php @@ -21,11 +21,6 @@ use ApiPlatform\Tests\RecreateSchemaTrait; use ApiPlatform\Tests\SetupClassResourcesTrait; use Doctrine\ODM\MongoDB\MongoDBException; -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 IriFilterTest extends ApiTestCase { @@ -42,32 +37,18 @@ public static function getResources(): array return [ChickenCoop::class, Chicken::class]; } - /** - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - */ public function testIriFilter(): void { $client = $this->createClient(); - $res = $client->request('GET', '/chicken_coops?chickens=/chickens/2')->toArray(); + $res = $client->request('GET', '/chickens?chickenCoop=/chicken_coops/2')->toArray(); $this->assertCount(1, $res['member']); - $this->assertEquals(['/chickens/2'], $res['member'][0]['chickens']); + $this->assertEquals('/chicken_coops/2', $res['member'][0]['chickenCoop']); } - /** - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - */ public function testIriFilterMultiple(): void { $client = $this->createClient(); - $res = $client->request('GET', '/chicken_coops?chickens[]=/chickens/2&chickens[]=/chickens/1')->toArray(); + $res = $client->request('GET', '/chickens?chickenCoop[]=/chicken_coops/2&chickenCoop[]=/chicken_coops/1')->toArray(); $this->assertCount(2, $res['member']); } diff --git a/tests/Functional/Parameters/NumericFilterTest.php b/tests/Functional/Parameters/NumericFilterTest.php index 4ad7b7dbab6..03512ac1462 100644 --- a/tests/Functional/Parameters/NumericFilterTest.php +++ b/tests/Functional/Parameters/NumericFilterTest.php @@ -20,11 +20,6 @@ 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 NumericFilterTest extends ApiTestCase { @@ -52,13 +47,6 @@ protected function setUp(): void $this->loadFixtures($entityClass); } - /** - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - */ #[DataProvider('rangeFilterScenariosProvider')] public function testRangeFilterResponses(string $url, int $expectedCount): void { @@ -78,13 +66,6 @@ public static function rangeFilterScenariosProvider(): \Generator yield 'amount_alias_int_equal' => ['/filtered_numeric_parameters?amount=20', 2]; } - /** - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - */ #[DataProvider('nullAndEmptyScenariosProvider')] public function testRangeFilterWithNullAndEmptyValues(string $url, int $expectedCount): void { diff --git a/tests/Functional/Parameters/OrFilterTest.php b/tests/Functional/Parameters/OrFilterTest.php index d51270e4559..4c67a50308c 100644 --- a/tests/Functional/Parameters/OrFilterTest.php +++ b/tests/Functional/Parameters/OrFilterTest.php @@ -22,11 +22,6 @@ 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; /** * @author Vincent Amstoutz @@ -57,13 +52,6 @@ protected function setUp(): void $this->loadFixtures(); } - /** - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - */ #[DataProvider('orFilterDataProvider')] public function testOrFilter(string $url, int $expectedCount): void { @@ -71,29 +59,19 @@ public function testOrFilter(string $url, int $expectedCount): void $client->request('GET', $url); $this->assertResponseIsSuccessful(); - $this->assertJsonContains(['hydra:totalItems' => $expectedCount]); + $this->assertJsonContains(['totalItems' => $expectedCount]); } public static function orFilterDataProvider(): \Generator { - yield 'filter by coop 1 OR coop 2 using IRIs' => [ - 'url' => '/chickens?relation[]=/chickens/1&relation[]=/chickens/2', - 'expectedCount' => 2, - ]; - - yield 'relationBis: filter by coop 1 (IRI) OR coop 2 (partial name)' => [ - 'url' => '/chickens?relationBis[]=/chicken_coops/1&relationBis[]=Henri', - 'expectedCount' => 2, - ]; - - yield 'relationBis: filter by coop 1 (partial name) OR coop 2 (partial name)' => [ - 'url' => '/chickens?relationBis[]=Gertrude&relationBis[]=Henri', - 'expectedCount' => 2, + yield 'ean through autocomplete' => [ + 'url' => '/chickens?autocomplete=978020137962', + 'expectedCount' => 1, ]; - yield 'relationBis: filter by partial name matching both coops' => [ - 'url' => '/chickens?relationBis[]=Coop', - 'expectedCount' => 2, + yield 'name through autocomplete' => [ + 'url' => '/chickens?autocomplete=Gertrude', + 'expectedCount' => 1, ]; } @@ -112,10 +90,12 @@ private function loadFixtures(): void $chicken1 = new $chickenClass(); $chicken1->setName('Gertrude'); + $chicken1->setEan('978020137962'); $chicken1->setChickenCoop($chickenCoop1); $chicken2 = new $chickenClass(); $chicken2->setName('Henriette'); + $chicken2->setEan('978020137963'); $chicken2->setChickenCoop($chickenCoop2); $chickenCoop1->addChicken($chicken1); diff --git a/tests/Functional/Parameters/OrderFilterTest.php b/tests/Functional/Parameters/OrderFilterTest.php index 6b7fb6a23d2..c893caae55e 100644 --- a/tests/Functional/Parameters/OrderFilterTest.php +++ b/tests/Functional/Parameters/OrderFilterTest.php @@ -20,11 +20,6 @@ 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 OrderFilterTest extends ApiTestCase { @@ -52,13 +47,6 @@ protected function setUp(): void $this->loadFixtures($entityClass); } - /** - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - */ #[DataProvider('orderFilterScenariosProvider')] public function testOrderFilterResponses(string $url, array $expectedOrder): void { diff --git a/tests/Functional/Parameters/PartialSearchFilterTest.php b/tests/Functional/Parameters/PartialSearchFilterTest.php index 7134a312a48..72f6cc744d7 100644 --- a/tests/Functional/Parameters/PartialSearchFilterTest.php +++ b/tests/Functional/Parameters/PartialSearchFilterTest.php @@ -22,11 +22,6 @@ 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; /** * @author Vincent Amstoutz @@ -59,13 +54,6 @@ protected function setUp(): void $this->loadFixtures(); } - /** - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - * @throws TransportExceptionInterface - */ #[DataProvider('partialSearchFilterProvider')] public function testPartialSearchFilter(string $url, int $expectedCount, array $expectedNames): void { @@ -73,7 +61,7 @@ public function testPartialSearchFilter(string $url, int $expectedCount, array $ $this->assertResponseIsSuccessful(); $responseData = $response->toArray(); - $filteredItems = $responseData['hydra:member']; + $filteredItems = $responseData['member']; $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); diff --git a/tests/Functional/Parameters/RangeFilterTest.php b/tests/Functional/Parameters/RangeFilterTest.php index 51d1d232f16..0ad31aa5c29 100644 --- a/tests/Functional/Parameters/RangeFilterTest.php +++ b/tests/Functional/Parameters/RangeFilterTest.php @@ -20,11 +20,6 @@ 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 RangeFilterTest extends ApiTestCase { @@ -52,13 +47,6 @@ protected function setUp(): void $this->loadFixtures($entityClass); } - /** - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - */ #[DataProvider('rangeFilterScenariosProvider')] public function testRangeFilterResponses(string $url, int $expectedCount): void { @@ -93,13 +81,6 @@ public static function rangeFilterScenariosProvider(): \Generator yield 'amount_alias_lte_and_between' => ['/filtered_range_parameters?amount[lte]=30&amount[between]=15..40', 2]; } - /** - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - */ #[DataProvider('nullAndEmptyScenariosProvider')] public function testRangeFilterWithNullAndEmptyValues(string $url, int $expectedCount): void { From 7349cec4bbf669ede0613836b055ce3c2db7ff28 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 27 Aug 2025 14:15:54 +0200 Subject: [PATCH 25/25] more --- .../Odm/Extension/ParameterExtension.php | 9 ++++++- src/Doctrine/Odm/Filter/ExactFilter.php | 27 ++++++++++--------- .../Odm/Filter/FreeTextQueryFilter.php | 6 ++++- src/Doctrine/Odm/Filter/IriFilter.php | 22 +++++++++------ src/Doctrine/Odm/Filter/OrFilter.php | 27 ++++++++----------- .../Odm/Filter/PartialSearchFilter.php | 19 ++++++++----- .../Fixtures/TestBundle/Document/Chicken.php | 1 + tests/Fixtures/TestBundle/Entity/Chicken.php | 1 + .../Functional/Parameters/ExactFilterTest.php | 6 ++--- .../Parameters/FreeTextQueryFilterTest.php | 2 +- 10 files changed, 71 insertions(+), 49 deletions(-) diff --git a/src/Doctrine/Odm/Extension/ParameterExtension.php b/src/Doctrine/Odm/Extension/ParameterExtension.php index 02b2f61d95d..585ea9ec059 100644 --- a/src/Doctrine/Odm/Extension/ParameterExtension.php +++ b/src/Doctrine/Odm/Extension/ParameterExtension.php @@ -89,12 +89,19 @@ private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass $filter->setProperties($properties ?? []); } - $filterContext = ['filters' => $values, 'parameter' => $parameter]; + $filterContext = ['filters' => $values, 'parameter' => $parameter, 'match' => $context['match'] ?? null]; $filter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext); // update by reference if (isset($filterContext['mongodb_odm_sort_fields'])) { $context['mongodb_odm_sort_fields'] = $filterContext['mongodb_odm_sort_fields']; } + if (isset($filterContext['match'])) { + $context['match'] = $filterContext['match']; + } + } + + if (isset($context['match'])) { + $aggregationBuilder->match()->addAnd($context['match']); } } diff --git a/src/Doctrine/Odm/Filter/ExactFilter.php b/src/Doctrine/Odm/Filter/ExactFilter.php index 12c01826af5..ccb883d86e6 100644 --- a/src/Doctrine/Odm/Filter/ExactFilter.php +++ b/src/Doctrine/Odm/Filter/ExactFilter.php @@ -42,6 +42,10 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera $parameter = $context['parameter']; $property = $parameter->getProperty(); $value = $parameter->getValue(); + $operator = $context['operator'] ?? 'addAnd'; + $match = $context['match'] = $context['match'] ?? + $aggregationBuilder + ->matchExpr(); $documentManager = $this->getManagerRegistry()->getManagerForClass($resourceClass); if (!$documentManager instanceof DocumentManager) { @@ -51,10 +55,8 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera $classMetadata = $documentManager->getClassMetadata($resourceClass); if (!$classMetadata->hasReference($property)) { - $aggregationBuilder - ->match() - ->field($property) - ->{is_iterable($value) ? 'in' : 'equals'}($value); + $match + ->{$operator}($aggregationBuilder->matchExpr()->field($property)->{is_iterable($value) ? 'in' : 'equals'}($value)); return; } @@ -63,21 +65,22 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera $method = $classMetadata->isSingleValuedAssociation($property) ? 'references' : 'includesReferenceTo'; if (is_iterable($value)) { - $match = $aggregationBuilder->match(); - $or = $match->expr(); + $or = $aggregationBuilder->matchExpr(); foreach ($value as $v) { - $or->addOr($match->expr()->field($property)->{$method}($documentManager->getPartialReference($mapping['targetDocument'], $v))); + $or->addOr($aggregationBuilder->matchExpr()->field($property)->{$method}($documentManager->getPartialReference($mapping['targetDocument'], $v))); } - $match->addAnd($or); + $match->{$operator}($or); return; } - $aggregationBuilder - ->match() - ->field($property) - ->{$method}($documentManager->getPartialReference($mapping['targetDocument'], $value)); + $match + ->{$operator}( + $aggregationBuilder->matchExpr() + ->field($property) + ->{$method}($documentManager->getPartialReference($mapping['targetDocument'], $value)) + ); } } diff --git a/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php b/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php index f247f3d2c92..2dde16d6ecc 100644 --- a/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php +++ b/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php @@ -46,13 +46,17 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera $parameter = $context['parameter']; foreach ($this->properties ?? $parameter->getProperties() ?? [] as $property) { - $newContext = ['parameter' => $parameter->withProperty($property)] + $context; + $newContext = ['parameter' => $parameter->withProperty($property), 'match' => $context['match'] ?? $aggregationBuilder->match()->expr()] + $context; $this->filter->apply( $aggregationBuilder, $resourceClass, $operation, $newContext, ); + + if (isset($newContext['match'])) { + $context['match'] = $newContext['match']; + } } } } diff --git a/src/Doctrine/Odm/Filter/IriFilter.php b/src/Doctrine/Odm/Filter/IriFilter.php index 0621ad49e83..4f0d742dc1b 100644 --- a/src/Doctrine/Odm/Filter/IriFilter.php +++ b/src/Doctrine/Odm/Filter/IriFilter.php @@ -41,6 +41,10 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera { $parameter = $context['parameter']; $value = $parameter->getValue(); + $operator = $context['operator'] ?? 'addAnd'; + $match = $context['match'] = $context['match'] ?? + $aggregationBuilder + ->matchExpr(); $documentManager = $this->getManagerRegistry()->getManagerForClass($resourceClass); if (!$documentManager instanceof DocumentManager) { @@ -56,22 +60,24 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera $method = $classMetadata->isSingleValuedAssociation($property) ? 'references' : 'includesReferenceTo'; if (is_iterable($value)) { - $match = $aggregationBuilder->match(); - $or = $match->expr(); + $or = $aggregationBuilder->matchExpr(); foreach ($value as $v) { - $or->addOr($match->expr()->field($property)->{$method}($v)); + $or->addOr($aggregationBuilder->matchExpr()->field($property)->{$method}($v)); } - $match->addAnd($or); + $match->{$operator}($or); return; } - $aggregationBuilder - ->match() - ->field($property) - ->{$method}($value); + $match + ->{$operator}( + $aggregationBuilder + ->matchExpr() + ->field($property) + ->{$method}($value) + ); } public static function getParameterProvider(): string diff --git a/src/Doctrine/Odm/Filter/OrFilter.php b/src/Doctrine/Odm/Filter/OrFilter.php index cf3d50da566..9017bceba13 100644 --- a/src/Doctrine/Odm/Filter/OrFilter.php +++ b/src/Doctrine/Odm/Filter/OrFilter.php @@ -33,29 +33,24 @@ final class OrFilter implements FilterInterface, OpenApiParameterFilterInterface use ManagerRegistryAwareTrait; use OpenApiFilterTrait; - /** - * @param FilterInterface[] $filters - */ - private array $filters; - - public function __construct(FilterInterface ...$filters) + public function __construct(private readonly FilterInterface $filter) { - $this->filters = $filters; } public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { - foreach ($this->filters as $filter) { - if ($filter instanceof ManagerRegistryAwareInterface) { - $filter->setManagerRegistry($this->getManagerRegistry()); - } + if ($this->filter instanceof ManagerRegistryAwareInterface) { + $this->filter->setManagerRegistry($this->getManagerRegistry()); + } - if ($filter instanceof LoggerAwareInterface) { - $filter->setLogger($this->getLogger()); - } + if ($this->filter instanceof LoggerAwareInterface) { + $this->filter->setLogger($this->getLogger()); + } - $context = ['whereClause' => 'orWhere'] + $context; - $filter->apply($aggregationBuilder, $resourceClass, $operation, $context); + $newContext = ['operator' => 'addOr'] + $context; + $this->filter->apply($aggregationBuilder, $resourceClass, $operation, $newContext); + if (isset($newContext['match'])) { + $context['match'] = $newContext['match']; } } } diff --git a/src/Doctrine/Odm/Filter/PartialSearchFilter.php b/src/Doctrine/Odm/Filter/PartialSearchFilter.php index a9d7b09f9d7..f5fd2f1bb32 100644 --- a/src/Doctrine/Odm/Filter/PartialSearchFilter.php +++ b/src/Doctrine/Odm/Filter/PartialSearchFilter.php @@ -33,26 +33,31 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera $parameter = $context['parameter']; $property = $parameter->getProperty(); $values = $parameter->getValue(); + $match = $context['match'] = $context['match'] ?? + $aggregationBuilder + ->matchExpr(); + $operator = $context['operator'] ?? 'addAnd'; if (!is_iterable($values)) { $escapedValue = preg_quote($values, '/'); - $aggregationBuilder - ->match() - ->field($property) - ->equals(new Regex($escapedValue, 'i')); + $match->{$operator}( + $aggregationBuilder->matchExpr()->field($property)->equals(new Regex($escapedValue, 'i')) + ); return; } - $match = $aggregationBuilder->match(); + $or = $aggregationBuilder->matchExpr(); foreach ($values as $value) { $escapedValue = preg_quote($value, '/'); - $match->addOr( - $match->expr() + $or->addOr( + $aggregationBuilder->matchExpr() ->field($property) ->equals(new Regex($escapedValue, 'i')) ); } + + $match->{$operator}($or); } } diff --git a/tests/Fixtures/TestBundle/Document/Chicken.php b/tests/Fixtures/TestBundle/Document/Chicken.php index 831487a3ac2..2fe846ecae3 100644 --- a/tests/Fixtures/TestBundle/Document/Chicken.php +++ b/tests/Fixtures/TestBundle/Document/Chicken.php @@ -27,6 +27,7 @@ normalizationContext: ['hydra_prefix' => false], parameters: [ 'chickenCoop' => new QueryParameter(filter: new IriFilter()), + 'chickenCoopId' => new QueryParameter(filter: new ExactFilter(), property: 'chickenCoop'), 'name' => new QueryParameter(filter: new ExactFilter()), 'namePartial' => new QueryParameter( filter: new PartialSearchFilter(), diff --git a/tests/Fixtures/TestBundle/Entity/Chicken.php b/tests/Fixtures/TestBundle/Entity/Chicken.php index d22a777a57b..f3533f59801 100644 --- a/tests/Fixtures/TestBundle/Entity/Chicken.php +++ b/tests/Fixtures/TestBundle/Entity/Chicken.php @@ -27,6 +27,7 @@ normalizationContext: ['hydra_prefix' => false], parameters: [ 'chickenCoop' => new QueryParameter(filter: new IriFilter()), + 'chickenCoopId' => new QueryParameter(filter: new ExactFilter(), property: 'chickenCoop'), 'name' => new QueryParameter(filter: new ExactFilter()), 'namePartial' => new QueryParameter( filter: new PartialSearchFilter(), diff --git a/tests/Functional/Parameters/ExactFilterTest.php b/tests/Functional/Parameters/ExactFilterTest.php index 2daf0b34b9d..61e16f9bddd 100644 --- a/tests/Functional/Parameters/ExactFilterTest.php +++ b/tests/Functional/Parameters/ExactFilterTest.php @@ -87,19 +87,19 @@ public static function exactSearchFilterProvider(): \Generator ]; yield 'filter by exact coop id' => [ - '/chickens?chickenCoop=1', + '/chickens?chickenCoopId=1', 1, ['Gertrude'], ]; yield 'filter by coop id and correct name' => [ - '/chickens?chickenCoop=1&name=Gertrude', + '/chickens?chickenCoopId=1&name=Gertrude', 1, ['Gertrude'], ]; yield 'filter by coop id and incorrect name' => [ - '/chickens?chickenCoop=1&name=Henriette', + '/chickens?chickenCoopId=1&name=Henriette', 0, [], ]; diff --git a/tests/Functional/Parameters/FreeTextQueryFilterTest.php b/tests/Functional/Parameters/FreeTextQueryFilterTest.php index 8f07a653fd3..55dbfb0b8b4 100644 --- a/tests/Functional/Parameters/FreeTextQueryFilterTest.php +++ b/tests/Functional/Parameters/FreeTextQueryFilterTest.php @@ -40,7 +40,7 @@ public static function getResources(): array public function testFreeTextQueryFilter(): void { $client = $this->createClient(); - $res = $client->request('GET', '/chickens?q=9780')->toArray(); + $client->request('GET', '/chickens?q=9780')->toArray(); $this->assertJsonContains(['totalItems' => 1]); }