From 5dc1b08ba7483f03b0148e9bfe33014acc4266e3 Mon Sep 17 00:00:00 2001 From: Jordan Samouh Date: Sun, 12 Mar 2017 23:36:52 +0100 Subject: [PATCH] Manage Embedded Fields --- features/bootstrap/FeatureContext.php | 126 ++++++++ features/doctrine/boolean_filter.feature | 204 +++++++++++++ features/doctrine/date_filter.feature | 280 +++++++++++++++++- features/doctrine/order_filter.feature | 121 +++++++- features/doctrine/search_filter.feature | 40 +++ features/main/crud.feature | 16 +- features/main/relation.feature | 3 + features/main/subresource.feature | 3 + .../Doctrine/Orm/Filter/AbstractFilter.php | 123 +++++++- .../Doctrine/Orm/Filter/BooleanFilter.php | 9 +- src/Bridge/Doctrine/Orm/Filter/DateFilter.php | 8 +- .../Doctrine/Orm/Filter/ExistsFilter.php | 10 +- .../Doctrine/Orm/Filter/NumericFilter.php | 10 +- .../Doctrine/Orm/Filter/OrderFilter.php | 8 +- .../Doctrine/Orm/Filter/RangeFilter.php | 6 +- .../Doctrine/Orm/Filter/SearchFilter.php | 10 +- .../Doctrine/Orm/Util/QueryNameGenerator.php | 2 +- .../TestBundle/Entity/EmbeddableDummy.php | 174 +++++++++++ .../TestBundle/Entity/EmbeddedDummy.php | 145 +++++++++ .../TestBundle/Entity/RelatedDummy.php | 24 ++ tests/Fixtures/app/config/config.yml | 10 +- 21 files changed, 1271 insertions(+), 61 deletions(-) create mode 100644 tests/Fixtures/TestBundle/Entity/EmbeddableDummy.php create mode 100644 tests/Fixtures/TestBundle/Entity/EmbeddedDummy.php diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 3445b4dcfce..f55fb76b5e0 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -25,6 +25,8 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyOffer; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyProduct; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyProperty; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\EmbeddableDummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\EmbeddedDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\FileConfigDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Node; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Question; @@ -170,6 +172,25 @@ public function thereIsDummyPropertyObjects($nb) $this->manager->flush(); } + /** + * @Given there are :nb embedded dummy objects + */ + public function thereIsEmbeddedDummyObjects($nb) + { + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new EmbeddedDummy(); + $dummy->setName('Dummy #'.$i); + + $embeddableDummy = new EmbeddableDummy(); + $embeddableDummy->setDummyName('Dummy #'.$i); + $dummy->setEmbeddedDummy($embeddableDummy); + + $this->manager->persist($dummy); + } + + $this->manager->flush(); + } + /** * @Given there is :nb dummy objects with relatedDummy */ @@ -191,6 +212,25 @@ public function thereIsDummyObjectsWithRelatedDummy($nb) $this->manager->flush(); } + /** + * @Given there is :nb dummy objects with embeddedDummy + */ + public function thereIsDummyObjectsWithEmbeddedDummy($nb) + { + for ($i = 1; $i <= $nb; ++$i) { + $embeddableDummy = new EmbeddableDummy(); + $embeddableDummy->setDummyName('EmbeddedDummy #'.$i); + + $dummy = new EmbeddedDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setEmbeddedDummy($embeddableDummy); + + $this->manager->persist($dummy); + } + + $this->manager->flush(); + } + /** * @Given there is :nb dummy objects having each :nbrelated relatedDummies */ @@ -305,6 +345,32 @@ public function thereIsDummyObjectsWithDummyDateAndRelatedDummy($nb) $this->manager->flush(); } + /** + * @Given there is :nb embedded dummy objects with dummyDate and embeddedDummy + */ + public function thereIsDummyObjectsWithDummyDateAndEmbeddedDummy($nb) + { + for ($i = 1; $i <= $nb; ++$i) { + $date = new \DateTime(sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); + + $embeddableDummy = new EmbeddableDummy(); + $embeddableDummy->setDummyName('Embeddable #'.$i); + $embeddableDummy->setDummyDate($date); + + $dummy = new EmbeddedDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setEmbeddedDummy($embeddableDummy); + // Last Dummy has a null date + if ($nb !== $i) { + $dummy->setDummyDate($date); + } + + $this->manager->persist($dummy); + } + + $this->manager->flush(); + } + /** * @Given there is :nb dummy objects with dummyPrice */ @@ -354,6 +420,66 @@ public function thereIsDummyObjectsWithDummyBoolean($nb, $bool) $this->manager->flush(); } + /** + * @Given there is :nb embedded dummy objects with embeddedDummy.dummyBoolean :bool + */ + public function thereIsDummyObjectsWithEmbeddedDummyBoolean($nb, $bool) + { + if (in_array($bool, ['true', '1', 1], true)) { + $bool = true; + } elseif (in_array($bool, ['false', '0', 0], true)) { + $bool = false; + } else { + $expected = ['true', 'false', '1', '0']; + throw new InvalidArgumentException(sprintf('Invalid boolean value for "%s" property, expected one of ( "%s" )', $bool, implode('" | "', $expected))); + } + + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new EmbeddedDummy(); + $dummy->setName('Embedded Dummy #'.$i); + $embeddableDummy = new EmbeddableDummy(); + $embeddableDummy->setDummyName('Embedded Dummy #'.$i); + $embeddableDummy->setDummyBoolean($bool); + $dummy->setEmbeddedDummy($embeddableDummy); + $this->manager->persist($dummy); + } + + $this->manager->flush(); + } + + /** + * @Given there is :nb embedded dummy objects with relatedDummy.embeddedDummy.dummyBoolean :bool + */ + public function thereIsDummyObjectsWithRelationEmbeddedDummyBoolean($nb, $bool) + { + if (in_array($bool, ['true', '1', 1], true)) { + $bool = true; + } elseif (in_array($bool, ['false', '0', 0], true)) { + $bool = false; + } else { + $expected = ['true', 'false', '1', '0']; + throw new InvalidArgumentException(sprintf('Invalid boolean value for "%s" property, expected one of ( "%s" )', $bool, implode('" | "', $expected))); + } + + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new EmbeddedDummy(); + $dummy->setName('Embedded Dummy #'.$i); + $embeddableDummy = new EmbeddableDummy(); + $embeddableDummy->setDummyName('Embedded Dummy #'.$i); + $embeddableDummy->setDummyBoolean($bool); + + $relationDummy = new RelatedDummy(); + $relationDummy->setEmbeddedDummy($embeddableDummy); + + $dummy->setRelatedDummy($relationDummy); + + $this->manager->persist($relationDummy); + $this->manager->persist($dummy); + } + + $this->manager->flush(); + } + /** * @Given there is a RelationEmbedder object */ diff --git a/features/doctrine/boolean_filter.feature b/features/doctrine/boolean_filter.feature index 5cf86e14bae..b89d9369515 100644 --- a/features/doctrine/boolean_filter.feature +++ b/features/doctrine/boolean_filter.feature @@ -166,6 +166,210 @@ Feature: Boolean filter on collections """ And the JSON node "hydra:totalItems" should be equal to 10 + Scenario: Get collection by embeddedDummy.dummyBoolean true + Given there is "15" embedded dummy objects with embeddedDummy.dummyBoolean true + And there is "10" embedded dummy objects with embeddedDummy.dummyBoolean false + When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyBoolean=true" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, + "@id": {"pattern": "^/embedded_dummies$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@id": { + "oneOf": [ + {"pattern": "^/embedded_dummies/1$"}, + {"pattern": "^/embedded_dummies/2$"}, + {"pattern": "^/embedded_dummies/3$"} + ] + } + } + } + }, + "hydra:view": { + "type": "object", + "properties": { + "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyBoolean=true"}, + "@type": {"pattern": "^hydra:PartialCollectionView$"} + } + } + } + } + """ + And the JSON node "hydra:totalItems" should be equal to 15 + + Scenario: Get collection by embeddedDummy.dummyBoolean true + When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyBoolean=1" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, + "@id": {"pattern": "^/embedded_dummies$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@id": { + "oneOf": [ + {"pattern": "^/embedded_dummies/1$"}, + {"pattern": "^/embedded_dummies/2$"}, + {"pattern": "^/embedded_dummies/3$"} + ] + } + } + } + }, + "hydra:view": { + "type": "object", + "properties": { + "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyBoolean=1"}, + "@type": {"pattern": "^hydra:PartialCollectionView$"} + } + } + } + } + """ + And the JSON node "hydra:totalItems" should be equal to 15 + + Scenario: Get collection by embeddedDummy.dummyBoolean false + When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyBoolean=false" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, + "@id": {"pattern": "^/embedded_dummies$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@id": { + "oneOf": [ + {"pattern": "^/embedded_dummies/16$"}, + {"pattern": "^/embedded_dummies/17$"}, + {"pattern": "^/embedded_dummies/18$"} + ] + } + } + } + }, + "hydra:view": { + "type": "object", + "properties": { + "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyBoolean=false"}, + "@type": {"pattern": "^hydra:PartialCollectionView$"} + } + } + } + } + """ + And the JSON node "hydra:totalItems" should be equal to 10 + + Scenario: Get collection by embeddedDummy.dummyBoolean false + When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyBoolean=0" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, + "@id": {"pattern": "^/embedded_dummies$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@id": { + "oneOf": [ + {"pattern": "^/embedded_dummies/16$"}, + {"pattern": "^/embedded_dummies/17$"}, + {"pattern": "^/embedded_dummies/18$"} + ] + } + } + } + }, + "hydra:view": { + "type": "object", + "properties": { + "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyBoolean=0"}, + "@type": {"pattern": "^hydra:PartialCollectionView$"} + } + } + } + } + """ + And the JSON node "hydra:totalItems" should be equal to 10 + + Scenario: Get collection by association with embed relatedDummy.embeddedDummy.dummyBoolean true + Given there is "15" embedded dummy objects with relatedDummy.embeddedDummy.dummyBoolean true + And there is "10" embedded dummy objects with relatedDummy.embeddedDummy.dummyBoolean false + When I send a "GET" request to "/embedded_dummies?relatedDummy.embeddedDummy.dummyBoolean=true" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, + "@id": {"pattern": "^/embedded_dummies$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@id": { + "oneOf": [ + {"pattern": "^/embedded_dummies/26$"}, + {"pattern": "^/embedded_dummies/27$"}, + {"pattern": "^/embedded_dummies/28$"} + ] + } + } + } + }, + "hydra:view": { + "type": "object", + "properties": { + "@id": {"pattern": "^/embedded_dummies\\?relatedDummy.embeddedDummy\\.dummyBoolean=true"}, + "@type": {"pattern": "^hydra:PartialCollectionView$"} + } + } + } + } + """ + And the JSON node "hydra:totalItems" should be equal to 15 + @dropSchema Scenario: Get collection ordered by a non valid properties When I send a "GET" request to "/dummies?unknown=0" diff --git a/features/doctrine/date_filter.feature b/features/doctrine/date_filter.feature index 2eb5b993aeb..f09d0e707c2 100644 --- a/features/doctrine/date_filter.feature +++ b/features/doctrine/date_filter.feature @@ -395,14 +395,269 @@ Feature: Date filter on collections And the JSON should be equal to: """ { - "@context": "/contexts/Dummy", - "@id": "/dummies", - "@type": "hydra:Collection", - "hydra:member": [], - "hydra:totalItems": 0, - "hydra:view": { - "@id": "/dummies?relatedDummy.dummyDate%5Bafter%5D=2015-04-28", - "@type": "hydra:PartialCollectionView" + "@context": "/contexts/Dummy", + "@id": "/dummies", + "@type": "hydra:Collection", + "hydra:member": [], + "hydra:totalItems": 0, + "hydra:view": { + "@id": "/dummies?relatedDummy.dummyDate%5Bafter%5D=2015-04-28", + "@type": "hydra:PartialCollectionView" + }, + "hydra:search": { + "@type": "hydra:IriTemplate", + "hydra:template": "/dummies{?dummyBoolean,relatedDummy.embeddedDummy.dummyBoolean,dummyDate[before],dummyDate[after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[after],description[exists],relatedDummy.name[exists],dummyBoolean[exists],dummyFloat,dummyPrice,order[id],order[name],order[relatedDummy.name],order[relatedDummy.symfony],dummyFloat[between],dummyFloat[gt],dummyFloat[gte],dummyFloat[lt],dummyFloat[lte],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],dummy,relatedDummies.name}", + "hydra:variableRepresentation": "BasicRepresentation", + "hydra:mapping": [ + { + "@type": "IriTemplateMapping", + "variable": "dummyBoolean", + "property": "dummyBoolean", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "relatedDummy.embeddedDummy.dummyBoolean", + "property": "relatedDummy.embeddedDummy.dummyBoolean", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyDate[before]", + "property": "dummyDate", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyDate[after]", + "property": "dummyDate", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "relatedDummy.dummyDate[before]", + "property": "relatedDummy.dummyDate", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "relatedDummy.dummyDate[after]", + "property": "relatedDummy.dummyDate", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "description[exists]", + "property": "description", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "relatedDummy.name[exists]", + "property": "relatedDummy.name", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyBoolean[exists]", + "property": "dummyBoolean", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyFloat", + "property": "dummyFloat", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyPrice", + "property": "dummyPrice", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "order[id]", + "property": "id", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "order[name]", + "property": "name", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "order[relatedDummy.name]", + "property": "relatedDummy.name", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "order[relatedDummy.symfony]", + "property": "relatedDummy.symfony", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyFloat[between]", + "property": "dummyFloat", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyFloat[gt]", + "property": "dummyFloat", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyFloat[gte]", + "property": "dummyFloat", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyFloat[lt]", + "property": "dummyFloat", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyFloat[lte]", + "property": "dummyFloat", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyPrice[between]", + "property": "dummyPrice", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyPrice[gt]", + "property": "dummyPrice", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyPrice[gte]", + "property": "dummyPrice", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyPrice[lt]", + "property": "dummyPrice", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyPrice[lte]", + "property": "dummyPrice", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "id", + "property": "id", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "id[]", + "property": "id", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "name", + "property": "name", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "alias", + "property": "alias", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "description", + "property": "description", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "relatedDummy.name", + "property": "relatedDummy.name", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "relatedDummy.name[]", + "property": "relatedDummy.name", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "relatedDummies", + "property": "relatedDummies", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "relatedDummies[]", + "property": "relatedDummies", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummy", + "property": "dummy", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "relatedDummies.name", + "property": "relatedDummies.name", + "required": false + } + ] + } + } + """ + + @dropSchema + @createSchema + Scenario: Get collection filtered by embedded date + Given there is "2" embedded dummy objects with dummyDate and embeddedDummy + When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyDate[after]=2015-04-28" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, + "@id": {"pattern": "^/embedded_dummies$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@id": { + "oneOf": [ + {"pattern": "^/embedded_dummies/28$"}, + {"pattern": "^/embedded_dummies/29$"} + ] + } + } }, "hydra:search": { "@type": "hydra:IriTemplate", @@ -614,6 +869,15 @@ Feature: Date filter on collections "required": false } ] + }, + "hydra:view": { + "type": "object", + "properties": { + "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyDate%5Bafter%5D=2015-04-28$"}, + "@type": {"pattern": "^hydra:PartialCollectionView$"} + } } + } } + } """ diff --git a/features/doctrine/order_filter.feature b/features/doctrine/order_filter.feature index e1c9ab5357c..22766464cbd 100644 --- a/features/doctrine/order_filter.feature +++ b/features/doctrine/order_filter.feature @@ -331,7 +331,7 @@ Feature: Order filter on collections "properties": { "@id": { "type": "string", - "pattern": "^/dummies/2$" + "pattern": "^/dummies/2" } } }, @@ -360,6 +360,125 @@ Feature: Order filter on collections } """ + Scenario: Get collection ordered in ascending order on an embedded and on which order filter has been enabled in whitelist mode + Given there is "30" dummy objects with embeddedDummy + When I send a "GET" request to "/embedded_dummies?order[embeddedDummy]=asc" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, + "@id": {"pattern": "^/embedded_dummies$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": [ + { + "type": "object", + "properties": { + "@id": { + "type": "string", + "pattern": "^/embedded_dummies/1$" + } + } + }, + { + "type": "object", + "properties": { + "@id": { + "type": "string", + "pattern": "^/embedded_dummies/2$" + } + } + }, + { + "type": "object", + "properties": { + "@id": { + "type": "string", + "pattern": "^/embedded_dummies/3$" + } + } + } + ], + "additionalItems": false, + "maxItems": 3, + "minItems": 3 + }, + "hydra:view": { + "type": "object", + "properties": { + "@id": {"pattern": "^/embedded_dummies\\?order%5BembeddedDummy%5D=asc"}, + "@type": {"pattern": "^hydra:PartialCollectionView$"} + } + } + } + } + """ + + Scenario: Get collection ordered by default configured order on a embedded string property and on which order filter has been enabled in whitelist mode with default descending order + When I send a "GET" request to "/embedded_dummies?order[embeddedDummy.dummyName]" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, + "@id": {"pattern": "^/embedded_dummies$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": [ + { + "type": "object", + "properties": { + "@id": { + "type": "string", + "pattern": "^/embedded_dummies/9" + } + } + }, + { + "type": "object", + "properties": { + "@id": { + "type": "string", + "pattern": "^/embedded_dummies/8" + } + } + }, + { + "type": "object", + "properties": { + "@id": { + "type": "string", + "pattern": "^/embedded_dummies/7" + } + } + } + ], + "additionalItems": false, + "maxItems": 3, + "minItems": 3 + }, + "hydra:view": { + "type": "object", + "properties": { + "@id": {"pattern": "^/embedded_dummies\\?order%5BembeddedDummy\\.dummyName%5D="}, + "@type": {"pattern": "^hydra:PartialCollectionView$"} + } + } + } + } + """ + @dropSchema Scenario: Get collection ordered by a non valid properties and on which order filter has been enabled in whitelist mode When I send a "GET" request to "/dummies?order[alias]=asc" diff --git a/features/doctrine/search_filter.feature b/features/doctrine/search_filter.feature index ee6072e954e..ba1394271d3 100644 --- a/features/doctrine/search_filter.feature +++ b/features/doctrine/search_filter.feature @@ -104,6 +104,46 @@ Feature: Search filter on collections } """ + Scenario: Search collection by name (partial) + Given there are "30" embedded dummy objects + When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyName=my" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, + "@id": {"pattern": "^/embedded_dummies$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@id": { + "oneOf": [ + {"pattern": "^/embedded_dummies/1$"}, + {"pattern": "^/embedded_dummies/2$"}, + {"pattern": "^/embedded_dummies/3$"} + ] + } + } + } + }, + "hydra:view": { + "type": "object", + "properties": { + "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyName=my"}, + "@type": {"pattern": "^hydra:PartialCollectionView$"} + } + } + } + } + """ + Scenario: Search collection by name (partial case insensitive) When I send a "GET" request to "/dummies?dummy=somedummytest1" Then the response status code should be 200 diff --git a/features/main/crud.feature b/features/main/crud.feature index 472ee1b47c5..27d1ccee016 100644 --- a/features/main/crud.feature +++ b/features/main/crud.feature @@ -92,7 +92,7 @@ Feature: Create-Retrieve-Update-Delete And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" And the JSON should be equal to: """ - { + { "@context": "/contexts/Dummy", "@id": "/dummies", "@type": "hydra:Collection", @@ -123,7 +123,7 @@ Feature: Create-Retrieve-Update-Delete "hydra:totalItems": 1, "hydra:search": { "@type": "hydra:IriTemplate", - "hydra:template": "/dummies{?dummyBoolean,dummyDate[before],dummyDate[after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[after],description[exists],relatedDummy.name[exists],dummyBoolean[exists],dummyFloat,dummyPrice,order[id],order[name],order[relatedDummy.symfony],dummyFloat[between],dummyFloat[gt],dummyFloat[gte],dummyFloat[lt],dummyFloat[lte],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],dummy,relatedDummies.name}", + "hydra:template": "/dummies{?dummyBoolean,relatedDummy.embeddedDummy.dummyBoolean,dummyDate[before],dummyDate[after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[after],description[exists],relatedDummy.name[exists],dummyBoolean[exists],dummyFloat,dummyPrice,order[id],order[name],order[relatedDummy.name],order[relatedDummy.symfony],dummyFloat[between],dummyFloat[gt],dummyFloat[gte],dummyFloat[lt],dummyFloat[lte],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],dummy,relatedDummies.name}", "hydra:variableRepresentation": "BasicRepresentation", "hydra:mapping": [ { @@ -132,6 +132,12 @@ Feature: Create-Retrieve-Update-Delete "property": "dummyBoolean", "required": false }, + { + "@type": "IriTemplateMapping", + "variable": "relatedDummy.embeddedDummy.dummyBoolean", + "property": "relatedDummy.embeddedDummy.dummyBoolean", + "required": false + }, { "@type": "IriTemplateMapping", "variable": "dummyDate[before]", @@ -198,6 +204,12 @@ Feature: Create-Retrieve-Update-Delete "property": "name", "required": false }, + { + "@type": "IriTemplateMapping", + "variable": "order[relatedDummy.name]", + "property": "relatedDummy.name", + "required": false + }, { "@type": "IriTemplateMapping", "variable": "order[relatedDummy.symfony]", diff --git a/features/main/relation.feature b/features/main/relation.feature index 6a8c7f65df9..36eb289c0f4 100644 --- a/features/main/relation.feature +++ b/features/main/relation.feature @@ -67,6 +67,9 @@ Feature: Relations support "thirdLevel": "/third_levels/1", "relatedToDummyFriend": [], "dummyBoolean": null, + "embeddedDummy": null, + "id": 1, + "symfony": "symfony", "age": null } """ diff --git a/features/main/subresource.feature b/features/main/subresource.feature index 02967934e06..b40c065c241 100644 --- a/features/main/subresource.feature +++ b/features/main/subresource.feature @@ -102,6 +102,7 @@ Feature: Subresource support "thirdLevel": "/third_levels/1", "relatedToDummyFriend": [], "dummyBoolean": null, + "embeddedDummy": [], "age": null }, { @@ -114,6 +115,7 @@ Feature: Subresource support "thirdLevel": "/third_levels/1", "relatedToDummyFriend": [], "dummyBoolean": null, + "embeddedDummy": [], "age": null } ], @@ -168,6 +170,7 @@ Feature: Subresource support "thirdLevel": "/third_levels/1", "relatedToDummyFriend": [], "dummyBoolean": null, + "embeddedDummy": [], "age": null } ], diff --git a/src/Bridge/Doctrine/Orm/Filter/AbstractFilter.php b/src/Bridge/Doctrine/Orm/Filter/AbstractFilter.php index 2d1eec718d2..9736004b72e 100644 --- a/src/Bridge/Doctrine/Orm/Filter/AbstractFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/AbstractFilter.php @@ -59,7 +59,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q return; } - foreach ($this->extractProperties($request) as $property => $value) { + foreach ($this->extractProperties($request, $resourceClass) as $property => $value) { $this->filterProperty($property, $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operationName); } } @@ -98,11 +98,23 @@ protected function getClassMetadata(string $resourceClass): ClassMetadata * * @return bool */ - protected function isPropertyEnabled(string $property): bool + protected function isPropertyEnabled(string $property/*, string $resourceClass*/): bool { + if (func_num_args() > 1) { + $resourceClass = func_get_arg(1); + } else { + if (__CLASS__ !== get_class($this)) { + $r = new \ReflectionMethod($this, __FUNCTION__); + if (__CLASS__ !== $r->getDeclaringClass()->getName()) { + @trigger_error(sprintf('Method %s() will have a second `$resourceClass` argument in version API Platform 3.0. Not defining it is deprecated since API Platform 2.1.', __FUNCTION__), E_USER_DEPRECATED); + } + } + $resourceClass = null; + } + if (null === $this->properties) { // to ensure sanity, nested properties must still be explicitly enabled - return !$this->isPropertyNested($property); + return !$this->isPropertyNested($property, $resourceClass); } return array_key_exists($property, $this->properties); @@ -119,8 +131,8 @@ protected function isPropertyEnabled(string $property): bool */ protected function isPropertyMapped(string $property, string $resourceClass, bool $allowAssociation = false): bool { - if ($this->isPropertyNested($property)) { - $propertyParts = $this->splitPropertyParts($property); + if ($this->isPropertyNested($property, $resourceClass)) { + $propertyParts = $this->splitPropertyParts($property, $resourceClass); $metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']); $property = $propertyParts['field']; } else { @@ -137,9 +149,38 @@ protected function isPropertyMapped(string $property, string $resourceClass, boo * * @return bool */ - protected function isPropertyNested(string $property): bool + protected function isPropertyNested(string $property/*, string $resourceClass*/): bool { - return false !== strpos($property, '.'); + if (func_num_args() > 1) { + $resourceClass = func_get_arg(1); + } else { + if (__CLASS__ !== get_class($this)) { + $r = new \ReflectionMethod($this, __FUNCTION__); + if (__CLASS__ !== $r->getDeclaringClass()->getName()) { + @trigger_error(sprintf('Method %s() will have a second `$resourceClass` argument in version API Platform 3.0. Not defining it is deprecated since API Platform 2.1.', __FUNCTION__), E_USER_DEPRECATED); + } + } + $resourceClass = null; + } + + if (false === $pos = strpos($property, '.')) { + return false; + } + + return null !== $resourceClass && $this->getClassMetadata($resourceClass)->hasAssociation(substr($property, 0, $pos)); + } + + /** + * Determines whether the given property is embedded. + * + * @param string $property + * @param string $resourceClass + * + * @return bool + */ + protected function isPropertyEmbedded(string $property, string $resourceClass): bool + { + return false !== strpos($property, '.') && $this->getClassMetadata($resourceClass)->hasField($property); } /** @@ -179,13 +220,43 @@ protected function getNestedMetadata(string $resourceClass, array $associations) * * @return array */ - protected function splitPropertyParts(string $property): array + protected function splitPropertyParts(string $property/*, string $resourceClass*/): array { $parts = explode('.', $property); + if (func_num_args() > 1) { + $resourceClass = func_get_arg(1); + } else { + if (__CLASS__ !== get_class($this)) { + $r = new \ReflectionMethod($this, __FUNCTION__); + if (__CLASS__ !== $r->getDeclaringClass()->getName()) { + @trigger_error(sprintf('Method %s() will have a second `$resourceClass` argument in version API Platform 3.0. Not defining it is deprecated since API Platform 2.1.', __FUNCTION__), E_USER_DEPRECATED); + } + } + + return [ + 'associations' => array_slice($parts, 0, -1), + 'field' => end($parts), + ]; + } + + $metadata = $this->getClassMetadata($resourceClass); + $slice = 0; + + foreach ($parts as $part) { + if ($metadata->hasAssociation($part)) { + $metadata = $this->getClassMetadata($metadata->getAssociationTargetClass($part)); + $slice += 1; + } + } + + if ($slice === count($parts)) { + $slice -= 1; + } + return [ - 'associations' => array_slice($parts, 0, -1), - 'field' => end($parts), + 'associations' => array_slice($parts, 0, $slice), + 'field' => implode('.', array_slice($parts, $slice)), ]; } @@ -196,13 +267,25 @@ protected function splitPropertyParts(string $property): array * * @return array */ - protected function extractProperties(Request $request): array + protected function extractProperties(Request $request/*, string $resourceClass*/): array { + if (func_num_args() > 1) { + $resourceClass = func_get_arg(1); + } else { + if (__CLASS__ !== get_class($this)) { + $r = new \ReflectionMethod($this, __FUNCTION__); + if (__CLASS__ !== $r->getDeclaringClass()->getName()) { + @trigger_error(sprintf('Method %s() will have a second `$resourceClass` argument in version API Platform 3.0. Not defining it is deprecated since API Platform 2.1.', __FUNCTION__), E_USER_DEPRECATED); + } + } + $resourceClass = null; + } + $needsFixing = false; if (null !== $this->properties) { foreach ($this->properties as $property => $value) { - if ($this->isPropertyNested($property) && $request->query->has(str_replace('.', '_', $property))) { + if (($this->isPropertyNested($property, $resourceClass) || $this->isPropertyEmbedded($property, $resourceClass)) && $request->query->has(str_replace('.', '_', $property))) { $needsFixing = true; } } @@ -229,9 +312,21 @@ protected function extractProperties(Request $request): array * the second element is the $field name * the third element is the $associations array */ - protected function addJoinsForNestedProperty(string $property, string $rootAlias, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator): array + protected function addJoinsForNestedProperty(string $property, string $rootAlias, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator/*, string $resourceClass*/): array { - $propertyParts = $this->splitPropertyParts($property); + if (func_num_args() > 4) { + $resourceClass = func_get_arg(4); + } else { + if (__CLASS__ !== get_class($this)) { + $r = new \ReflectionMethod($this, __FUNCTION__); + if (__CLASS__ !== $r->getDeclaringClass()->getName()) { + @trigger_error(sprintf('Method %s() will have a fifth `$resourceClass` argument in version API Platform 3.0. Not defining it is deprecated since API Platform 2.1.', __FUNCTION__), E_USER_DEPRECATED); + } + } + $resourceClass = null; + } + + $propertyParts = $this->splitPropertyParts($property, $resourceClass); $parentAlias = $rootAlias; foreach ($propertyParts['associations'] as $association) { diff --git a/src/Bridge/Doctrine/Orm/Filter/BooleanFilter.php b/src/Bridge/Doctrine/Orm/Filter/BooleanFilter.php index b27e78ec336..f17d400a234 100644 --- a/src/Bridge/Doctrine/Orm/Filter/BooleanFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/BooleanFilter.php @@ -65,7 +65,7 @@ public function getDescription(string $resourceClass): array protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) { if ( - !$this->isPropertyEnabled($property) || + !$this->isPropertyEnabled($property, $resourceClass) || !$this->isPropertyMapped($property, $resourceClass) || !$this->isBooleanField($property, $resourceClass) ) { @@ -92,9 +92,10 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB $alias = 'o'; $field = $property; - if ($this->isPropertyNested($property)) { - list($alias, $field) = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator); + if ($this->isPropertyNested($property, $resourceClass)) { + list($alias, $field) = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass); } + $valueParameter = $queryNameGenerator->generateParameterName($field); $queryBuilder @@ -112,7 +113,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB */ protected function isBooleanField(string $property, string $resourceClass): bool { - $propertyParts = $this->splitPropertyParts($property); + $propertyParts = $this->splitPropertyParts($property, $resourceClass); $metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']); return DBALType::BOOLEAN === $metadata->getTypeOfField($propertyParts['field']); diff --git a/src/Bridge/Doctrine/Orm/Filter/DateFilter.php b/src/Bridge/Doctrine/Orm/Filter/DateFilter.php index c6adb6cdd9c..727b94be92b 100644 --- a/src/Bridge/Doctrine/Orm/Filter/DateFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/DateFilter.php @@ -68,7 +68,7 @@ protected function filterProperty(string $property, $values, QueryBuilder $query // Expect $values to be an array having the period as keys and the date value as values if ( !is_array($values) || - !$this->isPropertyEnabled($property) || + !$this->isPropertyEnabled($property, $resourceClass) || !$this->isPropertyMapped($property, $resourceClass) || !$this->isDateField($property, $resourceClass) ) { @@ -78,8 +78,8 @@ protected function filterProperty(string $property, $values, QueryBuilder $query $alias = 'o'; $field = $property; - if ($this->isPropertyNested($property)) { - list($alias, $field) = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator); + if ($this->isPropertyNested($property, $resourceClass)) { + list($alias, $field) = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass); } $nullManagement = $this->properties[$property] ?? null; @@ -159,7 +159,7 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf */ protected function isDateField(string $property, string $resourceClass): bool { - $propertyParts = $this->splitPropertyParts($property); + $propertyParts = $this->splitPropertyParts($property, $resourceClass); $metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']); return isset(self::DOCTRINE_DATE_TYPES[$metadata->getTypeOfField($propertyParts['field'])]); diff --git a/src/Bridge/Doctrine/Orm/Filter/ExistsFilter.php b/src/Bridge/Doctrine/Orm/Filter/ExistsFilter.php index 4019a4d0147..bc9e9febcb6 100644 --- a/src/Bridge/Doctrine/Orm/Filter/ExistsFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/ExistsFilter.php @@ -68,7 +68,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB { if ( !isset($value[self::QUERY_PARAMETER_KEY]) || - !$this->isPropertyEnabled($property) || + !$this->isPropertyEnabled($property, $resourceClass) || !$this->isPropertyMapped($property, $resourceClass, true) || !$this->isNullableField($property, $resourceClass) ) { @@ -95,11 +95,11 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB $alias = 'o'; $field = $property; - if ($this->isPropertyNested($property)) { - list($alias, $field) = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator); + if ($this->isPropertyNested($property, $resourceClass)) { + list($alias, $field) = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass); } - $propertyParts = $this->splitPropertyParts($property); + $propertyParts = $this->splitPropertyParts($property, $resourceClass); $metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']); if ($metadata->hasAssociation($field)) { @@ -132,7 +132,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB */ protected function isNullableField(string $property, string $resourceClass): bool { - $propertyParts = $this->splitPropertyParts($property); + $propertyParts = $this->splitPropertyParts($property, $resourceClass); $metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']); $field = $propertyParts['field']; diff --git a/src/Bridge/Doctrine/Orm/Filter/NumericFilter.php b/src/Bridge/Doctrine/Orm/Filter/NumericFilter.php index c628f368726..0797e2c0515 100644 --- a/src/Bridge/Doctrine/Orm/Filter/NumericFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/NumericFilter.php @@ -60,7 +60,7 @@ public function getDescription(string $resourceClass): array if (!$this->isPropertyMapped($property, $resourceClass) || !$this->isNumericField($property, $resourceClass)) { continue; } - $propertyParts = $this->splitPropertyParts($property); + $propertyParts = $this->splitPropertyParts($property, $resourceClass); $metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']); $description[$property] = [ @@ -99,7 +99,7 @@ private function getType(string $doctrineType): string protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) { if ( - !$this->isPropertyEnabled($property) || + !$this->isPropertyEnabled($property, $resourceClass) || !$this->isPropertyMapped($property, $resourceClass) || !$this->isNumericField($property, $resourceClass) ) { @@ -117,8 +117,8 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB $alias = 'o'; $field = $property; - if ($this->isPropertyNested($property)) { - list($alias, $field) = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator); + if ($this->isPropertyNested($property, $resourceClass)) { + list($alias, $field) = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass); } $valueParameter = $queryNameGenerator->generateParameterName($field); @@ -137,7 +137,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB */ protected function isNumericField(string $property, string $resourceClass): bool { - $propertyParts = $this->splitPropertyParts($property); + $propertyParts = $this->splitPropertyParts($property, $resourceClass); $metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']); return isset(self::DOCTRINE_NUMERIC_TYPES[$metadata->getTypeOfField($propertyParts['field'])]); diff --git a/src/Bridge/Doctrine/Orm/Filter/OrderFilter.php b/src/Bridge/Doctrine/Orm/Filter/OrderFilter.php index f5c5e39bd91..3432866a0d8 100644 --- a/src/Bridge/Doctrine/Orm/Filter/OrderFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/OrderFilter.php @@ -105,7 +105,7 @@ public function getDescription(string $resourceClass): array */ protected function filterProperty(string $property, $direction, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) { - if (!$this->isPropertyEnabled($property) || !$this->isPropertyMapped($property, $resourceClass)) { + if (!$this->isPropertyEnabled($property, $resourceClass) || !$this->isPropertyMapped($property, $resourceClass)) { return; } @@ -122,8 +122,8 @@ protected function filterProperty(string $property, $direction, QueryBuilder $qu $alias = 'o'; $field = $property; - if ($this->isPropertyNested($property)) { - list($alias, $field) = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator); + if ($this->isPropertyNested($property, $resourceClass)) { + list($alias, $field) = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass); } if (null !== $nullsComparison = $this->properties[$property]['nulls_comparison'] ?? null) { @@ -141,7 +141,7 @@ protected function filterProperty(string $property, $direction, QueryBuilder $qu /** * {@inheritdoc} */ - protected function extractProperties(Request $request): array + protected function extractProperties(Request $request/*, string $resourceClass*/): array { return $request->query->get($this->orderParameterName, []); } diff --git a/src/Bridge/Doctrine/Orm/Filter/RangeFilter.php b/src/Bridge/Doctrine/Orm/Filter/RangeFilter.php index 7a6d181a01b..8686534f883 100644 --- a/src/Bridge/Doctrine/Orm/Filter/RangeFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/RangeFilter.php @@ -64,7 +64,7 @@ protected function filterProperty(string $property, $values, QueryBuilder $query { if ( !is_array($values) || - !$this->isPropertyEnabled($property) || + !$this->isPropertyEnabled($property, $resourceClass) || !$this->isPropertyMapped($property, $resourceClass) ) { return; @@ -73,8 +73,8 @@ protected function filterProperty(string $property, $values, QueryBuilder $query $alias = 'o'; $field = $property; - if ($this->isPropertyNested($property)) { - list($alias, $field) = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator); + if ($this->isPropertyNested($property, $resourceClass)) { + list($alias, $field) = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass); } foreach ($values as $operator => $value) { diff --git a/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php b/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php index 08352f9286f..55ff75c2e80 100644 --- a/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php @@ -84,8 +84,8 @@ public function getDescription(string $resourceClass): array continue; } - if ($this->isPropertyNested($property)) { - $propertyParts = $this->splitPropertyParts($property); + if ($this->isPropertyNested($property, $resourceClass)) { + $propertyParts = $this->splitPropertyParts($property, $resourceClass); $field = $propertyParts['field']; $metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']); } else { @@ -167,7 +167,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB { if ( null === $value || - !$this->isPropertyEnabled($property) || + !$this->isPropertyEnabled($property, $resourceClass) || !$this->isPropertyMapped($property, $resourceClass, true) ) { return; @@ -176,8 +176,8 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB $alias = 'o'; $field = $property; - if ($this->isPropertyNested($property)) { - list($alias, $field, $associations) = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator); + if ($this->isPropertyNested($property, $resourceClass)) { + list($alias, $field, $associations) = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass); $metadata = $this->getNestedMetadata($resourceClass, $associations); } else { $metadata = $this->getClassMetadata($resourceClass); diff --git a/src/Bridge/Doctrine/Orm/Util/QueryNameGenerator.php b/src/Bridge/Doctrine/Orm/Util/QueryNameGenerator.php index 44f2eab03db..e767716107b 100644 --- a/src/Bridge/Doctrine/Orm/Util/QueryNameGenerator.php +++ b/src/Bridge/Doctrine/Orm/Util/QueryNameGenerator.php @@ -38,6 +38,6 @@ public function generateJoinAlias(string $association): string */ public function generateParameterName(string $name): string { - return sprintf('%s_p%d', $name, $this->incrementedName++); + return sprintf('%s_p%d', str_replace('.', '_', $name), $this->incrementedName++); } } diff --git a/tests/Fixtures/TestBundle/Entity/EmbeddableDummy.php b/tests/Fixtures/TestBundle/Entity/EmbeddableDummy.php new file mode 100644 index 00000000000..dfc0eafe5c6 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/EmbeddableDummy.php @@ -0,0 +1,174 @@ + + * + * 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\Entity; + +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * Embeddable Dummy. + * + * @author Jordan Samouh + * + * @ORM\Embeddable + */ +class EmbeddableDummy +{ + /** + * @var string The dummy name + * + * @ORM\Column(nullable=true) + */ + private $dummyName; + + /** + * @var bool A dummy boolean + * + * @ORM\Column(type="boolean", nullable=true) + */ + public $dummyBoolean; + + /** + * @var \DateTime A dummy date + * + * @ORM\Column(type="datetime", nullable=true) + * @Assert\DateTime + */ + public $dummyDate; + + /** + * @var string A dummy float + * + * @ORM\Column(type="float", nullable=true) + */ + public $dummyFloat; + + /** + * @var string A dummy price + * + * @ORM\Column(type="decimal", precision=10, scale=2, nullable=true) + */ + public $dummyPrice; + + /** + * @ORM\Column(type="string", nullable=true) + * @Groups({"barcelona", "chicago"}) + */ + protected $symfony; + + public static function staticMethod() + { + } + + public function __construct() + { + } + + /** + * @return string + */ + public function getDummyName() + { + return $this->dummyName; + } + + /** + * @param string $dummyName + */ + public function setDummyName(string $dummyName) + { + $this->dummyName = $dummyName; + } + + /** + * @return bool + */ + public function isDummyBoolean() + { + return $this->dummyBoolean; + } + + /** + * @param bool $dummyBoolean + */ + public function setDummyBoolean(bool $dummyBoolean) + { + $this->dummyBoolean = $dummyBoolean; + } + + /** + * @return \DateTime + */ + public function getDummyDate() + { + return $this->dummyDate; + } + + /** + * @param \DateTime $dummyDate + */ + public function setDummyDate(\DateTime $dummyDate) + { + $this->dummyDate = $dummyDate; + } + + /** + * @return string + */ + public function getDummyFloat() + { + return $this->dummyFloat; + } + + /** + * @param string $dummyFloat + */ + public function setDummyFloat(string $dummyFloat) + { + $this->dummyFloat = $dummyFloat; + } + + /** + * @return string + */ + public function getDummyPrice() + { + return $this->dummyPrice; + } + + /** + * @param string $dummyPrice + */ + public function setDummyPrice(string $dummyPrice) + { + $this->dummyPrice = $dummyPrice; + } + + /** + * @return mixed + */ + public function getSymfony() + { + return $this->symfony; + } + + /** + * @param mixed $symfony + */ + public function setSymfony($symfony) + { + $this->symfony = $symfony; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/EmbeddedDummy.php b/tests/Fixtures/TestBundle/Entity/EmbeddedDummy.php new file mode 100644 index 00000000000..4f60eea01f8 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/EmbeddedDummy.php @@ -0,0 +1,145 @@ + + * + * 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\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * Embedded Dummy. + * + * @author Jordan Samouh + * + * @ApiResource(attributes={"filters"={"my_dummy.search", "my_dummy.order", "my_dummy.date", "my_dummy.range", "my_dummy.boolean", "my_dummy.numeric"}}) + * @ORM\Entity + */ +class EmbeddedDummy +{ + /** + * @var int The id + * + * @ORM\Column(type="integer") + * @ORM\Id + * @ORM\GeneratedValue(strategy="AUTO") + */ + private $id; + + /** + * @var string The dummy name + * + * @ORM\Column(nullable=true) + */ + private $name; + + /** + * @var \DateTime A dummy date + * + * @ORM\Column(type="datetime", nullable=true) + * @Assert\DateTime + */ + public $dummyDate; + + /** + * @var EmbeddableDummy + * + * @ORM\Embedded(class="EmbeddableDummy") + */ + public $embeddedDummy; + + /** + * @var RelatedDummy A related dummy + * + * @ORM\ManyToOne(targetEntity="RelatedDummy") + */ + public $relatedDummy; + + public static function staticMethod() + { + } + + public function __construct() + { + $this->embeddedDummy = new EmbeddableDummy(); + } + + public function getId() + { + return $this->id; + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @param string $name + */ + public function setName(string $name) + { + $this->name = $name; + } + + /** + * @return EmbeddableDummy + */ + public function getEmbeddedDummy(): EmbeddableDummy + { + return $this->embeddedDummy; + } + + /** + * @param EmbeddableDummy $embeddedDummy + */ + public function setEmbeddedDummy(EmbeddableDummy $embeddedDummy) + { + $this->embeddedDummy = $embeddedDummy; + } + + /** + * @return \DateTime + */ + public function getDummyDate() + { + return $this->dummyDate; + } + + /** + * @param \DateTime $dummyDate + */ + public function setDummyDate(\DateTime $dummyDate) + { + $this->dummyDate = $dummyDate; + } + + /** + * @return RelatedDummy + */ + public function getRelatedDummy() + { + return $this->relatedDummy; + } + + /** + * @param RelatedDummy $relatedDummy + */ + public function setRelatedDummy(RelatedDummy $relatedDummy) + { + $this->relatedDummy = $relatedDummy; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php index 89ea07c535a..588f6eda3d1 100644 --- a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php +++ b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php @@ -88,6 +88,14 @@ public function __construct() */ public $dummyBoolean; + /** + * @var EmbeddableDummy + * + * @ORM\Embedded(class="EmbeddableDummy") + * @Groups({"friends"}) + */ + public $embeddedDummy; + public function getId() { return $this->id; @@ -163,4 +171,20 @@ public function addRelatedToDummyFriend(RelatedToDummyFriend $relatedToDummyFrie { $this->relatedToDummyFriend->add($relatedToDummyFriend); } + + /** + * @return EmbeddableDummy + */ + public function getEmbeddedDummy() + { + return $this->embeddedDummy; + } + + /** + * @param EmbeddableDummy $embeddedDummy + */ + public function setEmbeddedDummy(EmbeddableDummy $embeddedDummy) + { + $this->embeddedDummy = $embeddedDummy; + } } diff --git a/tests/Fixtures/app/config/config.yml b/tests/Fixtures/app/config/config.yml index af4cabe0174..f096b06256b 100644 --- a/tests/Fixtures/app/config/config.yml +++ b/tests/Fixtures/app/config/config.yml @@ -93,18 +93,18 @@ 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', 'relatedDummies.name': 'start' } ] + arguments: [ { 'id': 'exact', 'name': 'partial', 'alias': 'start', 'description': 'word_start', 'relatedDummy.name': 'exact', 'relatedDummies': 'exact', 'dummy': 'ipartial', 'relatedDummies.name': 'start', 'embeddedDummy.dummyName': 'partial' } ] tags: [ { name: 'api_platform.filter', id: 'my_dummy.search' } ] # Tests if the id default to the service name, do not add id attributes here my_dummy.order: parent: 'api_platform.doctrine.orm.order_filter' - arguments: [ { 'id': ~, 'name': 'desc', 'relatedDummy.symfony': ~ } ] - tags: [ { name: 'api_platform.filter' } ] + arguments: [ { 'id': ~, 'name': 'desc', 'relatedDummy.name': ~, 'embeddedDummy.dummyName': 'desc', 'relatedDummy.symfony': ~ } ] + tags: [ { name: 'api_platform.filter', id: 'my_dummy.order' } ] app.my_dummy_resource.date_filter: parent: 'api_platform.doctrine.orm.date_filter' - arguments: [ { 'dummyDate': ~, 'relatedDummy.dummyDate': ~ } ] + arguments: [ { 'dummyDate': ~, 'relatedDummy.dummyDate': ~, 'embeddedDummy.dummyDate': ~ } ] tags: [ { name: 'api_platform.filter', id: 'my_dummy.date' } ] app.my_dummy_resource.range_filter: @@ -114,7 +114,7 @@ services: app.my_dummy_resource.boolean_filter: parent: 'api_platform.doctrine.orm.boolean_filter' - arguments: [ { 'dummyBoolean': ~ } ] + arguments: [ { 'dummyBoolean': ~, 'embeddedDummy.dummyBoolean': ~, 'relatedDummy.embeddedDummy.dummyBoolean': ~ } ] tags: [ { name: 'api_platform.filter', id: 'my_dummy.boolean' } ] app.my_dummy_resource.numeric_filter: