From 753776fa1d53f511ce1ef1901466026e582a718e Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Thu, 12 Dec 2024 16:10:08 +0100 Subject: [PATCH] feat(doctrine): search filters like laravel eloquent filters --- .../Common/Filter/ExactSearchFilterTrait.php | 25 ++++ .../Common/Filter/IriSearchFilterTrait.php | 25 ++++ src/Doctrine/Orm/Filter/ExactSearchFilter.php | 54 +++++++ .../Orm/Filter/FilterInterfaceTrait.php | 78 ++++++++++ src/Doctrine/Orm/Filter/IriSearchFilter.php | 84 +++++++++++ src/Metadata/Parameter.php | 7 + ...meterResourceMetadataCollectionFactory.php | 1 + .../IriConverterParameterProvider.php | 65 ++++++++ .../Bundle/Resources/config/doctrine_orm.xml | 14 ++ .../Resources/config/state/provider.xml | 8 + .../Resources/config/symfony/events.xml | 8 + .../TestBundle/Entity/DummyAuthor.php | 58 +++++++ .../TestBundle/Entity/DummyAuthorExact.php | 58 +++++++ .../Fixtures/TestBundle/Entity/DummyBook.php | 83 +++++++++++ .../TestBundle/Entity/DummyBookExact.php | 86 +++++++++++ .../Parameters/ExactSearchFilterTest.php | 141 ++++++++++++++++++ .../Parameters/IriSearchFilterTest.php | 140 +++++++++++++++++ 17 files changed, 935 insertions(+) create mode 100644 src/Doctrine/Common/Filter/ExactSearchFilterTrait.php create mode 100644 src/Doctrine/Common/Filter/IriSearchFilterTrait.php create mode 100644 src/Doctrine/Orm/Filter/ExactSearchFilter.php create mode 100644 src/Doctrine/Orm/Filter/FilterInterfaceTrait.php create mode 100644 src/Doctrine/Orm/Filter/IriSearchFilter.php create mode 100644 src/State/Provider/IriConverterParameterProvider.php create mode 100644 tests/Fixtures/TestBundle/Entity/DummyAuthor.php create mode 100644 tests/Fixtures/TestBundle/Entity/DummyAuthorExact.php create mode 100644 tests/Fixtures/TestBundle/Entity/DummyBook.php create mode 100644 tests/Fixtures/TestBundle/Entity/DummyBookExact.php create mode 100644 tests/Functional/Parameters/ExactSearchFilterTest.php create mode 100644 tests/Functional/Parameters/IriSearchFilterTest.php diff --git a/src/Doctrine/Common/Filter/ExactSearchFilterTrait.php b/src/Doctrine/Common/Filter/ExactSearchFilterTrait.php new file mode 100644 index 00000000000..dfd92cad36c --- /dev/null +++ b/src/Doctrine/Common/Filter/ExactSearchFilterTrait.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 ApiPlatform\Metadata\Parameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; + +trait ExactSearchFilterTrait +{ + 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/Common/Filter/IriSearchFilterTrait.php b/src/Doctrine/Common/Filter/IriSearchFilterTrait.php new file mode 100644 index 00000000000..361318315d4 --- /dev/null +++ b/src/Doctrine/Common/Filter/IriSearchFilterTrait.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 ApiPlatform\Metadata\Parameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; + +trait IriSearchFilterTrait +{ + 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/ExactSearchFilter.php b/src/Doctrine/Orm/Filter/ExactSearchFilter.php new file mode 100644 index 00000000000..1388e9ea7d9 --- /dev/null +++ b/src/Doctrine/Orm/Filter/ExactSearchFilter.php @@ -0,0 +1,54 @@ + + * + * 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\ExactSearchFilterTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; +use Doctrine\Persistence\ManagerRegistry; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +final class ExactSearchFilter implements FilterInterface, ManagerRegistryAwareInterface, OpenApiParameterFilterInterface +{ + use ExactSearchFilterTrait; + use FilterInterfaceTrait; + + public function __construct( + private ?ManagerRegistry $managerRegistry = null, + private readonly ?array $properties = null, + private readonly ?NameConverterInterface $nameConverter = null, + ) { + } + + protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if ( + null === $value + || !$this->isPropertyEnabled($property, $resourceClass) + || !$this->isPropertyMapped($property, $resourceClass, true) + ) { + return; + } + + $alias = $queryBuilder->getRootAliases()[0]; + $parameterName = $queryNameGenerator->generateParameterName($property); + + $queryBuilder + ->andWhere(\sprintf('%s.%s = :%s', $alias, $property, $parameterName)) + ->setParameter($parameterName, $value); + } +} diff --git a/src/Doctrine/Orm/Filter/FilterInterfaceTrait.php b/src/Doctrine/Orm/Filter/FilterInterfaceTrait.php new file mode 100644 index 00000000000..53fb99cf1a0 --- /dev/null +++ b/src/Doctrine/Orm/Filter/FilterInterfaceTrait.php @@ -0,0 +1,78 @@ + + * + * 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\PropertyHelperTrait; +use ApiPlatform\Doctrine\Orm\PropertyHelperTrait as OrmPropertyHelperTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; +use Doctrine\Persistence\ManagerRegistry; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +trait FilterInterfaceTrait +{ + use OrmPropertyHelperTrait; + use PropertyHelperTrait; + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + foreach ($context['filters'] as $property => $value) { + $this->filterProperty($this->denormalizePropertyName($property), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + } + } + + public function getDescription(string $resourceClass): array + { + throw new RuntimeException('Not implemented.'); + } + + /** + * Determines whether the given property is enabled. + */ + protected function isPropertyEnabled(string $property, string $resourceClass): bool + { + if (null === $this->properties) { + // to ensure sanity, nested properties must still be explicitly enabled + return !$this->isPropertyNested($property, $resourceClass); + } + + return \array_key_exists($property, $this->properties); + } + + protected function denormalizePropertyName(string|int $property): string + { + if (!$this->nameConverter instanceof NameConverterInterface) { + return (string) $property; + } + + return implode('.', array_map($this->nameConverter->denormalize(...), explode('.', (string) $property))); + } + + public function hasManagerRegistry(): bool + { + return $this->managerRegistry instanceof ManagerRegistry; + } + + public function getManagerRegistry(): ManagerRegistry + { + return $this->managerRegistry; + } + + public function setManagerRegistry(ManagerRegistry $managerRegistry): void + { + $this->managerRegistry = $managerRegistry; + } +} diff --git a/src/Doctrine/Orm/Filter/IriSearchFilter.php b/src/Doctrine/Orm/Filter/IriSearchFilter.php new file mode 100644 index 00000000000..d4acf45dba3 --- /dev/null +++ b/src/Doctrine/Orm/Filter/IriSearchFilter.php @@ -0,0 +1,84 @@ + + * + * 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\IriSearchFilterTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\ParameterProviderFilterInterface; +use ApiPlatform\Metadata\PropertiesAwareInterface; +use ApiPlatform\State\Provider\IriConverterParameterProvider; +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\QueryBuilder; +use Doctrine\Persistence\ManagerRegistry; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +final class IriSearchFilter implements FilterInterface, ManagerRegistryAwareInterface, OpenApiParameterFilterInterface, PropertiesAwareInterface, ParameterProviderFilterInterface +{ + use FilterInterfaceTrait; + use IriSearchFilterTrait; + + public function __construct( + private ?ManagerRegistry $managerRegistry = null, + private readonly ?array $properties = null, + private readonly ?NameConverterInterface $nameConverter = null, + ) { + } + + protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if ( + null === $value + || !$this->isPropertyEnabled($property, $resourceClass) + || !$this->isPropertyMapped($property, $resourceClass, true) + ) { + return; + } + + $value = $context['parameter']->getValue(); + + $alias = $queryBuilder->getRootAliases()[0]; + $parameterName = $queryNameGenerator->generateParameterName($property); + + $queryBuilder + ->andWhere(\sprintf('%s.%s = :%s', $alias, $property, $parameterName)) + ->setParameter($parameterName, $value); + } + + /** + * {@inheritdoc} + */ + public function getType(string $doctrineType): string + { + // TODO: remove this test when doctrine/dbal:3 support is removed + if (\defined(Types::class.'::ARRAY') && Types::ARRAY === $doctrineType) { + return 'array'; + } + + return match ($doctrineType) { + Types::BIGINT, Types::INTEGER, Types::SMALLINT => 'int', + Types::BOOLEAN => 'bool', + Types::DATE_MUTABLE, Types::TIME_MUTABLE, Types::DATETIME_MUTABLE, Types::DATETIMETZ_MUTABLE, Types::DATE_IMMUTABLE, Types::TIME_IMMUTABLE, Types::DATETIME_IMMUTABLE, Types::DATETIMETZ_IMMUTABLE => \DateTimeInterface::class, + Types::FLOAT => 'float', + default => 'string', + }; + } + + public static function getParameterProvider(): string + { + return IriConverterParameterProvider::class; + } +} diff --git a/src/Metadata/Parameter.php b/src/Metadata/Parameter.php index 3aadd249b38..19ba0427771 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..d0ad2dee88d 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -196,6 +196,7 @@ private function setDefaults(string $key, Parameter $parameter, string $resource if ($filter instanceof SerializerFilterInterface && null === $parameter->getProvider()) { $parameter = $parameter->withProvider('api_platform.serializer.filter_parameter_provider'); } + $currentKey = $key; if (null === $parameter->getProperty() && isset($properties[$key])) { $parameter = $parameter->withProperty($key); diff --git a/src/State/Provider/IriConverterParameterProvider.php b/src/State/Provider/IriConverterParameterProvider.php new file mode 100644 index 00000000000..dc1a1eadd42 --- /dev/null +++ b/src/State/Provider/IriConverterParameterProvider.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\State\Provider; + +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\IdentifiersExtractor; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\State\ParameterProviderInterface; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; + +final readonly class IriConverterParameterProvider implements ParameterProviderInterface +{ + public function __construct( + private IriConverterInterface $iriConverter, + private PropertyAccessorInterface $propertyAccessor, + private ?IdentifiersExtractor $identifiersExtractor = null, + ) { + } + + public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation + { + $operation = $context['operation'] ?? null; + $value = $parameter->getValue(); + if (!$value) { + return $operation; + } + + $id = $this->getIdFromValue($value); + $parameter->setValue($id); + + return $operation; + } + + protected function getIdFromValue(string $value): mixed + { + try { + $item = $this->iriConverter->getResourceFromIri($value, ['fetch_data' => false]); + + if (null === $this->identifiersExtractor) { + return $this->propertyAccessor->getValue($item, 'id'); + } + + $identifiers = $this->identifiersExtractor->getIdentifiersFromItem($item); + + return 1 === \count($identifiers) ? array_pop($identifiers) : $identifiers; + } catch (InvalidArgumentException) { + // Do nothing, return the raw value + } + + return $value; + } +} diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml index 6e00d888829..825c17ebe73 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml @@ -199,6 +199,20 @@ + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/state/provider.xml b/src/Symfony/Bundle/Resources/config/state/provider.xml index 52bb2ea23f7..bbc92920397 100644 --- a/src/Symfony/Bundle/Resources/config/state/provider.xml +++ b/src/Symfony/Bundle/Resources/config/state/provider.xml @@ -18,6 +18,14 @@ + + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.xml b/src/Symfony/Bundle/Resources/config/symfony/events.xml index 4ecdb892921..d1063e33787 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.xml +++ b/src/Symfony/Bundle/Resources/config/symfony/events.xml @@ -33,6 +33,14 @@ + + + + + + + + null diff --git a/tests/Fixtures/TestBundle/Entity/DummyAuthor.php b/tests/Fixtures/TestBundle/Entity/DummyAuthor.php new file mode 100644 index 00000000000..d616bc013fc --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyAuthor.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 DummyAuthor +{ + public function __construct( + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + #[ORM\Column] + public ?int $id = null, + + #[ORM\Column] + public ?string $name = null, + + #[ORM\OneToMany(targetEntity: DummyBook::class, mappedBy: 'dummyAuthor')] + public ?Collection $dummyBooks = 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 getDummyBooks(): Collection + { + return $this->dummyBooks; + } +} 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/DummyBook.php b/tests/Fixtures/TestBundle/Entity/DummyBook.php new file mode 100644 index 00000000000..d077588027b --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyBook.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\IriSearchFilter; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +#[GetCollection( + parameters: [ + 'dummyAuthor' => new QueryParameter( + filter: new IriSearchFilter() + ), + ], +)] +#[ORM\Entity] +class DummyBook +{ + 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: DummyAuthor::class, inversedBy: 'dummyBooks')] + #[ORM\JoinColumn(nullable: false)] + public ?DummyAuthor $dummyAuthor = 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 getDummyAuthor(): DummyAuthor + { + return $this->dummyAuthor; + } + + public function setDummyAuthor(DummyAuthor $dummyAuthor): void + { + $this->dummyAuthor = $dummyAuthor; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/DummyBookExact.php b/tests/Fixtures/TestBundle/Entity/DummyBookExact.php new file mode 100644 index 00000000000..8392d3b3be2 --- /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\ExactSearchFilter; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +#[GetCollection( + parameters: [ + 'dummyAuthorExact' => new QueryParameter( + filter: new ExactSearchFilter() + ), + 'title' => new QueryParameter( + filter: new ExactSearchFilter() + ), + ], +)] +#[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/ExactSearchFilterTest.php b/tests/Functional/Parameters/ExactSearchFilterTest.php new file mode 100644 index 00000000000..a60daa934f5 --- /dev/null +++ b/tests/Functional/Parameters/ExactSearchFilterTest.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 ExactSearchFilterTest 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(); + } +} diff --git a/tests/Functional/Parameters/IriSearchFilterTest.php b/tests/Functional/Parameters/IriSearchFilterTest.php new file mode 100644 index 00000000000..6fb36355a5f --- /dev/null +++ b/tests/Functional/Parameters/IriSearchFilterTest.php @@ -0,0 +1,140 @@ + + * + * 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\DummyAuthor as DummyAuthorDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyBook as DummyBookDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyAuthor; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyBook; +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 IriSearchFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DummyBook::class, DummyAuthor::class]; + } + + /** + * @throws MongoDBException + * @throws \Throwable + */ + protected function setUp(): void + { + // TODO: implement ODM classes + $authorEntityClass = $this->isMongoDB() ? DummyAuthorDocument::class : DummyAuthor::class; + $bookEntityClass = $this->isMongoDB() ? DummyBookDocument::class : DummyBook::class; + + $this->recreateSchema([$authorEntityClass, $bookEntityClass]); + $this->loadFixtures($authorEntityClass, $bookEntityClass); + } + + /** + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + * @throws TransportExceptionInterface + */ + #[DataProvider('iriFilterScenariosProvider')] + public function testIriFilterResponses(string $url, int $expectedCount, string $expectedAuthorIri): 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)); + + foreach ($filteredItems as $item) { + $errorMessage = \sprintf('Expected the book to be associated with author IRI %s', $expectedAuthorIri); + $this->assertSame($expectedAuthorIri, $item['dummyAuthor'], $errorMessage); + } + } + + public static function iriFilterScenariosProvider(): \Generator + { + yield 'filter_by_author1' => [ + '/dummy_books?dummyAuthor=/dummy_authors/1', + 2, + '/dummy_authors/1', + ]; + yield 'filter_by_author_id_1' => [ + '/dummy_books?dummyAuthor=1', + 2, + '/dummy_authors/1', + ]; + yield 'filter_by_author2' => [ + '/dummy_books?dummyAuthor=/dummy_authors/2', + 1, + '/dummy_authors/2', + ]; + yield 'filter_by_author_id_2' => [ + '/dummy_books?dummyAuthor=2', + 1, + '/dummy_authors/2', + ]; + } + + /** + * @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'], + dummyAuthor: $bookData['author'] + ); + + $author->dummyBooks->add($book); + $manager->persist($book); + } + + $manager->flush(); + } +}