diff --git a/src/Doctrine/Orm/NestedPropertyHelperTrait.php b/src/Doctrine/Orm/NestedPropertyHelperTrait.php index da2d45b7232..e93f17d46f8 100644 --- a/src/Doctrine/Orm/NestedPropertyHelperTrait.php +++ b/src/Doctrine/Orm/NestedPropertyHelperTrait.php @@ -46,16 +46,31 @@ protected function addNestedParameterJoins( return [$alias, $property]; } - foreach ($nestedInfo['converted_relation_segments'] as $association) { - $alias = QueryBuilderHelper::addJoinOnce( - $queryBuilder, - $queryNameGenerator, - $alias, - $association, - $joinType - ); + $relationClasses = $nestedInfo['relation_classes'] ?? []; + $association = null; + $embedded = false; + + foreach ($nestedInfo['converted_relation_segments'] as $id => $association) { + $entityClass = $relationClasses[$id] ?? null; + if (!$entityClass) { + continue; + } + + $embedded = !$queryBuilder->getEntityManager()->getClassMetadata($entityClass)->hasAssociation($association); + + if (!$embedded) { + $alias = QueryBuilderHelper::addJoinOnce( + $queryBuilder, + $queryNameGenerator, + $alias, + $association, + $joinType + ); + } } - return [$alias, $nestedInfo['leaf_property']]; + $leafProperty = $nestedInfo['leaf_property']; + + return [$alias, $embedded && $association ? $association.'.'.$leafProperty : $leafProperty]; } } diff --git a/src/Metadata/Util/ResourceClassInfoTrait.php b/src/Metadata/Util/ResourceClassInfoTrait.php index 037f96de7b3..34a7d000d0d 100644 --- a/src/Metadata/Util/ResourceClassInfoTrait.php +++ b/src/Metadata/Util/ResourceClassInfoTrait.php @@ -77,7 +77,11 @@ private function extractClassNameFromType(Type $type): ?string } /** - * Gets the class name from a property metadata if it's a resource class. + * Gets the class name referenced by a property's type, if any. + * + * Returns the underlying class for object types (including the inner type of collections); + * returns null for scalar/builtin types or untyped properties. The returned class is not + * required to be an API resource — callers that need that constraint must check explicitly. */ protected function getClassNameFromProperty(ApiProperty $propertyMetadata): ?string { @@ -85,8 +89,6 @@ protected function getClassNameFromProperty(ApiProperty $propertyMetadata): ?str return null; } - $className = $this->extractClassNameFromType($type); - - return $className && $this->isResourceClass($className) ? $className : null; + return $this->extractClassNameFromType($type); } } diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue7916/UserActionResource.php b/tests/Fixtures/TestBundle/ApiResource/Issue7916/UserActionResource.php new file mode 100644 index 00000000000..090c7e3d2a3 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue7916/UserActionResource.php @@ -0,0 +1,53 @@ + + * + * 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\ApiResource\Issue7916; + +use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7916\UserAction; + +/** + * API Resource DTO for UserAction using stateOptions pattern. + * This is a separate API Resource for the UserAction entity which is NOT itself marked #[ApiResource]. + * + * Tests that nested property filters work on relations to non-ApiResource entities (User). + * + * @see https://github.com/api-platform/core/issues/7916 + */ +#[ApiResource( + operations: [ + new GetCollection( + uriTemplate: '/user-actions', + parameters: [ + 'name' => new QueryParameter( + filter: new PartialSearchFilter(), + property: 'user.name', + ), + 'email' => new QueryParameter( + filter: new PartialSearchFilter(), + property: 'user.email', + ), + ], + ), + ], + stateOptions: new Options(entityClass: UserAction::class), +)] +class UserActionResource +{ + public ?int $id = null; + public ?string $action = null; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue7916/UserActionResourceOdm.php b/tests/Fixtures/TestBundle/ApiResource/Issue7916/UserActionResourceOdm.php new file mode 100644 index 00000000000..d15f5d5bd89 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue7916/UserActionResourceOdm.php @@ -0,0 +1,51 @@ + + * + * 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\ApiResource\Issue7916; + +use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter; +use ApiPlatform\Doctrine\Odm\State\Options; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Issue7916\UserAction; + +/** + * API Resource DTO for MongoDB UserAction using stateOptions pattern. + * This is a separate API Resource for the UserAction document which is NOT itself marked #[ApiResource]. + * + * @see https://github.com/api-platform/core/issues/7916 + */ +#[ApiResource( + operations: [ + new GetCollection( + uriTemplate: '/user-actions', + parameters: [ + 'name' => new QueryParameter( + filter: new PartialSearchFilter(), + property: 'user.name', + ), + 'email' => new QueryParameter( + filter: new PartialSearchFilter(), + property: 'user.email', + ), + ], + ), + ], + stateOptions: new Options(documentClass: UserAction::class), +)] +class UserActionResourceOdm +{ + public ?int $id = null; + public ?string $action = null; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue7916/UserResource.php b/tests/Fixtures/TestBundle/ApiResource/Issue7916/UserResource.php new file mode 100644 index 00000000000..8b510e971c0 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue7916/UserResource.php @@ -0,0 +1,34 @@ + + * + * 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\ApiResource\Issue7916; + +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7916\User; + +/** + * API Resource DTO for User using stateOptions pattern. + * This is a separate API Resource for the User entity which is NOT itself marked #[ApiResource]. + * + * @see https://github.com/api-platform/core/issues/7916 + */ +#[ApiResource( + stateOptions: new Options(entityClass: User::class), +)] +class UserResource +{ + public ?int $id = null; + public ?string $name = null; + public ?string $email = null; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue7916/UserResourceOdm.php b/tests/Fixtures/TestBundle/ApiResource/Issue7916/UserResourceOdm.php new file mode 100644 index 00000000000..ffd4395ad28 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue7916/UserResourceOdm.php @@ -0,0 +1,34 @@ + + * + * 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\ApiResource\Issue7916; + +use ApiPlatform\Doctrine\Odm\State\Options; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Issue7916\User; + +/** + * API Resource DTO for MongoDB User using stateOptions pattern. + * This is a separate API Resource for the User document which is NOT itself marked #[ApiResource]. + * + * @see https://github.com/api-platform/core/issues/7916 + */ +#[ApiResource( + stateOptions: new Options(documentClass: User::class), +)] +class UserResourceOdm +{ + public ?int $id = null; + public ?string $name = null; + public ?string $email = null; +} diff --git a/tests/Fixtures/TestBundle/Document/Issue7916/User.php b/tests/Fixtures/TestBundle/Document/Issue7916/User.php new file mode 100644 index 00000000000..d50405a735f --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/Issue7916/User.php @@ -0,0 +1,66 @@ + + * + * 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\Issue7916; + +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * User document for issue #7916. + * + * This document is NOT marked with #[ApiResource] to test nested property filtering + * on relations to non-API-Resource entities. + * + * @see https://github.com/api-platform/core/issues/7916 + */ +#[ODM\Document(collection: 'issue_7916_user')] +class User +{ + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + private ?int $id = null; + + #[ODM\Field(type: 'string')] + private ?string $name = null; + + #[ODM\Field(type: 'string')] + private ?string $email = null; + + 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 getEmail(): ?string + { + return $this->email; + } + + public function setEmail(?string $email): self + { + $this->email = $email; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Document/Issue7916/UserAction.php b/tests/Fixtures/TestBundle/Document/Issue7916/UserAction.php new file mode 100644 index 00000000000..605d34a7361 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/Issue7916/UserAction.php @@ -0,0 +1,66 @@ + + * + * 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\Issue7916; + +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * MongoDB version of UserAction for issue #7916. + * + * This document has a reference to User (which is NOT an ApiResource). + * Used to test nested property filtering on non-ApiResource relations with PartialSearchFilter. + * + * @see https://github.com/api-platform/core/issues/7916 + */ +#[ODM\Document(collection: 'issue_7916_user_action')] +class UserAction +{ + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + private ?int $id = null; + + #[ODM\Field(type: 'string')] + private string $action = ''; + + #[ODM\ReferenceOne(targetDocument: User::class, storeAs: 'id')] + private ?User $user = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getAction(): string + { + return $this->action; + } + + public function setAction(string $action): self + { + $this->action = $action; + + return $this; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): self + { + $this->user = $user; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Document/NonResourceRelation.php b/tests/Fixtures/TestBundle/Document/NonResourceRelation.php new file mode 100644 index 00000000000..e653ab42ec8 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/NonResourceRelation.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\Document; + +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ODM\Document] +class NonResourceRelation +{ + #[ODM\Id(type: 'string', strategy: 'INCREMENT')] + private ?string $id = null; + + #[ODM\Field(type: 'string')] + private string $name = ''; + + #[ODM\Field(type: 'string')] + private string $category = ''; + + 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 getCategory(): string + { + return $this->category; + } + + public function setCategory(string $category): self + { + $this->category = $category; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Document/ResourceWithNonResourceRelation.php b/tests/Fixtures/TestBundle/Document/ResourceWithNonResourceRelation.php new file mode 100644 index 00000000000..e07728a185c --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/ResourceWithNonResourceRelation.php @@ -0,0 +1,80 @@ + + * + * 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\PartialSearchFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ApiResource( + shortName: 'ResourceWithNonResourceRelation', + operations: [ + new GetCollection( + uriTemplate: '/resources_with_non_resource_relations', + parameters: [ + 'name' => new QueryParameter( + filter: new PartialSearchFilter(), + property: 'nonResourceRelation.name', + ), + 'category' => new QueryParameter( + filter: new PartialSearchFilter(), + property: 'nonResourceRelation.category', + ), + ], + ), + ] +)] +#[ODM\Document] +class ResourceWithNonResourceRelation +{ + #[ODM\Id(type: 'string', strategy: 'INCREMENT')] + private ?string $id = null; + + #[ODM\Field(type: 'string')] + private string $title = ''; + + #[ODM\ReferenceOne(targetDocument: NonResourceRelation::class, storeAs: 'id')] + private ?NonResourceRelation $nonResourceRelation = null; + + public function getId(): ?string + { + return $this->id; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): self + { + $this->title = $title; + + return $this; + } + + public function getNonResourceRelation(): ?NonResourceRelation + { + return $this->nonResourceRelation; + } + + public function setNonResourceRelation(?NonResourceRelation $nonResourceRelation): self + { + $this->nonResourceRelation = $nonResourceRelation; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Issue7916/User.php b/tests/Fixtures/TestBundle/Entity/Issue7916/User.php new file mode 100644 index 00000000000..fec55c7c1d5 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Issue7916/User.php @@ -0,0 +1,67 @@ + + * + * 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\Issue7916; + +use Doctrine\ORM\Mapping as ORM; + +/** + * Plain Doctrine ORM entity (NOT marked #[ApiResource]). + * Used to test that nested property filters work on relations to non-ApiResource entities. + * + * @see https://github.com/api-platform/core/issues/7916 + */ +#[ORM\Entity] +#[ORM\Table(name: 'issue_7916_user')] +class User +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 255)] + private ?string $name = null; + + #[ORM\Column(type: 'string', length: 255)] + private ?string $email = null; + + 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 getEmail(): ?string + { + return $this->email; + } + + public function setEmail(?string $email): self + { + $this->email = $email; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Issue7916/UserAction.php b/tests/Fixtures/TestBundle/Entity/Issue7916/UserAction.php new file mode 100644 index 00000000000..0f47aaa72e9 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Issue7916/UserAction.php @@ -0,0 +1,70 @@ + + * + * 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\Issue7916; + +use Doctrine\ORM\Mapping as ORM; + +/** + * UserAction entity for issue #7916. + * + * This entity has a relation to User (which is NOT an ApiResource). + * Used to test nested property filtering on non-ApiResource relations with PartialSearchFilter. + * + * @see https://github.com/api-platform/core/issues/7916 + */ +#[ORM\Entity] +#[ORM\Table(name: 'issue_7916_user_action')] +class UserAction +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + #[ORM\Column(length: 50)] + private string $action = ''; + + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: false)] + private ?User $user = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getAction(): string + { + return $this->action; + } + + public function setAction(string $action): self + { + $this->action = $action; + + return $this; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): self + { + $this->user = $user; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/NonResourceRelation.php b/tests/Fixtures/TestBundle/Entity/NonResourceRelation.php new file mode 100644 index 00000000000..8decb8c0331 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/NonResourceRelation.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\Entity; + +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class NonResourceRelation +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(length: 255)] + private string $name = ''; + + #[ORM\Column(length: 255)] + private string $category = ''; + + 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 getCategory(): string + { + return $this->category; + } + + public function setCategory(string $category): self + { + $this->category = $category; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/ResourceWithNonResourceRelation.php b/tests/Fixtures/TestBundle/Entity/ResourceWithNonResourceRelation.php new file mode 100644 index 00000000000..0850d051f96 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/ResourceWithNonResourceRelation.php @@ -0,0 +1,82 @@ + + * + * 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\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource( + shortName: 'ResourceWithNonResourceRelation', + operations: [ + new GetCollection( + uriTemplate: '/resources_with_non_resource_relations', + parameters: [ + 'name' => new QueryParameter( + filter: new PartialSearchFilter(), + property: 'nonResourceRelation.name', + ), + 'category' => new QueryParameter( + filter: new PartialSearchFilter(), + property: 'nonResourceRelation.category', + ), + ], + ), + ] +)] +#[ORM\Entity] +class ResourceWithNonResourceRelation +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(length: 255)] + private string $title = ''; + + #[ORM\ManyToOne(targetEntity: NonResourceRelation::class)] + private ?NonResourceRelation $nonResourceRelation = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): self + { + $this->title = $title; + + return $this; + } + + public function getNonResourceRelation(): ?NonResourceRelation + { + return $this->nonResourceRelation; + } + + public function setNonResourceRelation(?NonResourceRelation $nonResourceRelation): self + { + $this->nonResourceRelation = $nonResourceRelation; + + return $this; + } +} diff --git a/tests/Functional/Parameters/Issue7916NestedFilterOnNonResourceTest.php b/tests/Functional/Parameters/Issue7916NestedFilterOnNonResourceTest.php new file mode 100644 index 00000000000..3ab46d80050 --- /dev/null +++ b/tests/Functional/Parameters/Issue7916NestedFilterOnNonResourceTest.php @@ -0,0 +1,151 @@ + + * + * 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\ApiResource\Issue7916\UserActionResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7916\UserActionResourceOdm; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7916\UserResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7916\UserResourceOdm; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Issue7916\User as DocumentUser; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Issue7916\UserAction as DocumentUserAction; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7916\User; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7916\UserAction; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * Tests for issue #7916: Nested property filters should work on relations + * to non-ApiResource entities. + * + * @see https://github.com/api-platform/core/issues/7916 + * @group issue-7916 + */ +final class Issue7916NestedFilterOnNonResourceTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + if ('mongodb' === static::getContainer()->getParameter('kernel.environment')) { + return [UserActionResourceOdm::class, UserResourceOdm::class]; + } + + return [UserActionResource::class, UserResource::class]; + } + + protected function setUp(): void + { + $entities = $this->isMongoDB() + ? [DocumentUserAction::class, DocumentUser::class] + : [UserAction::class, User::class]; + + $this->recreateSchema($entities); + $this->loadFixtures(); + } + + /** + * Test filtering on user.name where User is NOT an ApiResource. + * This was failing in #7916 with error: + * "[Semantical Error] Class UserAction has no field or association named user.name" + */ + public function testFilteringOnNonResourceRelationName(): void + { + $response = self::createClient()->request('GET', '/user-actions?name=john'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $this->assertCount(1, $data['hydra:member']); + $this->assertSame('login', $data['hydra:member'][0]['action']); + } + + /** + * Test filtering on user.email where User is NOT an ApiResource. + */ + public function testFilteringOnNonResourceRelationEmail(): void + { + $response = self::createClient()->request('GET', '/user-actions?email=john@example.com'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $this->assertCount(1, $data['hydra:member']); + $this->assertSame('login', $data['hydra:member'][0]['action']); + } + + /** + * Test partial matching on user.name. + */ + public function testPartialFilteringOnNonResourceRelation(): void + { + $response = self::createClient()->request('GET', '/user-actions?name=ane'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $this->assertCount(1, $data['hydra:member']); + $this->assertSame('logout', $data['hydra:member'][0]['action']); + } + + /** + * Test no match scenario. + */ + public function testNoMatchFilteringOnNonResourceRelation(): void + { + $response = self::createClient()->request('GET', '/user-actions?name=nonexistent'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $this->assertCount(0, $data['hydra:member']); + } + + private function loadFixtures(): void + { + $manager = $this->getManager(); + $isMongoDB = $this->isMongoDB(); + + $userClass = $isMongoDB ? DocumentUser::class : User::class; + $actionClass = $isMongoDB ? DocumentUserAction::class : UserAction::class; + + // Create users (NOT ApiResources) + $user1 = new $userClass(); + $user1->setName('john'); + $user1->setEmail('john@example.com'); + + $user2 = new $userClass(); + $user2->setName('jane'); + $user2->setEmail('jane@example.com'); + + $manager->persist($user1); + $manager->persist($user2); + $manager->flush(); + + // Create user actions + $action1 = new $actionClass(); + $action1->setAction('login'); + $action1->setUser($user1); + + $action2 = new $actionClass(); + $action2->setAction('logout'); + $action2->setUser($user2); + + $manager->persist($action1); + $manager->persist($action2); + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/NestedNonResourceRelationFilterTest.php b/tests/Functional/Parameters/NestedNonResourceRelationFilterTest.php new file mode 100644 index 00000000000..9954c6a239c --- /dev/null +++ b/tests/Functional/Parameters/NestedNonResourceRelationFilterTest.php @@ -0,0 +1,136 @@ + + * + * 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\NonResourceRelation as DocumentNonResourceRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ResourceWithNonResourceRelation as DocumentResourceWithNonResourceRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NonResourceRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ResourceWithNonResourceRelation; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * Tests that PartialSearchFilter and other filters work correctly + * with nested properties where the related entity is NOT an ApiResource. + * + * @see https://github.com/api-platform/core/issues/7916 + */ +final class NestedNonResourceRelationFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ResourceWithNonResourceRelation::class]; + } + + protected function setUp(): void + { + $entities = $this->isMongoDB() + ? [DocumentResourceWithNonResourceRelation::class, DocumentNonResourceRelation::class] + : [ResourceWithNonResourceRelation::class, NonResourceRelation::class]; + + $this->recreateSchema($entities); + $this->loadFixtures(); + } + + public function testPartialSearchFilterOnNonResourceRelationProperty(): void + { + $response = self::createClient()->request('GET', '/resources_with_non_resource_relations?name=Electronics'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $this->assertArrayHasKey('hydra:member', $data); + $this->assertCount(1, $data['hydra:member']); + $this->assertSame('Product A', $data['hydra:member'][0]['title']); + } + + public function testPartialSearchFilterOnNonResourceRelationPropertyPartialMatch(): void + { + $response = self::createClient()->request('GET', '/resources_with_non_resource_relations?name=lect'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $this->assertCount(1, $data['hydra:member']); + $this->assertSame('Product A', $data['hydra:member'][0]['title']); + } + + public function testPartialSearchFilterOnNonResourceRelationCategoryProperty(): void + { + $response = self::createClient()->request('GET', '/resources_with_non_resource_relations?category=Books'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $this->assertCount(2, $data['hydra:member']); + } + + public function testPartialSearchFilterOnNonResourceRelationNoMatch(): void + { + $response = self::createClient()->request('GET', '/resources_with_non_resource_relations?name=Nonexistent'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $this->assertCount(0, $data['hydra:member']); + } + + private function loadFixtures(): void + { + $manager = $this->getManager(); + $isMongoDB = $this->isMongoDB(); + + $nonResourceClass = $isMongoDB ? DocumentNonResourceRelation::class : NonResourceRelation::class; + $resourceClass = $isMongoDB ? DocumentResourceWithNonResourceRelation::class : ResourceWithNonResourceRelation::class; + + $relation1 = new $nonResourceClass(); + $relation1->setName('Electronics'); + $relation1->setCategory('Gadgets'); + + $relation2 = new $nonResourceClass(); + $relation2->setName('Novel'); + $relation2->setCategory('Books'); + + $relation3 = new $nonResourceClass(); + $relation3->setName('Computer'); + $relation3->setCategory('Books'); + + $manager->persist($relation1); + $manager->persist($relation2); + $manager->persist($relation3); + $manager->flush(); + + $resource1 = new $resourceClass(); + $resource1->setTitle('Product A'); + $resource1->setNonResourceRelation($relation1); + + $resource2 = new $resourceClass(); + $resource2->setTitle('Product B'); + $resource2->setNonResourceRelation($relation2); + + $resource3 = new $resourceClass(); + $resource3->setTitle('Product C'); + $resource3->setNonResourceRelation($relation3); + + $manager->persist($resource1); + $manager->persist($resource2); + $manager->persist($resource3); + $manager->flush(); + } +}