diff --git a/features/doctrine/eager_loading.feature b/features/doctrine/eager_loading.feature index 539330a1c54..ee7e73ff6a1 100644 --- a/features/doctrine/eager_loading.feature +++ b/features/doctrine/eager_loading.feature @@ -11,7 +11,7 @@ Feature: Eager Loading Then the response status code should be 200 And the DQL should be equal to: """ - SELECT o, thirdLevel_a1, fourthLevel_a2, relatedToDummyFriend_a3, dummyFriend_a4 + SELECT o, thirdLevel_a1, relatedToDummyFriend_a3, fourthLevel_a2, dummyFriend_a4 FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o LEFT JOIN o.thirdLevel thirdLevel_a1 LEFT JOIN thirdLevel_a1.fourthLevel fourthLevel_a2 @@ -46,7 +46,7 @@ Feature: Eager Loading Then the response status code should be 200 And the DQL should be equal to: """ - SELECT o, thirdLevel_a4, fourthLevel_a5, relatedToDummyFriend_a1, dummyFriend_a6 + SELECT o, thirdLevel_a4, relatedToDummyFriend_a1, fourthLevel_a5, dummyFriend_a6 FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o INNER JOIN o.relatedToDummyFriend relatedToDummyFriend_a1 LEFT JOIN o.thirdLevel thirdLevel_a4 @@ -83,7 +83,7 @@ Feature: Eager Loading Then the response status code should be 200 And the DQL should be equal to: """ - SELECT o, thirdLevel_a3, fourthLevel_a4, relatedToDummyFriend_a5, dummyFriend_a6 + SELECT o, thirdLevel_a3, relatedToDummyFriend_a5, fourthLevel_a4, dummyFriend_a6 FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o LEFT JOIN o.thirdLevel thirdLevel_a3 LEFT JOIN thirdLevel_a3.fourthLevel fourthLevel_a4 diff --git a/src/Doctrine/Orm/Extension/EagerLoadingExtension.php b/src/Doctrine/Orm/Extension/EagerLoadingExtension.php index 536f6a4dc1c..01a54bcedc1 100644 --- a/src/Doctrine/Orm/Extension/EagerLoadingExtension.php +++ b/src/Doctrine/Orm/Extension/EagerLoadingExtension.php @@ -23,6 +23,7 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Query\AST\PartialObjectExpression; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\Query\Expr\Select; use Doctrine\ORM\QueryBuilder; @@ -70,7 +71,7 @@ private function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $ $options = []; $forceEager = $operation?->getForceEager() ?? $this->forceEager; - $fetchPartial = $operation?->getFetchPartial() ?? $this->fetchPartial; + $fetchPartial = class_exists(PartialObjectExpression::class) && ($operation?->getFetchPartial() ?? $this->fetchPartial); if (!isset($context['groups']) && !isset($context['attributes'])) { $contextType = isset($context['api_denormalize']) ? 'denormalization_context' : 'normalization_context'; @@ -95,7 +96,61 @@ private function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $ $options['denormalization_groups'] = $denormalizationGroups; } - $this->joinRelations($queryBuilder, $queryNameGenerator, $resourceClass, $forceEager, $fetchPartial, $queryBuilder->getRootAliases()[0], $options, $context); + $selects = $this->joinRelations($queryBuilder, $queryNameGenerator, $resourceClass, $forceEager, $fetchPartial, $queryBuilder->getRootAliases()[0], $options, $context); + $selectsByClass = []; + foreach ($selects as [$entity, $alias, $fields]) { + if ($entity === $resourceClass) { + // We don't perform partial select the root entity + $fields = null; + } + + if (!isset($selectsByClass[$entity])) { + $selectsByClass[$entity] = [ + 'aliases' => [$alias => true], + 'fields' => null === $fields ? null : array_flip($fields), + ]; + } else { + $selectsByClass[$entity]['aliases'][$alias] = true; + if (null === $selectsByClass[$entity]['fields']) { + continue; + } + + if (null === $fields) { + $selectsByClass[$entity]['fields'] = null; + continue; + } + + // Merge fields + foreach ($fields as $field) { + $selectsByClass[$entity]['fields'][$field] = true; + } + } + } + + $existingSelects = []; + foreach ($queryBuilder->getDQLPart('select') ?? [] as $dqlSelect) { + if (!$dqlSelect instanceof Select) { + continue; + } + foreach ($dqlSelect->getParts() as $part) { + $existingSelects[(string) $part] = true; + } + } + + foreach ($selectsByClass as $data) { + $fields = null === $data['fields'] ? null : array_keys($data['fields']); + foreach (array_keys($data['aliases']) as $alias) { + if (isset($existingSelects[$alias])) { + continue; + } + + if (null === $fields) { + $queryBuilder->addSelect($alias); + } else { + $queryBuilder->addSelect(\sprintf('partial %s.{%s}', $alias, implode(',', $fields))); + } + } + } } /** @@ -107,7 +162,7 @@ private function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $ * * @throws RuntimeException when the max number of joins has been reached */ - private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, bool $forceEager, bool $fetchPartial, string $parentAlias, array $options = [], array $normalizationContext = [], bool $wasLeftJoin = false, int &$joinCount = 0, ?int $currentDepth = null, ?string $parentAssociation = null): void + private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, bool $forceEager, bool $fetchPartial, string $parentAlias, array $options = [], array $normalizationContext = [], bool $wasLeftJoin = false, int &$joinCount = 0, ?int $currentDepth = null, ?string $parentAssociation = null): iterable { if ($joinCount > $this->maxJoins) { throw new RuntimeException('The total number of joined relations has exceeded the specified maximum. Raise the limit if necessary with the "api_platform.eager_loading.max_joins" configuration key (https://api-platform.com/docs/core/performance/#eager-loading), or limit the maximum serialization depth using the "enable_max_depth" option of the Symfony serializer (https://symfony.com/doc/current/components/serializer.html#handling-serialization-depth).'); @@ -200,13 +255,13 @@ private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInt if (true === $fetchPartial) { try { $propertyOptions = $this->getPropertyContext($attributesMetadata[$association] ?? null, $options); - $this->addSelect($queryBuilder, $mapping['targetEntity'], $associationAlias, $propertyOptions); + yield from $this->addSelect($queryBuilder, $mapping['targetEntity'], $associationAlias, $propertyOptions); } catch (ResourceClassNotFoundException) { continue; } } else { $propertyOptions = null; - $this->addSelectOnce($queryBuilder, $associationAlias); + yield [$resourceClass, $associationAlias, null]; } // Avoid recursive joins for self-referencing relations @@ -229,7 +284,7 @@ private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInt } $propertyOptions ??= $this->getPropertyContext($attributesMetadata[$association] ?? null, $options); - $this->joinRelations($queryBuilder, $queryNameGenerator, $mapping['targetEntity'], $forceEager, $fetchPartial, $associationAlias, $propertyOptions, $childNormalizationContext, $isLeftJoin, $joinCount, $currentDepth, $association); + yield from $this->joinRelations($queryBuilder, $queryNameGenerator, $mapping['targetEntity'], $forceEager, $fetchPartial, $associationAlias, $propertyOptions, $childNormalizationContext, $isLeftJoin, $joinCount, $currentDepth, $association); } } @@ -267,13 +322,13 @@ private function getPropertyContext(?AttributeMetadataInterface $attributeMetada return $propertyOptions; } - private function addSelect(QueryBuilder $queryBuilder, string $entity, string $associationAlias, array $propertyMetadataOptions): void + private function addSelect(QueryBuilder $queryBuilder, string $entity, string $associationAlias, array $propertyMetadataOptions): iterable { $select = []; $entityManager = $queryBuilder->getEntityManager(); $targetClassMetadata = $entityManager->getClassMetadata($entity); if (!empty($targetClassMetadata->subClasses)) { - $this->addSelectOnce($queryBuilder, $associationAlias); + yield [$entity, $associationAlias, null]; return; } @@ -308,15 +363,6 @@ private function addSelect(QueryBuilder $queryBuilder, string $entity, string $a } } - $queryBuilder->addSelect(\sprintf('partial %s.{%s}', $associationAlias, implode(',', $select))); - } - - private function addSelectOnce(QueryBuilder $queryBuilder, string $alias): void - { - $existingSelects = array_reduce($queryBuilder->getDQLPart('select') ?? [], fn ($existing, $dqlSelect) => ($dqlSelect instanceof Select) ? array_merge($existing, $dqlSelect->getParts()) : $existing, []); - - if (!\in_array($alias, $existingSelects, true)) { - $queryBuilder->addSelect($alias); - } + yield [$entity, $associationAlias, $select]; } } diff --git a/src/Doctrine/Orm/Tests/Extension/EagerLoadingExtensionTest.php b/src/Doctrine/Orm/Tests/Extension/EagerLoadingExtensionTest.php index 73f079db0d4..ea8fd822fe0 100644 --- a/src/Doctrine/Orm/Tests/Extension/EagerLoadingExtensionTest.php +++ b/src/Doctrine/Orm/Tests/Extension/EagerLoadingExtensionTest.php @@ -117,6 +117,7 @@ public function testApplyToCollection(): void $emProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($relatedClassMetadataProphecy->reveal()); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->leftJoin('o.relatedDummy', 'relatedDummy_a1')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); @@ -224,6 +225,7 @@ public function testApplyToItem(): void $emProphecy->getClassMetadata(ThirdLevel::class)->shouldBeCalled()->willReturn($thirdLevelMetadataProphecy->reveal()); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->leftJoin('o.relatedDummy', 'relatedDummy_a1')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); $queryBuilderProphecy->leftJoin('relatedDummy_a1.relation', 'relation_a2')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); @@ -269,6 +271,7 @@ public function testCreateItemWithOperation(): void $emProphecy = $this->prophesize(EntityManager::class); $emProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30, false, true); @@ -290,6 +293,7 @@ public function testCreateCollectionWithOperation(): void $emProphecy = $this->prophesize(EntityManager::class); $emProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30, false, true); @@ -310,6 +314,7 @@ public function testDenormalizeItemWithCorrectResourceClass(): void $emProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30, false, true); @@ -330,6 +335,7 @@ public function testDenormalizeItemWithExistingGroups(): void $emProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30, false, true); @@ -364,6 +370,7 @@ public function testContextSwitch(): void $propertyMetadataFactoryProphecy->create(RelatedDummy::class, 'name', ['normalization_groups' => ['bar'], 'denormalization_groups' => ['foo']])->willReturn($namePropertyMetadata)->shouldBeCalled(); $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $classMetadataProphecy = $this->prophesize(ClassMetadata::class); $classMetadataProphecy->associationMappings = [ @@ -375,7 +382,7 @@ public function testContextSwitch(): void foreach ($relatedNameCollection as $property) { if ('id' !== $property && 'embeddedDummy' !== $property) { - $relatedClassMetadataProphecy->hasField($property)->willReturn('notindatabase' !== $property)->shouldBeCalled(); + $relatedClassMetadataProphecy->hasField($property)->willReturn(true)->shouldBeCalled(); } } @@ -412,6 +419,90 @@ public function testContextSwitch(): void $eagerExtensionTest->applyToCollection($queryBuilder, new QueryNameGenerator(), Dummy::class, new GetCollection(normalizationContext: [AbstractNormalizer::GROUPS => 'foo'])); } + public function testSameEntityWithDifferentPartialProperties(): void + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + + $relatedNameCollection = new PropertyNameCollection(['id', 'name']); + $propertyNameCollectionFactoryProphecy->create(RelatedDummy::class)->willReturn($relatedNameCollection)->shouldBeCalled(); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $relationPropertyMetadata = new ApiProperty(); + $relationPropertyMetadata = $relationPropertyMetadata->withReadableLink(false); + + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy1', ['serializer_groups' => ['foo'], 'normalization_groups' => 'foo'])->willReturn($relationPropertyMetadata)->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy2', ['serializer_groups' => ['foo'], 'normalization_groups' => 'foo'])->willReturn($relationPropertyMetadata)->shouldBeCalled(); + + $idPropertyMetadata = (new ApiProperty())->withIdentifier(true); + $namePropertyMetadataGroupA = (new ApiProperty())->withReadable(true); + // the property Name IS NOT readable in group B + $namePropertyMetadataGroupB = (new ApiProperty())->withReadable(false); + + // When called via `relatedDummy1` + $propertyMetadataFactoryProphecy->create(RelatedDummy::class, 'id', ['normalization_groups' => ['A'], 'denormalization_groups' => ['foo']])->willReturn($idPropertyMetadata)->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(RelatedDummy::class, 'name', ['normalization_groups' => ['A'], 'denormalization_groups' => ['foo']])->willReturn($namePropertyMetadataGroupA)->shouldBeCalled(); + + // When called via `relatedDummy2` + $propertyMetadataFactoryProphecy->create(RelatedDummy::class, 'id', ['normalization_groups' => ['B'], 'denormalization_groups' => ['foo']])->willReturn($idPropertyMetadata)->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(RelatedDummy::class, 'name', ['normalization_groups' => ['B'], 'denormalization_groups' => ['foo']])->willReturn($namePropertyMetadataGroupB)->shouldBeCalled(); + + $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); + + $classMetadataProphecy = $this->prophesize(ClassMetadata::class); + $classMetadataProphecy->associationMappings = [ + 'relatedDummy1' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [['nullable' => true]], 'targetEntity' => RelatedDummy::class], + 'relatedDummy2' => ['fetch' => ClassMetadata::FETCH_EAGER, 'joinColumns' => [['nullable' => true]], 'targetEntity' => RelatedDummy::class], + ]; + + $relatedClassMetadataProphecy = $this->prophesize(ClassMetadata::class); + + foreach ($relatedNameCollection as $property) { + if ('id' !== $property && 'embeddedDummy' !== $property) { + $relatedClassMetadataProphecy->hasField($property)->willReturn(true)->shouldBeCalled(); + } + } + + $dummyClassMetadataInterfaceProphecy = $this->prophesize(ClassMetadataInterface::class); + $relatedClassMetadataInterfaceProphecy = $this->prophesize(ClassMetadataInterface::class); + $classMetadataFactoryProphecy = $this->prophesize(ClassMetadataFactoryInterface::class); + + $relatedDummy1AttributeMetadata = new AttributeMetadata('relatedDummy'); + $relatedDummy1AttributeMetadata->setNormalizationContextForGroups(['groups' => ['A']], ['foo']); + + $relatedDummy2AttributeMetadata = new AttributeMetadata('relatedDummy'); + $relatedDummy2AttributeMetadata->setNormalizationContextForGroups(['groups' => ['B']], ['foo']); + + $dummyClassMetadataInterfaceProphecy->getAttributesMetadata()->willReturn([ + 'relatedDummy1' => $relatedDummy1AttributeMetadata, + 'relatedDummy2' => $relatedDummy2AttributeMetadata, + ]); + $relatedClassMetadataInterfaceProphecy->getAttributesMetadata()->willReturn([]); + + $classMetadataFactoryProphecy->getMetadataFor(RelatedDummy::class)->willReturn($relatedClassMetadataInterfaceProphecy->reveal()); + $classMetadataFactoryProphecy->getMetadataFor(Dummy::class)->willReturn($dummyClassMetadataInterfaceProphecy->reveal()); + + $relatedClassMetadataProphecy->associationMappings = []; + + $emProphecy = $this->prophesize(EntityManager::class); + $emProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); + $emProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($relatedClassMetadataProphecy->reveal()); + + $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); + + $queryBuilderProphecy->leftJoin('o.relatedDummy1', 'relatedDummy1_a1')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); + $queryBuilderProphecy->leftJoin('o.relatedDummy2', 'relatedDummy2_a2')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); + $queryBuilderProphecy->addSelect('partial relatedDummy1_a1.{id,name}')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); + // here is the purpose of this test: name is not readable in group B, BUT it is part of the partial selection because it is readable in group A + $queryBuilderProphecy->addSelect('partial relatedDummy2_a2.{id,name}')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); + $queryBuilderProphecy->getDQLPart('join')->willReturn([]); + + $queryBuilder = $queryBuilderProphecy->reveal(); + $eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30, false, true, $classMetadataFactoryProphecy->reveal()); + $eagerExtensionTest->applyToCollection($queryBuilder, new QueryNameGenerator(), Dummy::class, new GetCollection(normalizationContext: [AbstractNormalizer::GROUPS => 'foo'])); + } + public function testMaxJoinsReached(): void { $this->expectException(RuntimeException::class); @@ -454,10 +545,11 @@ public function testMaxJoinsReached(): void $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); - $queryBuilderProphecy->innerJoin(Argument::type('string'), Argument::type('string'))->shouldBeCalled()->willReturn($queryBuilderProphecy); - $queryBuilderProphecy->addSelect(Argument::type('string'))->shouldBeCalled()->willReturn($queryBuilderProphecy); + $queryBuilderProphecy->innerJoin(Argument::type('string'), Argument::type('string'))->willReturn($queryBuilderProphecy); + $queryBuilderProphecy->addSelect(Argument::type('string'))->willReturn($queryBuilderProphecy); $queryBuilderProphecy->getDQLPart('join')->willReturn([]); $eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30, false, true); @@ -519,6 +611,7 @@ public function testMaxDepth(): void $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->innerJoin(Argument::type('string'), Argument::type('string'))->shouldBeCalledTimes(2)->willReturn($queryBuilderProphecy); @@ -563,6 +656,7 @@ public function testForceEager(): void $queryBuilderProphecy->getDQLPart('join')->willReturn([]); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $orderExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30, true, true); @@ -593,6 +687,7 @@ public function testExtraLazy(): void $emProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $orderExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30, true, true); @@ -614,6 +709,7 @@ public function testResourceClassNotFoundException(): void $emProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $orderExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30, true, true); @@ -635,6 +731,7 @@ public function testPropertyNotFoundException(): void $emProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $orderExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30, true, true); @@ -660,6 +757,7 @@ public function testResourceClassNotFoundExceptionPropertyNameCollection(): void $emProphecy->getClassMetadata(UnknownDummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->innerJoin('o.relation', 'relation_a1')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); $queryBuilderProphecy->getDQLPart('join')->willReturn([]); @@ -713,6 +811,7 @@ public function testAttributes(): void $emProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($relatedClassMetadataProphecy->reveal()); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->leftJoin('o.relatedDummies', 'relatedDummies_a1')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); @@ -749,6 +848,7 @@ public function testNotInAttributes(): void $emProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilder = $queryBuilderProphecy->reveal(); @@ -801,6 +901,7 @@ public function testOnlyOneRelationNotInAttributes(): void $emProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($relatedClassMetadataProphecy->reveal()); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->leftJoin('o.relatedDummy', 'relatedDummy_a1')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); @@ -838,6 +939,7 @@ public function testApplyToCollectionNoPartial(): void $emProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($relatedClassMetadataProphecy->reveal()); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->leftJoin('o.relatedDummy', 'relatedDummy_a1')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); @@ -883,6 +985,7 @@ public function testApplyToCollectionWithANonReadableButFetchEagerProperty(): vo $emProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($relatedClassMetadataProphecy->reveal()); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->leftJoin('o.relatedDummy', 'relatedDummy_a1')->shouldBeCalledTimes(1)->willReturn($queryBuilderProphecy); @@ -925,6 +1028,7 @@ public function testApplyToCollectionWithExistingJoin(string $joinType): void $emProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($relatedClassMetadataProphecy->reveal()); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->getDQLPart('join')->willReturn([ 'o' => [ @@ -971,6 +1075,7 @@ public function testApplyToCollectionWithAReadableButNotFetchEagerProperty(): vo $emProphecy->getClassMetadata(RelatedDummy::class)->shouldNotBecalled(); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->leftJoin('o.relatedDummy', 'relatedDummy_a1')->shouldNotBeCalled(); @@ -1008,6 +1113,7 @@ public function testAvoidFetchCollectionOnIriOnlyProperty(): void $emProphecy->getClassMetadata(PropertyCollectionIriOnlyRelation::class)->shouldNotBecalled(); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getDQLPart('select')->willReturn([]); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->leftJoin('o.propertyCollectionIriOnlyRelation', 'propertyCollectionIriOnlyRelation_a1')->shouldNotBeCalled(); diff --git a/tests/Fixtures/TestBundle/Entity/DummyContext.php b/tests/Fixtures/TestBundle/Entity/DummyContext.php new file mode 100644 index 00000000000..5145a643748 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyContext.php @@ -0,0 +1,73 @@ + + * + * 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\ApiResource; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Attribute\Context; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource(normalizationContext: ['groups' => ['initial']], fetchPartial: true)] +#[ORM\Entity(readOnly: true)] +class DummyContext +{ + /** + * @var int|null The id + */ + #[ORM\Column(type: 'integer', nullable: true)] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private $id; + + #[ORM\OneToOne(targetEntity: DummyContextRelated::class)] + #[ORM\JoinColumn(referencedColumnName: 'id', nullable: false)] + #[Groups(['initial'])] + #[Context(normalizationContext: ['groups' => ['context_switched']])] + private DummyContextRelated $relatedWithSwitch; + + #[ORM\OneToOne(targetEntity: DummyContextRelated::class)] + #[ORM\JoinColumn(referencedColumnName: 'id', nullable: false)] + #[Groups(['initial'])] + private DummyContextRelated $relatedWithoutSwitch; + + public function getId() + { + return $this->id; + } + + public function setId($id): void + { + $this->id = $id; + } + + public function getRelatedWithSwitch(): DummyContextRelated + { + return $this->relatedWithSwitch; + } + + public function setRelatedWithSwitch(DummyContextRelated $relatedWithSwitch): void + { + $this->relatedWithSwitch = $relatedWithSwitch; + } + + public function getRelatedWithoutSwitch(): DummyContextRelated + { + return $this->relatedWithoutSwitch; + } + + public function setRelatedWithoutSwitch(DummyContextRelated $relatedWithoutSwitch): void + { + $this->relatedWithoutSwitch = $relatedWithoutSwitch; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/DummyContextRelated.php b/tests/Fixtures/TestBundle/Entity/DummyContextRelated.php new file mode 100644 index 00000000000..ffc90317187 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyContextRelated.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\Metadata\ApiResource; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource] +#[ORM\Entity(readOnly: true)] +class DummyContextRelated +{ + /** + * @var int|null The id + */ + #[ORM\Column(type: 'integer', nullable: true)] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private $id; + + #[ORM\Column(type: 'string')] + #[Groups(['context_switched'])] + private string $contextSwitched; + + #[ORM\Column(type: 'string')] + #[Groups(['initial'])] + private string $initialGroups; + + #[ORM\Column(type: 'string')] + private string $noGroups; + + public function getId() + { + return $this->id; + } + + public function setId($id): void + { + $this->id = $id; + } + + public function getContextSwitched(): string + { + return $this->contextSwitched; + } + + public function setContextSwitched(string $contextSwitched): void + { + $this->contextSwitched = $contextSwitched; + } + + public function getInitialGroups(): string + { + return $this->initialGroups; + } + + public function setInitialGroups(string $initialGroups): void + { + $this->initialGroups = $initialGroups; + } + + public function getNoGroups(): string + { + return $this->noGroups; + } + + public function setNoGroups(string $noGroups): void + { + $this->noGroups = $noGroups; + } +} diff --git a/tests/Functional/Doctrine/ContextSwitchTest.php b/tests/Functional/Doctrine/ContextSwitchTest.php new file mode 100644 index 00000000000..3fc1564492f --- /dev/null +++ b/tests/Functional/Doctrine/ContextSwitchTest.php @@ -0,0 +1,126 @@ + + * + * 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\DummyContext; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyContextRelated; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ContextSwitchTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DummyContext::class]; + } + + public function testPatialFetchWithContextSwitch(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('This test is not for MongoDB.'); + } + + $this->recreateSchema([DummyContext::class, DummyContextRelated::class]); + $manager = static::getContainer()->get('doctrine')->getManager(); + + $relatedWithoutSwitch = new DummyContextRelated(); + $relatedWithoutSwitch->setContextSwitched('context switched value'); + $relatedWithoutSwitch->setInitialGroups('initial group value'); + $relatedWithoutSwitch->setNoGroups('no group value'); + + $relatedWithSwitch = clone $relatedWithoutSwitch; + $manager->persist($relatedWithoutSwitch); + $manager->persist($relatedWithSwitch); + + $dummy = new DummyContext(); + $dummy->setRelatedWithoutSwitch($relatedWithoutSwitch); + $dummy->setRelatedWithSwitch($relatedWithSwitch); + $manager->persist($dummy); + $manager->flush(); + $manager->clear(); // this is important to avoid doctrine from reusing the objects instead of loading theme from SQL query + + $client = static::createClient(); + $response = $client->request('GET', '/dummy_contexts', ['headers' => ['Accept' => 'application/ld+json']]); + $this->assertResponseStatusCodeSame(200); + + $this->assertJsonContains([ + '@id' => '/dummy_contexts', + 'hydra:member' => [ + [ + '@id' => '/dummy_contexts/1', + 'relatedWithSwitch' => [ + 'contextSwitched' => 'context switched value', + ], + 'relatedWithoutSwitch' => [ + 'initialGroups' => 'initial group value', + ], + ], + ], + ]); + } + + public function testPatialFetchWithContextSwitchOnSameEntity(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('This test is not for MongoDB.'); + } + + $this->recreateSchema([DummyContext::class, DummyContextRelated::class]); + $manager = static::getContainer()->get('doctrine')->getManager(); + + $related = new DummyContextRelated(); + $related->setContextSwitched('context switched value'); + $related->setInitialGroups('initial group value'); + $related->setNoGroups('no group value'); + + $manager->persist($related); + + $dummy = new DummyContext(); + // the trick of this test is both relations point to the same entity, BUT they don't returns the same fields because of the context switch + // Doctrine reuses the same object instance, so the entity needs to be hydrated with all fields + $dummy->setRelatedWithoutSwitch($related); + $dummy->setRelatedWithSwitch($related); + $manager->persist($dummy); + $manager->flush(); + $manager->clear(); // this is important to avoid doctrine from reusing the objects instead of loading theme from SQL query + + $client = static::createClient(); + $response = $client->request('GET', '/dummy_contexts', ['headers' => ['Accept' => 'application/ld+json']]); + $this->assertResponseStatusCodeSame(200); + + $this->assertJsonContains([ + '@id' => '/dummy_contexts', + 'hydra:member' => [ + [ + '@id' => '/dummy_contexts/1', + 'relatedWithSwitch' => [ + 'contextSwitched' => 'context switched value', + ], + 'relatedWithoutSwitch' => [ + 'initialGroups' => 'initial group value', + ], + ], + ], + ]); + } +}