From ae973d3ba8c880cca67150efed9a68958e6b0a5d Mon Sep 17 00:00:00 2001 From: Julien Falque Date: Fri, 24 Apr 2020 16:26:33 +0200 Subject: [PATCH] Fix duplicated eager loading joins --- features/doctrine/eager_loading.feature | 80 +++++++++++++++++++ .../Orm/Extension/EagerLoadingExtension.php | 24 +++--- .../Doctrine/Orm/Util/EagerLoadingTrait.php | 4 +- .../Doctrine/Orm/Util/QueryBuilderHelper.php | 2 +- .../Bundle/Resources/config/doctrine_orm.xml | 3 +- tests/Behat/DoctrineContext.php | 20 +++++ .../Extension/EagerLoadingExtensionTest.php | 63 +++++++++++++++ .../TestBundle/Doctrine/Orm/EntityManager.php | 52 ++++++++++++ .../TestBundle/Entity/RelatedDummy.php | 2 +- .../Filter/ComplexSubQueryFilter.php | 66 +++++++++++++++ tests/Fixtures/app/config/config_test.yml | 11 +++ 11 files changed, 313 insertions(+), 14 deletions(-) create mode 100644 features/doctrine/eager_loading.feature create mode 100644 tests/Fixtures/TestBundle/Doctrine/Orm/EntityManager.php create mode 100644 tests/Fixtures/TestBundle/Filter/ComplexSubQueryFilter.php diff --git a/features/doctrine/eager_loading.feature b/features/doctrine/eager_loading.feature new file mode 100644 index 00000000000..168ce0839e2 --- /dev/null +++ b/features/doctrine/eager_loading.feature @@ -0,0 +1,80 @@ +@!mongodb +Feature: Eager Loading + In order to have better performance + As a client software developer + The eager loading should be enabled + + @createSchema + Scenario: Eager loading for a relation + Given there is a RelatedDummy with 2 friends + When I send a "GET" request to "/related_dummies/1" + And the response status code should be 200 + And the DQL should be equal to: + """ + SELECT o, thirdLevel_a1, relatedToDummyFriend_a2, dummyFriend_a3 + FROM ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy o + LEFT JOIN o.thirdLevel thirdLevel_a1 + LEFT JOIN o.relatedToDummyFriend relatedToDummyFriend_a2 + LEFT JOIN relatedToDummyFriend_a2.dummyFriend dummyFriend_a3 + WHERE o.id = :id_id + """ + + Scenario: Eager loading for the search filter + Given there is a dummy object with a fourth level relation + When I send a "GET" request to "/dummies?relatedDummy.thirdLevel.level=3" + Then the response status code should be 200 + And the DQL should be equal to: + """ + SELECT o + FROM ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy o + INNER JOIN o.relatedDummy relatedDummy_a1 + INNER JOIN relatedDummy_a1.thirdLevel thirdLevel_a2 + WHERE o IN( + SELECT o_a3 + FROM ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy o_a3 + INNER JOIN o_a3.relatedDummy relatedDummy_a4 + INNER JOIN relatedDummy_a4.thirdLevel thirdLevel_a5 + WHERE thirdLevel_a5.level = :level_p1 + ) + ORDER BY o.id ASC + """ + + Scenario: Eager loading for a relation and a search filter + Given there is a RelatedDummy with 2 friends + When I send a "GET" request to "/related_dummies?relatedToDummyFriend.dummyFriend=2" + And the response status code should be 200 + And the DQL should be equal to: + """ + SELECT o, thirdLevel_a4, relatedToDummyFriend_a1, dummyFriend_a5 + FROM ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy o + INNER JOIN o.relatedToDummyFriend relatedToDummyFriend_a1 + LEFT JOIN o.thirdLevel thirdLevel_a4 + INNER JOIN relatedToDummyFriend_a1.dummyFriend dummyFriend_a5 + WHERE o IN( + SELECT o_a2 + FROM ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy o_a2 + INNER JOIN o_a2.relatedToDummyFriend relatedToDummyFriend_a3 + WHERE relatedToDummyFriend_a3.dummyFriend = :dummyFriend_p1 + ) + ORDER BY o.id ASC + """ + + Scenario: Eager loading for a relation with complex sub-query filter + Given there is a RelatedDummy with 2 friends + When I send a "GET" request to "/related_dummies?complex_sub_query_filter=1" + Then the response status code should be 200 + And the DQL should be equal to: + """ + SELECT o, thirdLevel_a3, relatedToDummyFriend_a4, dummyFriend_a5 + FROM ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy o + LEFT JOIN o.thirdLevel thirdLevel_a3 + LEFT JOIN o.relatedToDummyFriend relatedToDummyFriend_a4 + LEFT JOIN relatedToDummyFriend_a4.dummyFriend dummyFriend_a5 + WHERE o.id IN ( + SELECT related_dummy_a1.id + FROM ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy related_dummy_a1 + INNER JOIN related_dummy_a1.relatedToDummyFriend related_to_dummy_friend_a2 + WITH related_to_dummy_friend_a2.name = :name_p1 + ) + ORDER BY o.id ASC + """ diff --git a/src/Bridge/Doctrine/Orm/Extension/EagerLoadingExtension.php b/src/Bridge/Doctrine/Orm/Extension/EagerLoadingExtension.php index db6c4508be6..14e80e1e091 100644 --- a/src/Bridge/Doctrine/Orm/Extension/EagerLoadingExtension.php +++ b/src/Bridge/Doctrine/Orm/Extension/EagerLoadingExtension.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\Bridge\Doctrine\Orm\Extension; use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\EagerLoadingTrait; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Exception\PropertyNotFoundException; @@ -24,6 +25,7 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Serializer\SerializerContextBuilderInterface; use Doctrine\ORM\Mapping\ClassMetadataInfo; +use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; @@ -190,16 +192,20 @@ private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInt continue; } - $isNullable = $mapping['joinColumns'][0]['nullable'] ?? true; - if (false !== $wasLeftJoin || true === $isNullable) { - $method = 'leftJoin'; + $existingJoin = QueryBuilderHelper::getExistingJoin($queryBuilder, $parentAlias, $association); + + if (null !== $existingJoin) { + $associationAlias = $existingJoin->getAlias(); + $isLeftJoin = Join::LEFT_JOIN === $existingJoin->getJoinType(); } else { - $method = 'innerJoin'; - } + $isNullable = $mapping['joinColumns'][0]['nullable'] ?? true; + $isLeftJoin = false !== $wasLeftJoin || true === $isNullable; + $method = $isLeftJoin ? 'leftJoin' : 'innerJoin'; - $associationAlias = $queryNameGenerator->generateJoinAlias($association); - $queryBuilder->{$method}(sprintf('%s.%s', $parentAlias, $association), $associationAlias); - ++$joinCount; + $associationAlias = $queryNameGenerator->generateJoinAlias($association); + $queryBuilder->{$method}(sprintf('%s.%s', $parentAlias, $association), $associationAlias); + ++$joinCount; + } if (true === $fetchPartial) { try { @@ -230,7 +236,7 @@ private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInt } } - $this->joinRelations($queryBuilder, $queryNameGenerator, $mapping['targetEntity'], $forceEager, $fetchPartial, $associationAlias, $options, $normalizationContext, 'leftJoin' === $method, $joinCount, $currentDepth); + $this->joinRelations($queryBuilder, $queryNameGenerator, $mapping['targetEntity'], $forceEager, $fetchPartial, $associationAlias, $options, $normalizationContext, $isLeftJoin, $joinCount, $currentDepth); } } diff --git a/src/Bridge/Doctrine/Orm/Util/EagerLoadingTrait.php b/src/Bridge/Doctrine/Orm/Util/EagerLoadingTrait.php index 367343e0a21..5c93c1fae03 100644 --- a/src/Bridge/Doctrine/Orm/Util/EagerLoadingTrait.php +++ b/src/Bridge/Doctrine/Orm/Util/EagerLoadingTrait.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\Bridge\Doctrine\Orm\Util; -use Doctrine\ORM\EntityManager; +use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadataInfo; /** @@ -66,7 +66,7 @@ private function getBooleanOperationAttribute(string $resourceClass, array $opti * * @param array $checked array cache of tested metadata classes */ - private function hasFetchEagerAssociation(EntityManager $em, ClassMetadataInfo $classMetadata, array &$checked = []): bool + private function hasFetchEagerAssociation(EntityManagerInterface $em, ClassMetadataInfo $classMetadata, array &$checked = []): bool { $checked[] = $classMetadata->name; diff --git a/src/Bridge/Doctrine/Orm/Util/QueryBuilderHelper.php b/src/Bridge/Doctrine/Orm/Util/QueryBuilderHelper.php index 1243eb8281e..10b77d65aab 100644 --- a/src/Bridge/Doctrine/Orm/Util/QueryBuilderHelper.php +++ b/src/Bridge/Doctrine/Orm/Util/QueryBuilderHelper.php @@ -160,7 +160,7 @@ public static function traverseJoins(string $alias, QueryBuilder $queryBuilder, /** * Gets the existing join from QueryBuilder DQL parts. */ - private static function getExistingJoin(QueryBuilder $queryBuilder, string $alias, string $association, string $originAlias = null): ?Join + public static function getExistingJoin(QueryBuilder $queryBuilder, string $alias, string $association, string $originAlias = null): ?Join { $parts = $queryBuilder->getDQLPart('join'); $rootAlias = $originAlias ?? $queryBuilder->getRootAliases()[0]; diff --git a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml index f60b7d564af..a9a2fd7a783 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml @@ -131,7 +131,8 @@ - + + diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php index bbbc943c763..5d1f3fd54ab 100644 --- a/tests/Behat/DoctrineContext.php +++ b/tests/Behat/DoctrineContext.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Tests\Behat; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Doctrine\Orm\EntityManager; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\AbsoluteUrlDummy as AbsoluteUrlDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\AbsoluteUrlRelationDummy as AbsoluteUrlRelationDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Address as AddressDocument; @@ -214,6 +215,25 @@ public function createDatabase() $this->doctrine->getManager()->clear(); } + /** + * @Then the DQL should be equal to: + */ + public function theDqlShouldBeEqualTo(PyStringNode $dql) + { + /** @var EntityManager $manager */ + $manager = $this->doctrine->getManager(); + + $actualDql = $manager::$dql; + + $expectedDql = preg_replace('/\(\R */', '(', (string) $dql); + $expectedDql = preg_replace('/\R *\)/', ')', $expectedDql); + $expectedDql = preg_replace('/\R */', ' ', $expectedDql); + + if ($expectedDql !== $actualDql) { + throw new \RuntimeException("The DQL:\n'$actualDql' is not equal to:\n'$expectedDql'"); + } + } + /** * @Given there are :nb dummy objects */ diff --git a/tests/Bridge/Doctrine/Orm/Extension/EagerLoadingExtensionTest.php b/tests/Bridge/Doctrine/Orm/Extension/EagerLoadingExtensionTest.php index ea95a402b83..91735a16993 100644 --- a/tests/Bridge/Doctrine/Orm/Extension/EagerLoadingExtensionTest.php +++ b/tests/Bridge/Doctrine/Orm/Extension/EagerLoadingExtensionTest.php @@ -35,6 +35,7 @@ use Doctrine\ORM\EntityManager; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\ClassMetadataInfo; +use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -124,6 +125,7 @@ public function testApplyToCollection() $queryBuilderProphecy->innerJoin('o.relatedDummy2', 'relatedDummy2_a2')->shouldBeCalledTimes(1); $queryBuilderProphecy->addSelect('partial relatedDummy_a1.{id,name,embeddedDummy.name}')->shouldBeCalledTimes(1); $queryBuilderProphecy->addSelect('partial relatedDummy2_a2.{id,name,embeddedDummy.name}')->shouldBeCalledTimes(1); + $queryBuilderProphecy->getDQLPart('join')->willReturn([]); $queryBuilder = $queryBuilderProphecy->reveal(); $eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), 30, false, null, null, true); @@ -230,6 +232,7 @@ public function testApplyToItem() $queryBuilderProphecy->addSelect('partial relatedDummy3_a4.{id}')->shouldBeCalledTimes(1); $queryBuilderProphecy->addSelect('partial relatedDummy4_a5.{id}')->shouldBeCalledTimes(1); $queryBuilderProphecy->addSelect('singleInheritanceRelation_a6')->shouldBeCalledTimes(1); + $queryBuilderProphecy->getDQLPart('join')->willReturn([]); $queryBuilder = $queryBuilderProphecy->reveal(); $orderExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), 30, false, null, null, true); @@ -377,6 +380,7 @@ public function testMaxJoinsReached() $queryBuilderProphecy->innerJoin(Argument::type('string'), Argument::type('string'))->shouldBeCalled(); $queryBuilderProphecy->addSelect(Argument::type('string'))->shouldBeCalled(); + $queryBuilderProphecy->getDQLPart('join')->willReturn([]); $eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), 30, false, null, null, true); $eagerExtensionTest->applyToCollection($queryBuilderProphecy->reveal(), new QueryNameGenerator(), Dummy::class, null, ['groups' => ['foo']]); @@ -445,6 +449,7 @@ public function testMaxDepth() $queryBuilderProphecy->innerJoin(Argument::type('string'), Argument::type('string'))->shouldBeCalledTimes(2); $queryBuilderProphecy->addSelect(Argument::type('string'))->shouldBeCalled(); + $queryBuilderProphecy->getDQLPart('join')->willReturn([]); $eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), 30, false, null, null, true, $classMetadataFactoryProphecy->reveal()); $eagerExtensionTest->applyToCollection($queryBuilderProphecy->reveal(), new QueryNameGenerator(), Dummy::class); @@ -486,6 +491,7 @@ public function testForceEager() $queryBuilderProphecy->innerJoin('o.relation', 'relation_a1')->shouldBeCalledTimes(1); $queryBuilderProphecy->addSelect('partial relation_a1.{id}')->shouldBeCalledTimes(1); + $queryBuilderProphecy->getDQLPart('join')->willReturn([]); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); @@ -602,6 +608,7 @@ public function testResourceClassNotFoundExceptionPropertyNameCollection() $queryBuilderProphecy->getRootAliases()->willReturn(['o']); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->innerJoin('o.relation', 'relation_a1')->shouldBeCalledTimes(1); + $queryBuilderProphecy->getDQLPart('join')->willReturn([]); $orderExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), 30, true, null, null, true); $orderExtensionTest->applyToItem($queryBuilderProphecy->reveal(), new QueryNameGenerator(), Dummy::class, []); @@ -657,6 +664,7 @@ public function testApplyToCollectionWithSerializerContextBuilder() $queryBuilderProphecy->leftJoin('o.relatedDummy', 'relatedDummy_a1')->shouldBeCalledTimes(1); $queryBuilderProphecy->addSelect('partial relatedDummy_a1.{id,name}')->shouldBeCalledTimes(1); + $queryBuilderProphecy->getDQLPart('join')->willReturn([]); $request = Request::create('/api/dummies', 'GET', []); @@ -721,6 +729,7 @@ public function testAttributes() $queryBuilderProphecy->leftJoin('o.relatedDummy', 'relatedDummy_a1')->shouldBeCalledTimes(1); $queryBuilderProphecy->addSelect('partial relatedDummy_a1.{id,name}')->shouldBeCalledTimes(1); + $queryBuilderProphecy->getDQLPart('join')->willReturn([]); $request = Request::create('/api/dummies', 'GET', []); @@ -828,6 +837,7 @@ public function testOnlyOneRelationNotInAttributes() $queryBuilderProphecy->leftJoin('o.relatedDummy', 'relatedDummy_a1')->shouldBeCalledTimes(1); $queryBuilderProphecy->addSelect('partial relatedDummy_a1.{id,name}')->shouldBeCalledTimes(1); + $queryBuilderProphecy->getDQLPart('join')->willReturn([]); $request = Request::create('/api/dummies', 'GET', []); @@ -877,6 +887,7 @@ public function testApplyToCollectionNoPartial() $queryBuilderProphecy->innerJoin('o.relatedDummy2', 'relatedDummy2_a2')->shouldBeCalledTimes(1); $queryBuilderProphecy->addSelect('relatedDummy_a1')->shouldBeCalledTimes(1); $queryBuilderProphecy->addSelect('relatedDummy2_a2')->shouldBeCalledTimes(1); + $queryBuilderProphecy->getDQLPart('join')->willReturn([]); $queryBuilder = $queryBuilderProphecy->reveal(); $eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), 30); @@ -933,12 +944,64 @@ private function doTestApplyToCollectionWithANonReadableButFetchEagerProperty(bo $queryBuilderProphecy->innerJoin('o.relatedDummy2', 'relatedDummy2_a2')->shouldBeCalledTimes(1); $queryBuilderProphecy->addSelect('relatedDummy_a1')->shouldBeCalledTimes(1); $queryBuilderProphecy->addSelect('relatedDummy2_a2')->shouldBeCalledTimes(1); + $queryBuilderProphecy->getDQLPart('join')->willReturn([]); $queryBuilder = $queryBuilderProphecy->reveal(); $eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), 30); $eagerExtensionTest->applyToCollection($queryBuilder, new QueryNameGenerator(), Dummy::class); } + /** + * @dataProvider provideExistingJoinCases + */ + public function testApplyToCollectionWithExistingJoin(string $joinType): void + { + $context = ['groups' => ['foo']]; + $callContext = ['serializer_groups' => ['foo']]; + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata()); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $relationPropertyMetadata = new PropertyMetadata(); + $relationPropertyMetadata = $relationPropertyMetadata->withReadableLink(true); + + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', $callContext)->willReturn($relationPropertyMetadata)->shouldBeCalled(); + + $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); + + $classMetadataProphecy = $this->prophesize(ClassMetadata::class); + $classMetadataProphecy->associationMappings = [ + 'relatedDummy' => ['fetch' => ClassMetadataInfo::FETCH_EAGER, 'joinColumns' => [['nullable' => true]], 'targetEntity' => RelatedDummy::class], + ]; + + $relatedClassMetadataProphecy = $this->prophesize(ClassMetadata::class); + + $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->getDQLPart('join')->willReturn([ + 'o' => [ + new Join($joinType, 'o.relatedDummy', 'existing_join_alias'), + ], + ]); + $queryBuilderProphecy->addSelect('existing_join_alias')->shouldBeCalledTimes(1); + + $queryBuilder = $queryBuilderProphecy->reveal(); + $eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), 30, false); + $eagerExtensionTest->applyToCollection($queryBuilder, new QueryNameGenerator(), Dummy::class, null, $context); + } + + public function provideExistingJoinCases(): iterable + { + yield [Join::LEFT_JOIN]; + yield [Join::INNER_JOIN]; + } + public function testApplyToCollectionWithAReadableButNotFetchEagerProperty() { $this->doTestApplyToCollectionWithAReadableButNotFetchEagerProperty(false); diff --git a/tests/Fixtures/TestBundle/Doctrine/Orm/EntityManager.php b/tests/Fixtures/TestBundle/Doctrine/Orm/EntityManager.php new file mode 100644 index 00000000000..b54ad524e04 --- /dev/null +++ b/tests/Fixtures/TestBundle/Doctrine/Orm/EntityManager.php @@ -0,0 +1,52 @@ + + * + * 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\Core\Tests\Fixtures\TestBundle\Doctrine\Orm; + +use Doctrine\ORM\Decorator\EntityManagerDecorator; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Query; +use Doctrine\ORM\QueryBuilder; +use Doctrine\ORM\Repository\RepositoryFactory; +use Doctrine\Persistence\ObjectRepository; + +final class EntityManager extends EntityManagerDecorator +{ + public static $dql; + + private $repositoryFactory; + + public function __construct(EntityManagerInterface $wrapped, RepositoryFactory $repositoryFactory) + { + parent::__construct($wrapped); + + $this->repositoryFactory = $repositoryFactory; + } + + public function getRepository($className): ObjectRepository + { + return $this->repositoryFactory->getRepository($this, $className); + } + + public function createQueryBuilder(): QueryBuilder + { + return new QueryBuilder($this); + } + + public function createQuery($dql = ''): Query + { + self::$dql = $dql; + + return $this->wrapped->createQuery($dql); + } +} diff --git a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php index 02f13cf929e..73844a82116 100644 --- a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php +++ b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php @@ -26,7 +26,7 @@ * * @author Kévin Dunglas * - * @ApiResource(iri="https://schema.org/Product", attributes={"normalization_context"={"groups"={"friends"}}, "filters"={"related_dummy.friends"}}) + * @ApiResource(iri="https://schema.org/Product", attributes={"normalization_context"={"groups"={"friends"}}, "filters"={"related_dummy.friends", "related_dummy.complex_sub_query"}}) * @ORM\Entity */ class RelatedDummy extends ParentDummy diff --git a/tests/Fixtures/TestBundle/Filter/ComplexSubQueryFilter.php b/tests/Fixtures/TestBundle/Filter/ComplexSubQueryFilter.php new file mode 100644 index 00000000000..311c2101964 --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/ComplexSubQueryFilter.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\Core\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractContextAwareFilter; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Query\Expr\Join; +use Doctrine\ORM\QueryBuilder; + +final class ComplexSubQueryFilter extends AbstractContextAwareFilter +{ + protected function filterProperty( + string $property, + $value, + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + ?string $operationName = null + ): void { + if ('complex_sub_query_filter' !== $property) { + return; + } + + $rootAlias = $queryBuilder->getRootAliases()[0]; + + $relatedDummyAlias = $queryNameGenerator->generateJoinAlias('related_dummy'); + $relatedToDummyFriendAlias = $queryNameGenerator->generateJoinAlias('related_to_dummy_friend'); + + $nameParameterName = $queryNameGenerator->generateParameterName('name'); + + /** @var EntityManagerInterface $entityManager */ + $entityManager = $this->managerRegistry->getManager(); + + $subQueryBuilder = $entityManager->createQueryBuilder() + ->select("{$relatedDummyAlias}.id") + ->from(RelatedDummy::class, $relatedDummyAlias) + ->innerJoin( + "{$relatedDummyAlias}.relatedToDummyFriend", + $relatedToDummyFriendAlias, + Join::WITH, + "{$relatedToDummyFriendAlias}.name = :{$nameParameterName}" + ); + + $queryBuilder + ->andWhere("{$rootAlias}.id IN ({$subQueryBuilder->getDQL()})") + ->setParameter($nameParameterName, 'foo'); + } + + public function getDescription(string $resourceClass): array + { + return []; + } +} diff --git a/tests/Fixtures/app/config/config_test.yml b/tests/Fixtures/app/config/config_test.yml index 6e4d2182897..3e62834a24f 100644 --- a/tests/Fixtures/app/config/config_test.yml +++ b/tests/Fixtures/app/config/config_test.yml @@ -10,6 +10,12 @@ api_platform: - '%kernel.project_dir%/config/api_resources_orm.yaml' services: + ApiPlatform\Core\Tests\Fixtures\TestBundle\Doctrine\Orm\EntityManager: + decorates: 'doctrine.orm.default_entity_manager' + arguments: + $wrapped: '@ApiPlatform\Core\Tests\Fixtures\TestBundle\Doctrine\Orm\EntityManager.inner' + $repositoryFactory: '@doctrine.orm.container_repository_factory' + app.my_dummy_resource.search_filter: parent: 'api_platform.doctrine.orm.search_filter' arguments: [ { 'id': 'exact', 'name': 'partial', 'alias': 'start', 'description': 'word_start', 'relatedDummy.name': 'exact', 'relatedDummies': 'exact', 'dummy': 'ipartial', 'relatedDummies.name': 'start', 'embeddedDummy.dummyName': 'partial', 'relatedDummy.thirdLevel.level': 'exact', 'relatedDummy.thirdLevel.fourthLevel.level': 'exact', 'relatedDummy.thirdLevel.badFourthLevel.level': 'exact', 'relatedDummy.thirdLevel.fourthLevel.badThirdLevel.level': 'exact', 'nameConverted': 'partial' } ] @@ -61,6 +67,11 @@ services: arguments: [ { 'relatedToDummyFriend.dummyFriend': 'exact', 'name': 'partial' } ] tags: [ { name: 'api_platform.filter', id: 'related_dummy.friends' } ] + app.related_dummy_resource.complex_sub_query_filter: + class: ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\ComplexSubQueryFilter + arguments: ['@doctrine'] + tags: [ { name: 'api_platform.filter', id: 'related_dummy.complex_sub_query' } ] + app.related_dummy_to_friend_resource.search_filter: parent: 'api_platform.doctrine.orm.search_filter' arguments: [ { 'name': 'ipartial', 'description': 'ipartial' } ]