diff --git a/src/Doctrine/Orm/State/ItemProvider.php b/src/Doctrine/Orm/State/ItemProvider.php index b201d03b7d0..05cf095b6ec 100644 --- a/src/Doctrine/Orm/State/ItemProvider.php +++ b/src/Doctrine/Orm/State/ItemProvider.php @@ -59,9 +59,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } $fetchData = $context['fetch_data'] ?? true; - if (!$fetchData && \array_key_exists('id', $uriVariables)) { - // todo : if uriVariables don't contain the id, this fails. This should behave like it does in the following code - return $manager->getReference($entityClass, $uriVariables); + if (!$fetchData && null !== ($identifiers = $this->getReferenceIdentifiers($entityClass, $operation, $uriVariables, $context))) { + return $manager->getReference($entityClass, $identifiers); } $repository = $manager->getRepository($entityClass); @@ -88,4 +87,42 @@ public function provide(Operation $operation, array $uriVariables = [], array $c return $queryBuilder->getQuery()->getOneOrNullResult(); } + + /** + * Builds the [identifierField => value] map for getReference() from the resource's own identifier + * links, ignoring parent/relation links a subresource carries (e.g. "companyId") which are not + * identifiers of the entity and would make getReference() throw UnrecognizedIdentifierFields. + * + * Returns null when an own identifier value is missing, so the caller falls through to the query + * that resolves the link. + * + * @param array $uriVariables + * @param array $context + * + * @return array|null + */ + private function getReferenceIdentifiers(string $entityClass, Operation $operation, array $uriVariables, array $context): ?array + { + $identifiers = []; + foreach ($this->getLinks($entityClass, $operation, $context) as $parameterName => $link) { + // Mirrors LinksHandlerTrait: the identifier-self link has no relation property and points to the entity itself. + if ($entityClass !== $link->getFromClass() || $link->getFromProperty() || $link->getToProperty()) { + continue; + } + + $identifierProperties = $link->getIdentifiers(); + $hasCompositeIdentifiers = 1 < \count($identifierProperties); + foreach ($identifierProperties as $identifierProperty) { + // Composite identifiers are exploded by field name upstream; a single identifier is keyed by its uriVariable name. + $key = $hasCompositeIdentifiers ? $identifierProperty : $parameterName; + if (!\array_key_exists($key, $uriVariables)) { + return null; + } + + $identifiers[$identifierProperty] = $uriVariables[$key]; + } + } + + return $identifiers ?: null; + } } diff --git a/src/Doctrine/Orm/Tests/State/ItemProviderTest.php b/src/Doctrine/Orm/Tests/State/ItemProviderTest.php index 168939cc047..fdff4783e3b 100644 --- a/src/Doctrine/Orm/Tests/State/ItemProviderTest.php +++ b/src/Doctrine/Orm/Tests/State/ItemProviderTest.php @@ -138,6 +138,99 @@ public function testGetItemDoubleIdentifier(): void $this->assertEquals($returnObject, $dataProvider->provide($operation, ['ida' => 1, 'idb' => 2], $context)); } + public function testGetItemWithFetchDataFalseOnSubresourceFiltersParentLink(): void + { + $reference = new Employee(); + + $managerMock = $this->createMock(EntityManagerInterface::class); + $managerMock->expects($this->once()) + ->method('getReference') + ->with(Employee::class, ['id' => 2]) + ->willReturn($reference); + + $managerRegistryMock = $this->createMock(ManagerRegistry::class); + $managerRegistryMock->method('getManagerForClass')->with(Employee::class)->willReturn($managerMock); + + $operation = (new Get())->withUriVariables([ + 'companyId' => (new Link())->withFromClass(Company::class)->withToProperty('company'), + 'id' => (new Link())->withFromClass(Employee::class)->withIdentifiers(['id']), + ])->withName('get')->withClass(Employee::class); + + $dataProvider = new ItemProvider( + $this->createStub(ResourceMetadataCollectionFactoryInterface::class), + $managerRegistryMock, + ); + + $this->assertSame($reference, $dataProvider->provide($operation, ['companyId' => 1, 'id' => 2], ['fetch_data' => false])); + } + + public function testGetItemWithFetchDataFalseMapsRenamedIdentifierUriVariable(): void + { + $reference = new Employee(); + + $managerMock = $this->createMock(EntityManagerInterface::class); + $managerMock->expects($this->once()) + ->method('getReference') + ->with(Employee::class, ['id' => 2]) + ->willReturn($reference); + + $managerRegistryMock = $this->createMock(ManagerRegistry::class); + $managerRegistryMock->method('getManagerForClass')->with(Employee::class)->willReturn($managerMock); + + // The identifier uriVariable is named "employeeId" while the entity's own identifier field is "id". + $operation = (new Get())->withUriVariables([ + 'companyId' => (new Link())->withFromClass(Company::class)->withToProperty('company'), + 'employeeId' => (new Link())->withFromClass(Employee::class)->withIdentifiers(['id'])->withParameterName('employeeId'), + ])->withName('get')->withClass(Employee::class); + + $dataProvider = new ItemProvider( + $this->createStub(ResourceMetadataCollectionFactoryInterface::class), + $managerRegistryMock, + ); + + $this->assertSame($reference, $dataProvider->provide($operation, ['companyId' => 1, 'employeeId' => 2], ['fetch_data' => false])); + } + + public function testGetItemWithFetchDataFalseFallsBackToQueryWhenOwnIdentifierMissing(): void + { + $returnObject = new \stdClass(); + + $queryMock = $this->createMock($this->getQueryClass()); + $queryMock->method('getOneOrNullResult')->willReturn($returnObject); + + $queryBuilderMock = $this->createMock(QueryBuilder::class); + $queryBuilderMock->method('getQuery')->willReturn($queryMock); + $queryBuilderMock->method('getRootAliases')->willReturn(['o']); + + $classMetadataMock = $this->createMock(ClassMetadata::class); + $classMetadataMock->method('getIdentifierFieldNames')->willReturn(['id']); + + $repositoryMock = $this->createMock(EntityRepository::class); + $repositoryMock->method('createQueryBuilder')->with('o')->willReturn($queryBuilderMock); + + $managerMock = $this->createMock(EntityManagerInterface::class); + $managerMock->method('getClassMetadata')->willReturn($classMetadataMock); + $managerMock->method('getRepository')->willReturn($repositoryMock); + // Only the parent link is provided: the own identifier cannot be resolved to a reference, + // so we must fall back to the query that resolves the link instead of calling getReference(). + $managerMock->expects($this->never())->method('getReference'); + + $managerRegistryMock = $this->createMock(ManagerRegistry::class); + $managerRegistryMock->method('getManagerForClass')->willReturn($managerMock); + + $operation = (new Get())->withUriVariables([ + 'companyId' => (new Link())->withFromClass(Company::class)->withToProperty('company')->withIdentifiers(['id']), + 'id' => (new Link())->withFromClass(Employee::class)->withIdentifiers(['id']), + ])->withName('get')->withClass(Employee::class); + + $dataProvider = new ItemProvider( + $this->createStub(ResourceMetadataCollectionFactoryInterface::class), + $managerRegistryMock, + ); + + $this->assertSame($returnObject, $dataProvider->provide($operation, ['companyId' => 1], ['fetch_data' => false])); + } + public function testQueryResultExtension(): void { $returnObject = new \stdClass(); diff --git a/tests/Functional/Doctrine/FetchDataFalseSubresourceTest.php b/tests/Functional/Doctrine/FetchDataFalseSubresourceTest.php new file mode 100644 index 00000000000..d25b691de43 --- /dev/null +++ b/tests/Functional/Doctrine/FetchDataFalseSubresourceTest.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Company; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Employee; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class FetchDataFalseSubresourceTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Company::class, Employee::class]; + } + + public function testGetResourceFromSubresourceIriWithFetchDataFalseReturnsReference(): void + { + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped('getReference()/UnrecognizedIdentifierFields is ORM specific.'); + } + + $this->recreateSchema([Company::class, Employee::class]); + + $manager = $this->getManager(); + $company = new Company(); + $company->name = 'test'; + $manager->persist($company); + + $employees = []; + for ($i = 0; $i < 3; ++$i) { + $employee = new Employee(); + $employee->name = "Employee number $i"; + $employee->company = $company; + $manager->persist($employee); + $employees[] = $employee; + } + $manager->flush(); + + $employee = $employees[1]; + $iri = \sprintf('/companies/%d/employees/%d', $company->getId(), $employee->getId()); + + // fetch_data=false short-circuits to EntityManager::getReference(); the subresource IRI carries the + // parent link "companyId" which is not an identifier of Employee and used to raise + // Doctrine\ORM\Exception\UnrecognizedIdentifierFields ('companyId'). See #8124. + $reference = $container->get('api_platform.iri_converter')->getResourceFromIri($iri, ['fetch_data' => false]); + + $this->assertInstanceOf(Employee::class, $reference); + $this->assertSame($employee->getId(), $reference->getId()); + } +}