From c141b25c4d91f2ee82e91387d4bc620878e7b491 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 10 Apr 2025 16:32:44 +0200 Subject: [PATCH] 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 src/Metadata/Parameter.php | 7 ++ ...meterResourceMetadataCollectionFactory.php | 6 +- .../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 +++++++++++++++++++ 14 files changed, 494 insertions(+), 3 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/Metadata/Parameter.php b/src/Metadata/Parameter.php index 815a68d405c..36c6cb3559f 100644 --- a/src/Metadata/Parameter.php +++ b/src/Metadata/Parameter.php @@ -127,6 +127,13 @@ public function getValue(mixed $default = new ParameterNotFound()): mixed return $this->extraProperties['_api_values'] ?? $default; } + public function setValue(mixed $value): static + { + $this->extraProperties['_api_values'] = $value; + + return $this; + } + /** * @return array */ diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index a164456d6ad..3fcef0b8f31 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -117,6 +117,10 @@ private function getDefaultParameters(Operation $operation, string $resourceClas ['propertyNames' => $propertyNames, 'properties' => $properties] = $this->getProperties($resourceClass); $parameters = $operation->getParameters() ?? new Parameters(); foreach ($parameters as $key => $parameter) { + if (null === $parameter->getProvider() && (($f = $parameter->getFilter()) && $f instanceof ParameterProviderFilterInterface)) { + $parameters->add($key, $parameter->withProvider($f->getParameterProvider())); + } + if (':property' === $key) { foreach ($propertyNames as $property) { $converted = $this->nameConverter?->denormalize($property) ?? $property; @@ -131,7 +135,7 @@ private function getDefaultParameters(Operation $operation, string $resourceClas $key = $parameter->getKey() ?? $key; - if (str_contains($key, ':property') || (($f = $parameter->getFilter()) && is_a($f, PropertiesAwareInterface::class, true)) || $parameter instanceof PropertiesAwareInterface) { + if (str_contains($key, ':property') && ((($f = $parameter->getFilter()) && is_a($f, PropertiesAwareInterface::class, true)) || $parameter instanceof PropertiesAwareInterface)) { $p = []; foreach ($propertyNames as $prop) { $p[$this->nameConverter?->denormalize($prop) ?? $prop] = $prop; diff --git a/src/State/Provider/IriConverterParameterProvider.php b/src/State/Provider/IriConverterParameterProvider.php new file mode 100644 index 00000000000..a7bc538472c --- /dev/null +++ b/src/State/Provider/IriConverterParameterProvider.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\State\Provider; + +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\State\ParameterNotFound; +use ApiPlatform\State\ParameterProviderInterface; + +/** + * @author Vincent Amstoutz + */ +final readonly class IriConverterParameterProvider implements ParameterProviderInterface +{ + public function __construct( + private IriConverterInterface $iriConverter, + ) { + } + + public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation + { + $operation = $context['operation'] ?? null; + if (!($value = $parameter->getValue()) || $value instanceof ParameterNotFound) { + return $operation; + } + + if (!\is_array($value)) { + $value = [$value]; + } + + $entities = []; + foreach ($value as $v) { + $entities[] = $this->iriConverter->getResourceFromIri($v, ['fetch_data' => false]); + } + + $parameter->setValue($entities); + + return $operation; + } +} diff --git a/src/Symfony/Bundle/Resources/config/state/provider.xml b/src/Symfony/Bundle/Resources/config/state/provider.xml index 52bb2ea23f7..45f19b9eb9d 100644 --- a/src/Symfony/Bundle/Resources/config/state/provider.xml +++ b/src/Symfony/Bundle/Resources/config/state/provider.xml @@ -42,5 +42,11 @@ + + + + + + diff --git a/tests/Fixtures/TestBundle/Document/Chicken.php b/tests/Fixtures/TestBundle/Document/Chicken.php new file mode 100644 index 00000000000..89f79de85c2 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/Chicken.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Metadata\Get; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ODM\Document] +#[Get] +class Chicken +{ + #[ODM\Id] + private string $id; + + #[ODM\Field(type: 'string')] + private string $name; + + #[ODM\ReferenceOne(targetDocument: ChickenCoop::class, inversedBy: 'chickens')] + private ?ChickenCoop $chickenCoop = null; + + public function getId(): ?string + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getChickenCoop(): ?ChickenCoop + { + return $this->chickenCoop; + } + + public function setChickenCoop(?ChickenCoop $chickenCoop): self + { + $this->chickenCoop = $chickenCoop; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Document/ChickenCoop.php b/tests/Fixtures/TestBundle/Document/ChickenCoop.php new file mode 100644 index 00000000000..71ccb5204a8 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/ChickenCoop.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Doctrine\Orm\Filter\IriFilter; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Chicken; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ODM\Document] +#[GetCollection(normalizationContext: ['hydra_prefix' => false], parameters: ['chickens' => new QueryParameter(filter: new IriFilter())])] +class ChickenCoop +{ + #[ODM\Id] + private ?string $id = null; + + #[ODM\ReferenceMany(targetDocument: Chicken::class, mappedBy: 'chickenCoop')] + private Collection $chickens; + + public function __construct() + { + $this->chickens = new ArrayCollection(); + } + + public function getId(): ?string + { + return $this->id; + } + + /** + * @return Collection + */ + public function getChickens(): Collection + { + return $this->chickens; + } + + public function addChicken(Chicken $chicken): self + { + if (!$this->chickens->contains($chicken)) { + $this->chickens[] = $chicken; + $chicken->setChickenCoop($this); + } + + return $this; + } + + public function removeChicken(Chicken $chicken): self + { + if ($this->chickens->removeElement($chicken)) { + if ($chicken->getChickenCoop() === $this) { + $chicken->setChickenCoop(null); + } + } + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Document/Company.php b/tests/Fixtures/TestBundle/Document/Company.php index ca88faa6e44..a878dfad32d 100644 --- a/tests/Fixtures/TestBundle/Document/Company.php +++ b/tests/Fixtures/TestBundle/Document/Company.php @@ -22,7 +22,7 @@ use Symfony\Component\Serializer\Annotation\Groups; #[ApiResource] -#[GetCollection] +#[GetCollection()] #[Get] #[Post] #[ApiResource(uriTemplate: '/employees/{employeeId}/rooms/{roomId}/company/{companyId}', uriVariables: ['employeeId' => ['from_class' => Employee::class, 'from_property' => 'company']])] diff --git a/tests/Fixtures/TestBundle/Entity/Chicken.php b/tests/Fixtures/TestBundle/Entity/Chicken.php new file mode 100644 index 00000000000..dea2bde7a58 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Chicken.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\Get; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[Get()] +class Chicken +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 255)] + private string $name; + + #[ORM\ManyToOne(targetEntity: ChickenCoop::class, inversedBy: 'chickens')] + #[ORM\JoinColumn(nullable: false)] + private ChickenCoop $chickenCoop; + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getChickenCoop(): ?ChickenCoop + { + return $this->chickenCoop; + } + + public function setChickenCoop(?ChickenCoop $chickenCoop): self + { + $this->chickenCoop = $chickenCoop; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/ChickenCoop.php b/tests/Fixtures/TestBundle/Entity/ChickenCoop.php new file mode 100644 index 00000000000..0c821e660da --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/ChickenCoop.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Doctrine\Orm\Filter\IriFilter; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[GetCollection(normalizationContext: ['hydra_prefix' => false], parameters: ['chickens' => new QueryParameter(filter: new IriFilter())])] +class ChickenCoop +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\OneToMany(mappedBy: 'chickenCoop', targetEntity: Chicken::class, cascade: ['persist'])] + private Collection $chickens; + + public function __construct() + { + $this->chickens = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + /** + * @return Collection + */ + public function getChickens(): Collection + { + return $this->chickens; + } + + public function addChicken(Chicken $chicken): self + { + if (!$this->chickens->contains($chicken)) { + $this->chickens[] = $chicken; + $chicken->setChickenCoop($this); + } + + return $this; + } + + public function removeChicken(Chicken $chicken): self + { + if ($this->chickens->removeElement($chicken)) { + if ($chicken->getChickenCoop() === $this) { + $chicken->setChickenCoop(null); + } + } + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Company.php b/tests/Fixtures/TestBundle/Entity/Company.php index 26abb965a28..a95a9731da2 100644 --- a/tests/Fixtures/TestBundle/Entity/Company.php +++ b/tests/Fixtures/TestBundle/Entity/Company.php @@ -22,7 +22,7 @@ use Symfony\Component\Serializer\Annotation\Groups; #[ApiResource] -#[GetCollection] +#[GetCollection()] #[Get] #[Post] #[ApiResource( diff --git a/tests/Functional/Parameters/IriFilterTest.php b/tests/Functional/Parameters/IriFilterTest.php new file mode 100644 index 00000000000..a2b32e0bba0 --- /dev/null +++ b/tests/Functional/Parameters/IriFilterTest.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Chicken as DocumentChicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ChickenCoop as DocumentChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Chicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ChickenCoop; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; + +final class IriFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ChickenCoop::class, Chicken::class]; + } + + public function testIriFilter(): void + { + $client = $this->createClient(); + $res = $client->request('GET', '/chicken_coops?chickens=/chickens/2')->toArray(); + $this->assertCount(1, $res['member']); + $this->assertEquals(['/chickens/2'], $res['member'][0]['chickens']); + } + + public function testIriFilterMultiple(): void + { + $client = $this->createClient(); + $res = $client->request('GET', '/chicken_coops?chickens[]=/chickens/2&chickens[]=/chickens/1')->toArray(); + $this->assertCount(2, $res['member']); + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $this->recreateSchema([$this->isMongoDB() ? DocumentChicken::class : Chicken::class, $this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class]); + $this->loadFixtures(); + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(): void + { + $manager = $this->getManager(); + + $chickenCoop1 = new ($this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class)(); + $chickenCoop2 = new ($this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class)(); + + $chicken1 = new ($this->isMongoDB() ? DocumentChicken::class : Chicken::class)(); + $chicken1->setName('Gertrude'); + $chicken1->setChickenCoop($chickenCoop1); + + $chicken2 = new ($this->isMongoDB() ? DocumentChicken::class : Chicken::class)(); + $chicken2->setName('Henriette'); + $chicken2->setChickenCoop($chickenCoop2); + + $chickenCoop1->addChicken($chicken1); + $chickenCoop2->addChicken($chicken2); + + $manager->persist($chicken1); + $manager->persist($chicken2); + $manager->persist($chickenCoop1); + $manager->persist($chickenCoop2); + $manager->flush(); + } +}