diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 0a59761a033..99e157c1ae7 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -13,8 +13,12 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CompositeLabel; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CompositeRelation; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCar; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCarColor; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyFriend; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\FileConfigDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelationEmbedder; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\UuidIdentifierDummy; use Behat\Behat\Context\Context; @@ -135,18 +139,21 @@ public function thereIsDummyObjectsWithRelatedDummy($nb) } /** - * @Given there is :nb dummy objects with relatedDummies + * @Given there is :nb dummy objects having each :nbrelated relatedDummies */ - public function thereIsDummyObjectsWithRelatedDummies($nb) + public function thereIsDummyObjectsWithRelatedDummies($nb, $nbrelated) { for ($i = 1; $i <= $nb; ++$i) { - $relatedDummy = new RelatedDummy(); - $relatedDummy->setName('RelatedDummy #'.$i); - $dummy = new Dummy(); $dummy->setName('Dummy #'.$i); $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->addRelatedDummy($relatedDummy); + + for ($j = 1; $j <= $nbrelated; ++$j) { + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName('RelatedDummy'.$j.$i); + $this->manager->persist($relatedDummy); + $dummy->addRelatedDummy($relatedDummy); + } $this->manager->persist($relatedDummy); $this->manager->persist($dummy); @@ -319,4 +326,60 @@ public function thereIsAFileConfigDummyObject() $this->manager->persist($fileConfigDummy); $this->manager->flush(); } + + /** + * @Given there is a DummyCar entity with related colors + */ + public function thereIsAFooEntityWithRelatedBars() + { + $foo = new DummyCar(); + $this->manager->persist($foo); + + $bar1 = new DummyCarColor(); + $bar1->setProp('red'); + $bar1->setCar($foo); + $this->manager->persist($bar1); + + $bar2 = new DummyCarColor(); + $bar2->setProp('blue'); + $bar2->setCar($foo); + $this->manager->persist($bar2); + + $foo->setColors([$bar1, $bar2]); + $this->manager->persist($foo); + + $this->manager->flush(); + } + + /** + * @Given there is a RelatedDummy with :nb friends + */ + public function thereIsARelatedDummyWithFriends($nb) + { + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName('RelatedDummy with friends'); + $this->manager->persist($relatedDummy); + + for ($i = 1; $i <= $nb; ++$i) { + $friend = new DummyFriend(); + $friend->setName('Friend-'.$i); + + $this->manager->persist($friend); + + $relation = new RelatedToDummyFriend(); + $relation->setName('Relation-'.$i); + $relation->setDummyFriend($friend); + $relation->setRelatedDummy($relatedDummy); + + $relatedDummy->addRelatedToDummyFriend($relation); + + $this->manager->persist($relation); + } + + $relatedDummy2 = new RelatedDummy(); + $relatedDummy2->setName('RelatedDummy without friends'); + $this->manager->persist($relatedDummy2); + + $this->manager->flush(); + } } diff --git a/features/doctrine/date_filter.feature b/features/doctrine/date_filter.feature index a5cc5284d0f..a922817b9da 100644 --- a/features/doctrine/date_filter.feature +++ b/features/doctrine/date_filter.feature @@ -395,18 +395,18 @@ Feature: Date filter on collections And the JSON should be equal to: """ { - "@context": "\/contexts\/Dummy", - "@id": "\/dummies", + "@context": "/contexts/Dummy", + "@id": "/dummies", "@type": "hydra:Collection", "hydra:member": [], "hydra:totalItems": 0, "hydra:view": { - "@id": "\/dummies?relatedDummy.dummyDate%5Bafter%5D=2015-04-28", + "@id": "/dummies?relatedDummy.dummyDate%5Bafter%5D=2015-04-28", "@type": "hydra:PartialCollectionView" }, "hydra:search": { "@type": "hydra:IriTemplate", - "hydra:template": "\/dummies{?id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],dummy,order[id],order[name],order[relatedDummy.symfony],dummyDate[before],dummyDate[after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[after],dummyFloat[between],dummyFloat[gt],dummyFloat[gte],dummyFloat[lt],dummyFloat[lte],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],dummyBoolean,dummyFloat,dummyPrice}", + "hydra:template": "/dummies{?id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],dummy,relatedDummies.name,order[id],order[name],order[relatedDummy.symfony],dummyDate[before],dummyDate[after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[after],dummyFloat[between],dummyFloat[gt],dummyFloat[gte],dummyFloat[lt],dummyFloat[lte],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],dummyBoolean,dummyFloat,dummyPrice}", "hydra:variableRepresentation": "BasicRepresentation", "hydra:mapping": [ { @@ -469,6 +469,12 @@ Feature: Date filter on collections "property": "dummy", "required": false }, + { + "@type": "IriTemplateMapping", + "variable": "relatedDummies.name", + "property": "relatedDummies.name", + "required": false + }, { "@type": "IriTemplateMapping", "variable": "order[id]", diff --git a/features/doctrine/search_filter.feature b/features/doctrine/search_filter.feature index ff413b84821..ee6072e954e 100644 --- a/features/doctrine/search_filter.feature +++ b/features/doctrine/search_filter.feature @@ -4,6 +4,66 @@ Feature: Search filter on collections I need to search for collections properties @createSchema + @dropSchema + Scenario: Test ManyToMany with filter on join table + Given there is a RelatedDummy with 4 friends + When I add "Accept" header equal to "application/hal+json" + And I send a "GET" request to "/related_dummies?relatedToDummyFriend.dummyFriend=/dummy_friends/4" + Then the response status code should be 200 + And the JSON node "_embedded.item" should have 1 element + And the JSON node "_embedded.item[0]._links.relatedToDummyFriend" should have 4 elements + And the JSON node "_embedded.item[0]._embedded.relatedToDummyFriend" should have 4 elements + + @createSchema + Scenario: Test #944 + Given there is a DummyCar entity with related colors + When I send a "GET" request to "/dummy_cars?colors.prop=red" + Then the response status code should be 200 + And the JSON should be equal to: + """ + { + "@context": "/contexts/DummyCar", + "@id": "/dummy_cars", + "@type": "hydra:Collection", + "hydra:member": [ + { + "@id": "/dummy_cars/1", + "@type": "DummyCar", + "colors": [ + { + "@id": "/dummy_car_colors/1", + "@type": "DummyCarColor", + "prop": "red" + }, + { + "@id": "/dummy_car_colors/2", + "@type": "DummyCarColor", + "prop": "blue" + } + ] + } + ], + "hydra:totalItems": 1, + "hydra:view": { + "@id": "/dummy_cars?colors.prop=red", + "@type": "hydra:PartialCollectionView" + }, + "hydra:search": { + "@type": "hydra:IriTemplate", + "hydra:template": "/dummy_cars{?colors.prop}", + "hydra:variableRepresentation": "BasicRepresentation", + "hydra:mapping": [ + { + "@type": "IriTemplateMapping", + "variable": "colors.prop", + "property": "colors.prop", + "required": false + } + ] + } + } + """ + Scenario: Search collection by name (partial) Given there is "30" dummy objects When I send a "GET" request to "/dummies?name=my" @@ -45,7 +105,6 @@ Feature: Search filter on collections """ Scenario: Search collection by name (partial case insensitive) - Given there is "30" dummy objects When I send a "GET" request to "/dummies?dummy=somedummytest1" Then the response status code should be 200 And the response should be in JSON @@ -179,3 +238,17 @@ Feature: Search filter on collections } } """ + + @createSchema + @dropSchema + Scenario: Search related collection by name + Given there is 3 dummy objects having each 3 relatedDummies + When I add "Accept" header equal to "application/hal+json" + And I send a "GET" request to "/dummies?relatedDummies.name=RelatedDummy1" + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "_embedded.item" should have 3 elements + And the JSON node "_embedded.item[0]._links.relatedDummies" should have 3 elements + And the JSON node "_embedded.item[1]._links.relatedDummies" should have 3 elements + And the JSON node "_embedded.item[2]._links.relatedDummies" should have 3 elements + diff --git a/features/main/crud.feature b/features/main/crud.feature index 2c534c785e2..b10a663f09c 100644 --- a/features/main/crud.feature +++ b/features/main/crud.feature @@ -123,7 +123,7 @@ Feature: Create-Retrieve-Update-Delete "hydra:totalItems": 1, "hydra:search": { "@type": "hydra:IriTemplate", - "hydra:template": "/dummies{?id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],dummy,order[id],order[name],order[relatedDummy.symfony],dummyDate[before],dummyDate[after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[after],dummyFloat[between],dummyFloat[gt],dummyFloat[gte],dummyFloat[lt],dummyFloat[lte],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],dummyBoolean,dummyFloat,dummyPrice}", + "hydra:template": "/dummies{?id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],dummy,relatedDummies.name,order[id],order[name],order[relatedDummy.symfony],dummyDate[before],dummyDate[after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[after],dummyFloat[between],dummyFloat[gt],dummyFloat[gte],dummyFloat[lt],dummyFloat[lte],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],dummyBoolean,dummyFloat,dummyPrice}", "hydra:variableRepresentation": "BasicRepresentation", "hydra:mapping": [ { @@ -186,6 +186,12 @@ Feature: Create-Retrieve-Update-Delete "property": "dummy", "required": false }, + { + "@type": "IriTemplateMapping", + "variable": "relatedDummies.name", + "property": "relatedDummies.name", + "required": false + }, { "@type": "IriTemplateMapping", "variable": "order[id]", diff --git a/features/main/relation.feature b/features/main/relation.feature index 49f1f858196..6a8c7f65df9 100644 --- a/features/main/relation.feature +++ b/features/main/relation.feature @@ -60,13 +60,13 @@ Feature: Relations support "@context": "/contexts/RelatedDummy", "@id": "/related_dummies/1", "@type": "https://schema.org/Product", + "id": 1, "name": null, + "symfony": "symfony", "dummyDate": null, "thirdLevel": "/third_levels/1", - "relatedToDummyFriend": null, + "relatedToDummyFriend": [], "dummyBoolean": null, - "id": 1, - "symfony": "symfony", "age": null } """ diff --git a/src/Bridge/Doctrine/Orm/Extension/FilterEagerLoadingExtension.php b/src/Bridge/Doctrine/Orm/Extension/FilterEagerLoadingExtension.php new file mode 100644 index 00000000000..15547206278 --- /dev/null +++ b/src/Bridge/Doctrine/Orm/Extension/FilterEagerLoadingExtension.php @@ -0,0 +1,132 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Bridge\Doctrine\Orm\Extension; + +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use Doctrine\ORM\Query\Expr\Join; +use Doctrine\ORM\QueryBuilder; + +/** + * Fixes filters on OneToMany associations + * https://github.com/api-platform/core/issues/944. + */ +final class FilterEagerLoadingExtension implements QueryCollectionExtensionInterface +{ + private $resourceMetadataFactory; + private $forceEager; + + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, $forceEager = true) + { + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->forceEager = $forceEager; + } + + /** + * {@inheritdoc} + */ + public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) + { + if (false === $this->forceEager || false === $this->isForceEager($resourceClass, ['collection_operation_name' => $operationName])) { + return; + } + + //If no where part, nothing to do + $wherePart = $queryBuilder->getDQLPart('where'); + + if (!$wherePart) { + return; + } + + $joinParts = $queryBuilder->getDQLPart('join'); + + if (!$joinParts || !isset($joinParts['o'])) { + return; + } + + $queryBuilderClone = clone $queryBuilder; + $queryBuilderClone->resetDQLPart('where'); + $queryBuilderClone->andWhere($queryBuilderClone->expr()->in('o', $this->getQueryBuilderWithNewAliases($queryBuilder, $queryNameGenerator)->getDQL())); + + $queryBuilder->resetDQLPart('where'); + foreach ($queryBuilderClone->getDQLPart('where')->getParts() as $wherePart) { + $queryBuilder->add('where', $wherePart); + } + } + + /** + * Returns a clone of the given query builder where everything gets re-aliased. + * + * @param QueryBuilder $queryBuilder + * @param QueryNameGeneratorInterface $queryBuilder + * @param string $originAlias - the base alias + * @param string $replacement - the replacement for the base alias, will change the from alias + */ + private function getQueryBuilderWithNewAliases(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $originAlias = 'o', string $replacement = 'o_2') + { + $queryBuilderClone = clone $queryBuilder; + $queryBuilderClone->select($replacement); + + $joinParts = $queryBuilder->getDQLPart('join'); + $wherePart = $queryBuilder->getDQLPart('where'); + + //reset parts + $queryBuilderClone->resetDQLPart('join'); + $queryBuilderClone->resetDQLPart('where'); + + //Change from alias + $from = $queryBuilderClone->getDQLPart('from')[0]; + $queryBuilderClone->resetDQLPart('from'); + $queryBuilderClone->from($from->getFrom(), $replacement); + + $aliases = ["$originAlias."]; + $replacements = ["$replacement."]; + + //Change join aliases + foreach ($joinParts[$originAlias] as $joinPart) { + $aliases[] = "{$joinPart->getAlias()}."; + $alias = $queryNameGenerator->generateJoinAlias($joinPart->getAlias()); + $replacements[] = "$alias."; + $join = new Join($joinPart->getJoinType(), str_replace($aliases, $replacements, $joinPart->getJoin()), $alias, $joinPart->getConditionType(), $joinPart->getCondition(), $joinPart->getIndexBy()); + + $queryBuilderClone->add('join', [$join], true); + } + + //Change where aliases + foreach ($wherePart->getParts() as $where) { + $queryBuilderClone->add('where', str_replace($aliases, $replacements, $where)); + } + + return $queryBuilderClone; + } + + /** + * Does an operation force eager? + * + * @param string $resourceClass + * @param array $options + * + * @return bool + */ + private function isForceEager(string $resourceClass, array $options): bool + { + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + + if (isset($options['collection_operation_name'])) { + $forceEager = $resourceMetadata->getCollectionOperationAttribute($options['collection_operation_name'], 'force_eager', null, true); + } else { + $forceEager = $resourceMetadata->getAttribute('force_eager'); + } + + return is_bool($forceEager) ? $forceEager : $this->forceEager; + } +} diff --git a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml index ca72c60149c..823b3a11d5f 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml @@ -105,6 +105,15 @@ + + + + + %api_platform.eager_loading.force_eager% + + + + diff --git a/tests/Bridge/Doctrine/Orm/Extension/FilterEagerLoadingExtensionTest.php b/tests/Bridge/Doctrine/Orm/Extension/FilterEagerLoadingExtensionTest.php new file mode 100644 index 00000000000..78dbdec3212 --- /dev/null +++ b/tests/Bridge/Doctrine/Orm/Extension/FilterEagerLoadingExtensionTest.php @@ -0,0 +1,132 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Tests\Doctrine\Orm\Extension; + +use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\FilterEagerLoadingExtension; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCar; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Foo; +use Doctrine\ORM\EntityManager; +use Doctrine\ORM\Query\Expr; +use Doctrine\ORM\QueryBuilder; + +/** + * @author Antoine Bluchet + */ +class FilterEagerLoadingExtensionTest extends \PHPUnit_Framework_TestCase +{ + public function testIsNoForceEagerCollectionAttributes() + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(DummyCar::class)->willReturn(new ResourceMetadata(DummyCar::class, null, null, null, [ + 'get' => [ + 'force_eager' => false, + ], + ], null)); + + $qb = $this->prophesize(QueryBuilder::class); + $qb->getDQLPart('where')->shouldNotBeCalled(); + + $queryNameGenerator = $this->prophesize(QueryNameGeneratorInterface::class); + + $filterEagerLoadingExtension = new FilterEagerLoadingExtension($resourceMetadataFactoryProphecy->reveal(), true); + $filterEagerLoadingExtension->applyToCollection($qb->reveal(), $queryNameGenerator->reveal(), DummyCar::class, 'get'); + } + + public function testIsNoForceEagerResource() + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(DummyCar::class)->willReturn(new ResourceMetadata(DummyCar::class, null, null, null, [ + 'get' => [], + ], ['force_eager' => false])); + + $qb = $this->prophesize(QueryBuilder::class); + $qb->getDQLPart('where')->shouldNotBeCalled(); + + $queryNameGenerator = $this->prophesize(QueryNameGeneratorInterface::class); + + $filterEagerLoadingExtension = new FilterEagerLoadingExtension($resourceMetadataFactoryProphecy->reveal(), true); + $filterEagerLoadingExtension->applyToCollection($qb->reveal(), $queryNameGenerator->reveal(), DummyCar::class, null); + } + + public function testIsForceEagerConfig() + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(DummyCar::class)->willReturn(new ResourceMetadata(DummyCar::class, null, null, null, [ + 'get' => [], + ])); + + $qb = $this->prophesize(QueryBuilder::class); + $qb->getDQLPart('where')->shouldNotBeCalled(); + + $queryNameGenerator = $this->prophesize(QueryNameGeneratorInterface::class); + + $filterEagerLoadingExtension = new FilterEagerLoadingExtension($resourceMetadataFactoryProphecy->reveal(), false); + $filterEagerLoadingExtension->applyToCollection($qb->reveal(), $queryNameGenerator->reveal(), DummyCar::class, 'get'); + } + + public function testHasNoWherePart() + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(DummyCar::class)->willReturn(new ResourceMetadata(DummyCar::class)); + + $qb = $this->prophesize(QueryBuilder::class); + $qb->getDQLPart('where')->shouldBeCalled()->willReturn(null); + + $queryNameGenerator = $this->prophesize(QueryNameGeneratorInterface::class); + + $filterEagerLoadingExtension = new FilterEagerLoadingExtension($resourceMetadataFactoryProphecy->reveal(), true); + $filterEagerLoadingExtension->applyToCollection($qb->reveal(), $queryNameGenerator->reveal(), DummyCar::class, 'get'); + } + + public function testHasNoJoinPart() + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(DummyCar::class)->willReturn(new ResourceMetadata(DummyCar::class)); + + $qb = $this->prophesize(QueryBuilder::class); + $qb->getDQLPart('where')->shouldBeCalled()->willReturn(new Expr\Andx()); + $qb->getDQLPart('join')->shouldBeCalled()->willReturn(null); + + $queryNameGenerator = $this->prophesize(QueryNameGeneratorInterface::class); + + $filterEagerLoadingExtension = new FilterEagerLoadingExtension($resourceMetadataFactoryProphecy->reveal(), true); + $filterEagerLoadingExtension->applyToCollection($qb->reveal(), $queryNameGenerator->reveal(), DummyCar::class, 'get'); + } + + public function testApplyCollection() + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(DummyCar::class)->willReturn(new ResourceMetadata(DummyCar::class)); + + $em = $this->prophesize(EntityManager::class); + $em->getExpressionBuilder()->shouldBeCalled()->willReturn(new Expr()); + + $qb = new QueryBuilder($em->reveal()); + + $qb->select('o') + ->from(DummyCar::class, 'o') + ->leftJoin('o.colors', 'colors') + ->where('o.colors = :foo') + ->setParameter('foo', 1); + + $queryNameGenerator = $this->prophesize(QueryNameGeneratorInterface::class); + $queryNameGenerator->generateJoinAlias('colors')->shouldBeCalled()->willReturn('colors_2'); + + $filterEagerLoadingExtension = new FilterEagerLoadingExtension($resourceMetadataFactoryProphecy->reveal(), true); + $filterEagerLoadingExtension->applyToCollection($qb, $queryNameGenerator->reveal(), DummyCar::class, 'get'); + + $this->assertEquals('SELECT o FROM ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCar o LEFT JOIN o.colors colors WHERE o IN(SELECT o_2 FROM ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCar o_2 LEFT JOIN o_2.colors colors_2 WHERE o_2.colors = :foo)', $qb->getDQL()); + } +} diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 0f550c2b6ea..28542e64dda 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -249,6 +249,7 @@ private function getContainerBuilderProphecy() 'api_platform.doctrine.orm.order_filter', 'api_platform.doctrine.orm.query_extension.eager_loading', 'api_platform.doctrine.orm.query_extension.filter', + 'api_platform.doctrine.orm.query_extension.filter_eager_loading', 'api_platform.doctrine.orm.query_extension.order', 'api_platform.doctrine.orm.query_extension.pagination', 'api_platform.doctrine.orm.range_filter', diff --git a/tests/Fixtures/TestBundle/Entity/DummyCar.php b/tests/Fixtures/TestBundle/Entity/DummyCar.php new file mode 100644 index 00000000000..db2f46076d8 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyCar.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation as Serializer; + +/** + * @ApiResource( + * attributes={ + * "normalization_context"={"groups"={"colors"}}, + * "filters"={"dummy_car_colors.search_filter"} + * } + * ) + * @ORM\Entity + */ +class DummyCar +{ + /** + * @var int The entity Id + * + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @var string Something else + * + * @ORM\OneToMany(targetEntity="DummyCarColor", mappedBy="car") + * + * @Serializer\Groups({"colors"}) + */ + private $colors; + + public function __construct() + { + $this->colors = new ArrayCollection(); + } + + public function getId() + { + return $this->id; + } + + /** + * @return string + */ + public function getColors() + { + return $this->colors; + } + + /** + * @param string $colors + * + * @return static + */ + public function setColors($colors) + { + $this->colors = $colors; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/DummyCarColor.php b/tests/Fixtures/TestBundle/Entity/DummyCarColor.php new file mode 100644 index 00000000000..1a43d918a5f --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyCarColor.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation as Serializer; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * @ApiResource + * @ORM\Entity + */ +class DummyCarColor +{ + /** + * @var int The entity Id + * + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @var DummyCar + * + * @ORM\ManyToOne(targetEntity="DummyCar", inversedBy="colors") + * @ORM\JoinColumn(nullable=false, onDelete="CASCADE") + * @Assert\NotBlank + */ + private $car; + + /** + * @var string + * + * @ORM\Column(nullable=false) + * @Assert\NotBlank + * + * @Serializer\Groups({"colors"}) + */ + private $prop = ''; + + public function getId() + { + return $this->id; + } + + /** + * @return DummyCar|null + */ + public function getCar() + { + return $this->car; + } + + /** + * @param DummyCar $car + * + * @return static + */ + public function setCar(DummyCar $car) + { + $this->car = $car; + + return $this; + } + + /** + * @return string + */ + public function getProp() + { + return $this->prop; + } + + /** + * @param string $prop + * + * @return static + */ + public function setProp($prop) + { + $this->prop = $prop; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/DummyFriend.php b/tests/Fixtures/TestBundle/Entity/DummyFriend.php index 9c7e85a751e..b4511f87cb9 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyFriend.php +++ b/tests/Fixtures/TestBundle/Entity/DummyFriend.php @@ -42,7 +42,7 @@ class DummyFriend * @ORM\Column * @Assert\NotBlank * @ApiProperty(iri="http://schema.org/name") - * @Groups({"fakemanytomany"}) + * @Groups({"fakemanytomany", "friends"}) */ private $name; diff --git a/tests/Fixtures/TestBundle/Entity/ParentDummy.php b/tests/Fixtures/TestBundle/Entity/ParentDummy.php index 5059c45c0a2..b68bc7ef90e 100644 --- a/tests/Fixtures/TestBundle/Entity/ParentDummy.php +++ b/tests/Fixtures/TestBundle/Entity/ParentDummy.php @@ -12,6 +12,7 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; /** * Parent Dummy. @@ -26,6 +27,7 @@ class ParentDummy * @var int The age * * @ORM\Column(type="integer", nullable=true) + * @Groups({"friends"}) */ private $age; diff --git a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php index 7bc6c328aa2..d14513a52ce 100644 --- a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php +++ b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php @@ -12,6 +12,7 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; @@ -21,7 +22,7 @@ * * @author Kévin Dunglas * - * @ApiResource(iri="https://schema.org/Product") + * @ApiResource(iri="https://schema.org/Product", attributes={"normalization_context"={"groups"={"friends"}}, "filters"={"related_dummy.friends"}}) * @ORM\Entity */ class RelatedDummy extends ParentDummy @@ -30,6 +31,7 @@ class RelatedDummy extends ParentDummy * @ORM\Column(type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="AUTO") + * @Groups({"friends"}) */ private $id; @@ -37,12 +39,13 @@ class RelatedDummy extends ParentDummy * @var string A name * * @ORM\Column(nullable=true) + * @Groups({"friends"}) */ public $name; /** * @ORM\Column - * @Groups({"barcelona", "chicago"}) + * @Groups({"barcelona", "chicago", "friends"}) */ protected $symfony = 'symfony'; @@ -51,25 +54,32 @@ class RelatedDummy extends ParentDummy * * @ORM\Column(type="datetime", nullable=true) * @Assert\DateTime + * @Groups({"friends"}) */ public $dummyDate; /** * @ORM\ManyToOne(targetEntity="ThirdLevel", cascade={"persist"}) - * @Groups({"barcelona", "chicago"}) + * @Groups({"barcelona", "chicago", "friends"}) */ public $thirdLevel; /** - * @ORM\OneToMany(targetEntity="RelatedToDummyFriend", cascade={"persist"}, fetch="EAGER", mappedBy="relatedDummy") - * @Groups({"fakemanytomany"}) + * @ORM\OneToMany(targetEntity="RelatedToDummyFriend", cascade={"persist"}, mappedBy="relatedDummy") + * @Groups({"fakemanytomany", "friends"}) */ public $relatedToDummyFriend; + public function __construct() + { + $this->relatedToDummyFriend = new ArrayCollection(); + } + /** * @var bool A dummy bool * * @ORM\Column(type="boolean", nullable=true) + * @Groups({"friends"}) */ public $dummyBoolean; @@ -139,8 +149,8 @@ public function getRelatedToDummyFriend() * * @param relatedToDummyFriend the value to set */ - public function setRelatedToDummyFriend(RelatedToDummyFriend $relatedToDummyFriend) + public function addRelatedToDummyFriend(RelatedToDummyFriend $relatedToDummyFriend) { - $this->relatedToDummyFriend = $relatedToDummyFriend; + $this->relatedToDummyFriend->add($relatedToDummyFriend); } } diff --git a/tests/Fixtures/TestBundle/Entity/RelatedToDummyFriend.php b/tests/Fixtures/TestBundle/Entity/RelatedToDummyFriend.php index 3b02503b30f..980cbe0b9d0 100644 --- a/tests/Fixtures/TestBundle/Entity/RelatedToDummyFriend.php +++ b/tests/Fixtures/TestBundle/Entity/RelatedToDummyFriend.php @@ -31,15 +31,15 @@ class RelatedToDummyFriend * @ORM\Column * @Assert\NotBlank * @ApiProperty(iri="http://schema.org/name") - * @Groups({"fakemanytomany"}) + * @Groups({"fakemanytomany", "friends"}) */ private $name; /** * @ORM\Id - * @ORM\ManyToOne(targetEntity="DummyFriend", fetch="EAGER") + * @ORM\ManyToOne(targetEntity="DummyFriend") * @ORM\JoinColumn(name="dummyfriend_id", referencedColumnName="id", nullable=false) - * @Groups({"fakemanytomany"}) + * @Groups({"fakemanytomany", "friends"}) */ private $dummyFriend; diff --git a/tests/Fixtures/app/config/config.yml b/tests/Fixtures/app/config/config.yml index bfd381bb6d4..1a91953ec75 100644 --- a/tests/Fixtures/app/config/config.yml +++ b/tests/Fixtures/app/config/config.yml @@ -94,7 +94,7 @@ services: 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' } ] + arguments: [ { 'id': 'exact', 'name': 'partial', 'alias': 'start', 'description': 'word_start', 'relatedDummy.name': 'exact', 'relatedDummies': 'exact', 'dummy': 'ipartial', 'relatedDummies.name': 'start' } ] tags: [ { name: 'api_platform.filter', id: 'my_dummy.search' } ] app.my_dummy_resource.order_filter: @@ -126,5 +126,17 @@ services: class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\Action\ConfigCustom' arguments: ['@api_platform.item_data_provider'] + app.entity.filter.dummy_car: + parent: 'api_platform.doctrine.orm.search_filter' + arguments: [ { 'colors.prop': 'ipartial' } ] + tags: [ { name: 'api_platform.filter', id: 'dummy_car_colors.search_filter' } ] + + app.related_dummy_resource.search_filter: + parent: 'api_platform.doctrine.orm.search_filter' + arguments: [ { 'relatedToDummyFriend.dummyFriend': 'exact' } ] + tags: [ { name: 'api_platform.filter', id: 'related_dummy.friends' } ] + + logger: class: Psr\Log\NullLogger +