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%
+
+
+
+