diff --git a/src/Doctrine/Orm/State/LinksHandlerTrait.php b/src/Doctrine/Orm/State/LinksHandlerTrait.php index 65f062b944e..72a445d09d6 100644 --- a/src/Doctrine/Orm/State/LinksHandlerTrait.php +++ b/src/Doctrine/Orm/State/LinksHandlerTrait.php @@ -88,6 +88,13 @@ private function handleLinks(QueryBuilder $queryBuilder, array $identifiers, Que $joinProperties = $doctrineClassMetadata->getIdentifierFieldNames(); if ($link->getFromProperty() && !$link->getToProperty()) { + // The link was built from the property's native type (LinkFactory), but the + // property may not be a mapped Doctrine association (e.g. a transient, + // resource-typed self reference). There is nothing to join on, skip it. + if (!$fromClassMetadata->hasAssociation($link->getFromProperty())) { + continue; + } + $joinAlias = $queryNameGenerator->generateJoinAlias('m'); $associationMapping = $fromClassMetadata->getAssociationMapping($link->getFromProperty()); // @phpstan-ignore-line $relationType = $associationMapping['type']; diff --git a/src/Doctrine/Orm/Tests/State/ItemProviderTest.php b/src/Doctrine/Orm/Tests/State/ItemProviderTest.php index 168939cc047..bcb1663560c 100644 --- a/src/Doctrine/Orm/Tests/State/ItemProviderTest.php +++ b/src/Doctrine/Orm/Tests/State/ItemProviderTest.php @@ -282,6 +282,7 @@ public function testGetSubresourceFromProperty(): void $queryBuilderMock->expects($this->once())->method('setParameter')->with('id_p1', 1, Types::INTEGER); $employeeClassMetadataMock = $this->createMock(ClassMetadata::class); + $employeeClassMetadataMock->method('hasAssociation')->with('company')->willReturn(true); $employeeClassMetadataMock->method('getAssociationMapping')->with('company')->willReturn( class_exists(ManyToOneAssociationMapping::class) ? new ManyToOneAssociationMapping('company', Employee::class, Company::class) : diff --git a/tests/Fixtures/TestBundle/Entity/GraphQlTransientSelfReference/TransientSelfReference.php b/tests/Fixtures/TestBundle/Entity/GraphQlTransientSelfReference/TransientSelfReference.php new file mode 100644 index 00000000000..1ed8a673b3e --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/GraphQlTransientSelfReference/TransientSelfReference.php @@ -0,0 +1,46 @@ + + * + * 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\GraphQlTransientSelfReference; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource(graphQlOperations: [new Query()])] +#[ORM\Entity] +class TransientSelfReference +{ + #[ORM\Id] + #[ORM\Column(type: 'integer')] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null; + + #[ORM\Column(type: 'string')] + public ?string $name = null; + + // Typed as the resource class but NOT a Doctrine association: a transient, + // runtime-only self reference. LinkFactory builds a relation link from the + // native type, but the ORM has no association mapping to join on. + private ?self $relatedButNotMapped = null; + + public function getRelatedButNotMapped(): ?self + { + return $this->relatedButNotMapped; + } + + public function setRelatedButNotMapped(?self $related): void + { + $this->relatedButNotMapped = $related; + } +} diff --git a/tests/Functional/GraphQl/TransientSelfReferenceTest.php b/tests/Functional/GraphQl/TransientSelfReferenceTest.php new file mode 100644 index 00000000000..d40a49ac423 --- /dev/null +++ b/tests/Functional/GraphQl/TransientSelfReferenceTest.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\Functional\GraphQl; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\GraphQlTransientSelfReference\TransientSelfReference; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * A GraphQL item query must not crash when the resource has a public property + * typed as the resource class but not mapped as a Doctrine association. The + * relation link built from the native type has no association mapping to join + * on, so the ORM LinksHandlerTrait must skip it instead of throwing + * "No mapping found for field ... on class ...". + * + * @see https://github.com/api-platform/core/issues/8292 + */ +final class TransientSelfReferenceTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [TransientSelfReference::class]; + } + + public function testItemQueryWithTransientResourceTypedPropertyDoesNotCrash(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('TransientSelfReference is ORM-only.'); + } + + $this->recreateSchema([TransientSelfReference::class]); + + $manager = $this->getManager(); + $first = new TransientSelfReference(); + $first->name = 'First'; + $manager->persist($first); + $second = new TransientSelfReference(); + $second->name = 'Second'; + $manager->persist($second); + $manager->flush(); + + // Query the second item: proves the identifier-self link still applies + // WHERE id=X after the unmapped relation link is skipped. + $iri = '/transient_self_references/'.$second->id; + + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => <<assertResponseIsSuccessful(); + $json = $response->toArray(false); + $this->assertArrayNotHasKey('errors', $json, json_encode($json['errors'] ?? null)); + $this->assertSame('Second', $json['data']['transientSelfReference']['name']); + } +}