diff --git a/features/graphql/query.feature b/features/graphql/query.feature index 45f546066fc..4463a717b87 100644 --- a/features/graphql/query.feature +++ b/features/graphql/query.feature @@ -20,6 +20,54 @@ Feature: GraphQL query support And the JSON node "data.dummy.name" should be equal to "Dummy #1" And the JSON node "data.dummy.name_converted" should be equal to "Converted 1" + @createSchema + Scenario: Retrieve an item with different relations to the same resource + Given there are 2 multiRelationsDummy objects having each a manyToOneRelation, 2 manyToManyRelations and 3 oneToManyRelations + When I send the following GraphQL request: + """ + { + multiRelationsDummy(id: "/multi_relations_dummies/2") { + id + name + manyToOneRelation { + id + name + } + manyToManyRelations { + edges{ + node { + id + name + } + } + } + oneToManyRelations { + edges{ + node { + id + name + } + } + } + } + } + """ + 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/json" + And the JSON node "data.multiRelationsDummy.id" should be equal to "/multi_relations_dummies/2" + And the JSON node "data.multiRelationsDummy.name" should be equal to "Dummy #2" + And the JSON node "data.multiRelationsDummy.manyToOneRelation.id" should not be null + And the JSON node "data.multiRelationsDummy.manyToOneRelation.name" should be equal to "RelatedManyToOneDummy #2" + And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges" should have 2 element + And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[1].node.id" should not be null + And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[0].node.name" should be equal to "RelatedManyToManyDummy12" + And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[1].node.name" should be equal to "RelatedManyToManyDummy22" + And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges" should have 3 element + And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[1].node.id" should not be null + And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[0].node.name" should be equal to "RelatedOneToManyDummy12" + And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[2].node.name" should be equal to "RelatedOneToManyDummy32" + @createSchema Scenario: Retrieve a Relay Node Given there are 2 dummy objects with relatedDummy diff --git a/src/Doctrine/Common/State/LinksHandlerTrait.php b/src/Doctrine/Common/State/LinksHandlerTrait.php index badd9d3df50..38f52beba68 100644 --- a/src/Doctrine/Common/State/LinksHandlerTrait.php +++ b/src/Doctrine/Common/State/LinksHandlerTrait.php @@ -34,14 +34,20 @@ private function getLinks(string $resourceClass, Operation $operation, array $co return $links; } - $newLinks = []; + $newLink = null; + $linkProperty = $context['linkProperty'] ?? null; foreach ($links as $link) { - if ($linkClass === $link->getFromClass()) { - $newLinks[] = $link; + if ($linkClass === $link->getFromClass() && $linkProperty === $link->getFromProperty()) { + $newLink = $link; + break; } } + if ($newLink) { + return [$newLink]; + } + // Using GraphQL, it's possible that we won't find a GraphQL Operation of the same type (e.g. it is disabled). try { $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($linkClass); @@ -62,16 +68,17 @@ private function getLinks(string $resourceClass, Operation $operation, array $co } foreach ($this->getOperationLinks($linkedOperation ?? null) as $link) { - if ($resourceClass === $link->getToClass()) { - $newLinks[] = $link; + if ($resourceClass === $link->getToClass() && $linkProperty === $link->getFromProperty()) { + $newLink = $link; + break; } } - if (!$newLinks) { + if (!$newLink) { throw new RuntimeException(sprintf('The class "%s" cannot be retrieved from "%s".', $resourceClass, $linkClass)); } - return $newLinks; + return [$newLink]; } private function getIdentifierValue(array &$identifiers, string $name = null): mixed diff --git a/src/GraphQl/Resolver/Stage/ReadStage.php b/src/GraphQl/Resolver/Stage/ReadStage.php index b5c7d119030..527b7aea493 100644 --- a/src/GraphQl/Resolver/Stage/ReadStage.php +++ b/src/GraphQl/Resolver/Stage/ReadStage.php @@ -83,6 +83,7 @@ public function __invoke(?string $resourceClass, ?string $rootClass, Operation $ if (isset($source[$info->fieldName], $source[ItemNormalizer::ITEM_IDENTIFIERS_KEY], $source[ItemNormalizer::ITEM_RESOURCE_CLASS_KEY])) { $uriVariables = $source[ItemNormalizer::ITEM_IDENTIFIERS_KEY]; $normalizationContext['linkClass'] = $source[ItemNormalizer::ITEM_RESOURCE_CLASS_KEY]; + $normalizationContext['linkProperty'] = $info->fieldName; } return $this->provider->provide($operation, $uriVariables, $normalizationContext); diff --git a/src/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactory.php index e8e7b9990f0..d2f3afcae72 100644 --- a/src/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactory.php @@ -68,16 +68,16 @@ private function mergeLinks(array $links, array $toMergeLinks): array { $classLinks = []; foreach ($links as $link) { - $classLinks[$link->getToClass()] = $link; + $classLinks[$link->getToClass().'#'.$link->getFromProperty()] = $link; } foreach ($toMergeLinks as $link) { - if (isset($classLinks[$link->getToClass()])) { - $classLinks[$link->getToClass()] = $classLinks[$link->getToClass()]->withLink($link); + if (null !== $prevLink = $classLinks[$link->getToClass().'#'.$link->getFromProperty()] ?? null) { + $classLinks[$link->getToClass().'#'.$link->getFromProperty()] = $prevLink->withLink($link); continue; } - $classLinks[$link->getToClass()] = $link; + $classLinks[$link->getToClass().'#'.$link->getFromProperty()] = $link; } return array_values($classLinks); diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php index 37aaa67205b..7237a47359e 100644 --- a/tests/Behat/DoctrineContext.php +++ b/tests/Behat/DoctrineContext.php @@ -63,6 +63,8 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Document\InitializeInput as InitializeInputDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\IriOnlyDummy as IriOnlyDummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\MaxDepthDummy as MaxDepthDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsDummy as MultiRelationsDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsRelatedDummy as MultiRelationsRelatedDummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\NetworkPathDummy as NetworkPathDummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\NetworkPathRelationDummy as NetworkPathRelationDummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\Order as OrderDocument; @@ -138,6 +140,8 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\InternalUser; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\IriOnlyDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MaxDepthDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsRelatedDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NetworkPathDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NetworkPathRelationDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Order; @@ -759,6 +763,42 @@ public function thereAreDummyObjectsWithRelatedDummies(int $nb, int $nbrelated): $this->manager->flush(); } + /** + * @Given there are :nb multiRelationsDummy objects having each a manyToOneRelation, :nbmtmr manyToManyRelations and :nbotmr oneToManyRelations + */ + public function thereAreMultiRelationsDummyObjectsHavingEachAManyToOneRelationManyToManyRelationsAndOneToManyRelations(int $nb, int $nbmtmr, int $nbotmr): void + { + for ($i = 1; $i <= $nb; ++$i) { + $relatedDummy = $this->buildMultiRelationsRelatedDummy(); + $relatedDummy->name = 'RelatedManyToOneDummy #'.$i; + + $dummy = $this->buildMultiRelationsDummy(); + $dummy->name = 'Dummy #'.$i; + $dummy->setManyToOneRelation($relatedDummy); + + for ($j = 1; $j <= $nbmtmr; ++$j) { + $manyToManyItem = $this->buildMultiRelationsRelatedDummy(); + $manyToManyItem->name = 'RelatedManyToManyDummy'.$j.$i; + $this->manager->persist($manyToManyItem); + + $dummy->addManyToManyRelation($manyToManyItem); + } + + for ($j = 1; $j <= $nbotmr; ++$j) { + $oneToManyItem = $this->buildMultiRelationsRelatedDummy(); + $oneToManyItem->name = 'RelatedOneToManyDummy'.$j.$i; + $oneToManyItem->setOneToManyRelation($dummy); + $this->manager->persist($oneToManyItem); + + $dummy->addOneToManyRelation($oneToManyItem); + } + + $this->manager->persist($relatedDummy); + $this->manager->persist($dummy); + } + $this->manager->flush(); + } + /** * @Given there are :nb dummy objects with dummyDate * @Given there is :nb dummy object with dummyDate @@ -2300,4 +2340,14 @@ private function buildPayment(string $amount): Payment|PaymentDocument { return $this->isOrm() ? new Payment($amount) : new PaymentDocument($amount); } + + private function buildMultiRelationsDummy(): MultiRelationsDummy|MultiRelationsDummyDocument + { + return $this->isOrm() ? new MultiRelationsDummy() : new MultiRelationsDummyDocument(); + } + + private function buildMultiRelationsRelatedDummy(): MultiRelationsRelatedDummy|MultiRelationsRelatedDummyDocument + { + return $this->isOrm() ? new MultiRelationsRelatedDummy() : new MultiRelationsRelatedDummyDocument(); + } } diff --git a/tests/Fixtures/TestBundle/Document/MultiRelationsDummy.php b/tests/Fixtures/TestBundle/Document/MultiRelationsDummy.php new file mode 100644 index 00000000000..dd70130d1a9 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/MultiRelationsDummy.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Dummy using different kind of relations to the same resource. + * + * @author Thomas Helmrich + */ +#[ApiResource(graphQlOperations: [new QueryCollection(), new Query()])] +#[ODM\Document] +class MultiRelationsDummy +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + private ?int $id = null; + + #[ODM\Field(type: 'string')] + public string $name; + + #[ODM\ReferenceOne(targetDocument: MultiRelationsRelatedDummy::class, storeAs: 'id', nullable: true)] + public ?MultiRelationsRelatedDummy $manyToOneRelation = null; + + /** @var Collection */ + #[ODM\ReferenceMany(targetDocument: MultiRelationsRelatedDummy::class, storeAs: 'id', nullable: true)] + public Collection $manyToManyRelations; + + /** @var Collection */ + #[ODM\ReferenceMany(targetDocument: MultiRelationsRelatedDummy::class, mappedBy: 'oneToManyRelation', storeAs: 'id')] + public Collection $oneToManyRelations; + + public function __construct() + { + $this->manyToManyRelations = new ArrayCollection(); + $this->oneToManyRelations = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getManyToOneRelation(): ?MultiRelationsRelatedDummy + { + return $this->manyToOneRelation; + } + + public function setManyToOneRelation(?MultiRelationsRelatedDummy $relatedMultiUsedDummy): void + { + $this->manyToOneRelation = $relatedMultiUsedDummy; + } + + public function addManyToManyRelation(MultiRelationsRelatedDummy $relatedMultiUsedDummy): void + { + $this->manyToManyRelations->add($relatedMultiUsedDummy); + } + + public function addOneToManyRelation(MultiRelationsRelatedDummy $relatedMultiUsedDummy): void + { + $this->oneToManyRelations->add($relatedMultiUsedDummy); + } +} diff --git a/tests/Fixtures/TestBundle/Document/MultiRelationsRelatedDummy.php b/tests/Fixtures/TestBundle/Document/MultiRelationsRelatedDummy.php new file mode 100644 index 00000000000..8794d5f4867 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/MultiRelationsRelatedDummy.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Dummy used in different kind of relations in the same resource. + * + * @author Thomas Helmrich + */ +#[ApiResource(graphQlOperations: [new QueryCollection(), new Query()])] +#[ODM\Document] +class MultiRelationsRelatedDummy +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + private ?int $id = null; + + #[ODM\Field(type: 'string', nullable: true)] + public ?string $name; + + #[ODM\ReferenceOne(targetDocument: MultiRelationsDummy::class, inversedBy: 'oneToManyRelations', nullable: true, storeAs: 'id')] + private ?MultiRelationsDummy $oneToManyRelation; + + public function getId(): ?int + { + return $this->id; + } + + public function getOneToManyRelation(): ?MultiRelationsDummy + { + return $this->oneToManyRelation; + } + + public function setOneToManyRelation(?MultiRelationsDummy $oneToManyRelation): void + { + $this->oneToManyRelation = $oneToManyRelation; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/MultiRelationsDummy.php b/tests/Fixtures/TestBundle/Entity/MultiRelationsDummy.php new file mode 100644 index 00000000000..14e9d8a0f8b --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/MultiRelationsDummy.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +/** + * Dummy using different kind of relations to the same resource. + * + * @author Thomas Helmrich + */ +#[ApiResource(graphQlOperations: [new QueryCollection(), new Query()])] +#[ORM\Entity] +class MultiRelationsDummy +{ + #[ORM\Column(type: 'integer', nullable: true)] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column] + public string $name; + + #[ORM\ManyToOne(targetEntity: MultiRelationsRelatedDummy::class)] + public ?MultiRelationsRelatedDummy $manyToOneRelation = null; + + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: MultiRelationsRelatedDummy::class)] + public Collection $manyToManyRelations; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: MultiRelationsRelatedDummy::class, mappedBy: 'oneToManyRelation')] + public Collection $oneToManyRelations; + + public function __construct() + { + $this->manyToManyRelations = new ArrayCollection(); + $this->oneToManyRelations = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getManyToOneRelation(): ?MultiRelationsRelatedDummy + { + return $this->manyToOneRelation; + } + + public function setManyToOneRelation(?MultiRelationsRelatedDummy $relatedMultiUsedDummy): void + { + $this->manyToOneRelation = $relatedMultiUsedDummy; + } + + public function addManyToManyRelation(MultiRelationsRelatedDummy $relatedMultiUsedDummy): void + { + $this->manyToManyRelations->add($relatedMultiUsedDummy); + } + + public function addOneToManyRelation(MultiRelationsRelatedDummy $relatedMultiUsedDummy): void + { + $this->oneToManyRelations->add($relatedMultiUsedDummy); + } +} diff --git a/tests/Fixtures/TestBundle/Entity/MultiRelationsRelatedDummy.php b/tests/Fixtures/TestBundle/Entity/MultiRelationsRelatedDummy.php new file mode 100644 index 00000000000..ba462ce4b56 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/MultiRelationsRelatedDummy.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use Doctrine\ORM\Mapping as ORM; + +/** + * Dummy used in different kind of relations in the same resource. + * + * @author Thomas Helmrich + */ +#[ApiResource(graphQlOperations: [new QueryCollection(), new Query()])] +#[ORM\Entity] +class MultiRelationsRelatedDummy +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column(nullable: true)] + public ?string $name; + + #[ORM\ManyToOne(targetEntity: MultiRelationsDummy::class, inversedBy: 'oneToManyRelations')] + private ?MultiRelationsDummy $oneToManyRelation = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getOneToManyRelation(): ?MultiRelationsDummy + { + return $this->oneToManyRelation; + } + + public function setOneToManyRelation(?MultiRelationsDummy $oneToManyRelation): void + { + $this->oneToManyRelation = $oneToManyRelation; + } +} diff --git a/tests/GraphQl/Resolver/Stage/ReadStageTest.php b/tests/GraphQl/Resolver/Stage/ReadStageTest.php index 95c53841691..1022cf8347a 100644 --- a/tests/GraphQl/Resolver/Stage/ReadStageTest.php +++ b/tests/GraphQl/Resolver/Stage/ReadStageTest.php @@ -199,7 +199,7 @@ public function testApplyCollection(array $args, ?string $rootClass, ?array $sou $this->serializerContextBuilderProphecy->create($resourceClass, $operation, $context, true)->shouldBeCalled()->willReturn($normalizationContext); $this->providerProphecy->provide($operation, [], $normalizationContext + ['filters' => $expectedFilters])->willReturn([]); - $this->providerProphecy->provide($operation, ['id' => 3], $normalizationContext + ['filters' => $expectedFilters, 'linkClass' => 'myResource'])->willReturn(['resource']); + $this->providerProphecy->provide($operation, ['id' => 3], $normalizationContext + ['filters' => $expectedFilters, 'linkClass' => 'myResource', 'linkProperty' => 'resource'])->willReturn(['resource']); $result = ($this->readStage)($resourceClass, $rootClass, $operation, $context); diff --git a/tests/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactoryTest.php b/tests/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactoryTest.php index 174dcb1442b..790bc52fe4d 100644 --- a/tests/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactoryTest.php +++ b/tests/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactoryTest.php @@ -84,6 +84,7 @@ class: AttributeResource::class, class: AttributeResource::class, graphQlOperations: [ 'item_query' => (new Query(shortName: 'AttributeResource', class: AttributeResource::class))->withLinks([ + (new Link())->withFromProperty('foo')->withFromClass(AttributeResource::class)->withToClass(Dummy::class)->withIdentifiers(['id']), (new Link())->withFromProperty('foo2')->withFromClass(AttributeResource::class)->withToClass(Dummy::class)->withIdentifiers(['id']), (new Link())->withFromProperty('bar')->withFromClass(AttributeResource::class)->withToClass(RelatedDummy::class)->withIdentifiers(['id']), ]),