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/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/Doctrine/Orm/Filter/IriFilter.php b/src/Doctrine/Orm/Filter/IriFilter.php new file mode 100644 index 00000000000..0dd128c9239 --- /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; + +final 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/GraphQl/Tests/Type/TypeConverterTest.php b/src/GraphQl/Tests/Type/TypeConverterTest.php index 006ddccfbb4..8c4eb980391 100644 --- a/src/GraphQl/Tests/Type/TypeConverterTest.php +++ b/src/GraphQl/Tests/Type/TypeConverterTest.php @@ -30,14 +30,14 @@ use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type as GraphQLType; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; -use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; /** * @author Alan Poulain diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index 584bfffcd2d..c19b47113bf 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -45,8 +45,8 @@ use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CollectionType; -use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\TypeIdentifier; /** * Builds the GraphQL fields. diff --git a/src/State/Provider/IriConverterParameterProvider.php b/src/State/Provider/IriConverterParameterProvider.php new file mode 100644 index 00000000000..41509b54ad6 --- /dev/null +++ b/src/State/Provider/IriConverterParameterProvider.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\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 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..3378194f099 --- /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 = null; + + #[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..50e925b8314 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/ChickenCoop.php @@ -0,0 +1,74 @@ + + * + * 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\Odm\Filter\IriFilter; +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())]) +] +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..6b3ab63fc03 100644 --- a/tests/Fixtures/TestBundle/Document/Company.php +++ b/tests/Fixtures/TestBundle/Document/Company.php @@ -25,13 +25,19 @@ #[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 new file mode 100644 index 00000000000..603ecf1dba9 --- /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..410261adb84 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/ChickenCoop.php @@ -0,0 +1,76 @@ + + * + * 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(targetEntity: Chicken::class, mappedBy: 'chickenCoop', 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/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(); + } +} 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(); + } +}