Skip to content

Commit bd361bb

Browse files
authored
feat(doctrine): add link factory (#5345)
This link factory decorates the one from Metadata. It automatically adds a Link with a toProperty if the Doctrine relation is a ManyToMany on the inverse side.
1 parent ee2ef7c commit bd361bb

File tree

9 files changed

+431
-0
lines changed

9 files changed

+431
-0
lines changed

features/graphql/collection.feature

+30
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,36 @@ Feature: GraphQL collection support
9090
And the JSON node "data.dummies.edges[2].node.name" should be equal to "Dummy #3"
9191
And the JSON node "data.dummies.edges[2].node.relatedDummies.edges[1].node.name" should be equal to "RelatedDummy23"
9292

93+
@createSchema
94+
Scenario: Retrieve a collection with a nested collection (inverse side) through a GraphQL query
95+
Given there is a video game with music groups
96+
When I send the following GraphQL request:
97+
"""
98+
{
99+
musicGroups {
100+
edges {
101+
node {
102+
name
103+
videoGames {
104+
edges {
105+
node {
106+
name
107+
}
108+
}
109+
}
110+
}
111+
}
112+
}
113+
}
114+
"""
115+
Then the response status code should be 200
116+
And the response should be in JSON
117+
And the header "Content-Type" should be equal to "application/json"
118+
And the JSON node "data.musicGroups.edges[0].node.name" should be equal to "Sum 41"
119+
And the JSON node "data.musicGroups.edges[0].node.videoGames.edges[0].node.name" should be equal to "Guitar Hero"
120+
And the JSON node "data.musicGroups.edges[1].node.name" should be equal to "Franz Ferdinand"
121+
And the JSON node "data.musicGroups.edges[1].node.videoGames.edges[0].node.name" should be equal to "Guitar Hero"
122+
93123
@createSchema
94124
Scenario: Retrieve a collection and an item through a GraphQL query
95125
Given there are 3 dummy objects with dummyDate
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Doctrine\Orm\Metadata\Resource;
15+
16+
use ApiPlatform\Api\ResourceClassResolverInterface;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\Link;
19+
use ApiPlatform\Metadata\Operation;
20+
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
21+
use ApiPlatform\Metadata\Resource\Factory\LinkFactoryInterface;
22+
use ApiPlatform\Metadata\Resource\Factory\PropertyLinkFactoryInterface;
23+
use Doctrine\ORM\EntityManagerInterface;
24+
use Doctrine\Persistence\ManagerRegistry;
25+
26+
/**
27+
* @internal
28+
*/
29+
final class DoctrineOrmLinkFactory implements LinkFactoryInterface, PropertyLinkFactoryInterface
30+
{
31+
public function __construct(private readonly ManagerRegistry $managerRegistry, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly ResourceClassResolverInterface $resourceClassResolver, private readonly LinkFactoryInterface&PropertyLinkFactoryInterface $linkFactory)
32+
{
33+
}
34+
35+
/**
36+
* {@inheritdoc}
37+
*/
38+
public function createLinkFromProperty(ApiResource|Operation $operation, string $property): Link
39+
{
40+
return $this->linkFactory->createLinkFromProperty($operation, $property);
41+
}
42+
43+
/**
44+
* {@inheritdoc}
45+
*/
46+
public function createLinksFromIdentifiers(ApiResource|Operation $operation): array
47+
{
48+
return $this->linkFactory->createLinksFromIdentifiers($operation);
49+
}
50+
51+
/**
52+
* {@inheritdoc}
53+
*/
54+
public function createLinksFromRelations(ApiResource|Operation $operation): array
55+
{
56+
$links = $this->linkFactory->createLinksFromRelations($operation);
57+
58+
$resourceClass = $operation->getClass();
59+
if (!($manager = $this->managerRegistry->getManagerForClass($resourceClass)) instanceof EntityManagerInterface) {
60+
return $links;
61+
}
62+
63+
foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $property) {
64+
$doctrineMetadata = $manager->getClassMetadata($resourceClass);
65+
if (!$doctrineMetadata->hasAssociation($property)) {
66+
continue;
67+
}
68+
69+
$relationClass = $doctrineMetadata->getAssociationTargetClass($property);
70+
if (!($mappedBy = $doctrineMetadata->getAssociationMappedByTargetField($property)) || !$this->resourceClassResolver->isResourceClass($relationClass)) {
71+
continue;
72+
}
73+
74+
$link = new Link(fromProperty: $property, toProperty: $mappedBy, fromClass: $resourceClass, toClass: $relationClass);
75+
$link = $this->completeLink($link);
76+
$links[] = $link;
77+
}
78+
79+
return $links;
80+
}
81+
82+
/**
83+
* {@inheritdoc}
84+
*/
85+
public function createLinksFromAttributes(ApiResource|Operation $operation): array
86+
{
87+
return $this->linkFactory->createLinksFromAttributes($operation);
88+
}
89+
90+
/**
91+
* {@inheritdoc}
92+
*/
93+
public function completeLink(Link $link): Link
94+
{
95+
return $this->linkFactory->completeLink($link);
96+
}
97+
}

src/Symfony/Bundle/Resources/config/doctrine_orm.xml

+7
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,13 @@
167167
<argument type="service" id="api_platform.doctrine.orm.metadata.resource.metadata_collection_factory.inner" />
168168
</service>
169169

170+
<service id="api_platform.doctrine.orm.metadata.resource.link_factory" class="ApiPlatform\Doctrine\Orm\Metadata\Resource\DoctrineOrmLinkFactory" decorates="api_platform.metadata.resource.link_factory" decoration-priority="40">
171+
<argument type="service" id="doctrine" />
172+
<argument type="service" id="api_platform.metadata.property.name_collection_factory" />
173+
<argument type="service" id="api_platform.resource_class_resolver" />
174+
<argument type="service" id="api_platform.doctrine.orm.metadata.resource.link_factory.inner" />
175+
</service>
176+
170177
</services>
171178

172179
</container>

tests/Behat/DoctrineContext.php

+35
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
use ApiPlatform\Tests\Fixtures\TestBundle\Document\MaxDepthDummy as MaxDepthDummyDocument;
6666
use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsDummy as MultiRelationsDummyDocument;
6767
use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsRelatedDummy as MultiRelationsRelatedDummyDocument;
68+
use ApiPlatform\Tests\Fixtures\TestBundle\Document\MusicGroup as MusicGroupDocument;
6869
use ApiPlatform\Tests\Fixtures\TestBundle\Document\NetworkPathDummy as NetworkPathDummyDocument;
6970
use ApiPlatform\Tests\Fixtures\TestBundle\Document\NetworkPathRelationDummy as NetworkPathRelationDummyDocument;
7071
use ApiPlatform\Tests\Fixtures\TestBundle\Document\Order as OrderDocument;
@@ -88,6 +89,7 @@
8889
use ApiPlatform\Tests\Fixtures\TestBundle\Document\ThirdLevel as ThirdLevelDocument;
8990
use ApiPlatform\Tests\Fixtures\TestBundle\Document\UrlEncodedId as UrlEncodedIdDocument;
9091
use ApiPlatform\Tests\Fixtures\TestBundle\Document\User as UserDocument;
92+
use ApiPlatform\Tests\Fixtures\TestBundle\Document\VideoGame as VideoGameDocument;
9193
use ApiPlatform\Tests\Fixtures\TestBundle\Document\WithJsonDummy as WithJsonDummyDocument;
9294
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\AbsoluteUrlDummy;
9395
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\AbsoluteUrlRelationDummy;
@@ -142,6 +144,7 @@
142144
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MaxDepthDummy;
143145
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsDummy;
144146
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsRelatedDummy;
147+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MusicGroup;
145148
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NetworkPathDummy;
146149
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NetworkPathRelationDummy;
147150
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Order;
@@ -172,6 +175,7 @@
172175
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UrlEncodedId;
173176
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\User;
174177
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UuidIdentifierDummy;
178+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VideoGame;
175179
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\WithJsonDummy;
176180
use Behat\Behat\Context\Context;
177181
use Behat\Gherkin\Node\PyStringNode;
@@ -2048,6 +2052,27 @@ public function thereAreSeparatedEntities(int $nb): void
20482052
$this->manager->flush();
20492053
}
20502054

2055+
/**
2056+
* @Given there is a video game with music groups
2057+
*/
2058+
public function thereAreVideoGamesWithMusicGroups(): void
2059+
{
2060+
$sum41 = $this->buildMusicGroup();
2061+
$sum41->name = 'Sum 41';
2062+
$this->manager->persist($sum41);
2063+
$franz = $this->buildMusicGroup();
2064+
$franz->name = 'Franz Ferdinand';
2065+
$this->manager->persist($franz);
2066+
2067+
$videoGame = $this->buildVideoGame();
2068+
$videoGame->name = 'Guitar Hero';
2069+
$videoGame->addMusicGroup($sum41);
2070+
$videoGame->addMusicGroup($franz);
2071+
$this->manager->persist($videoGame);
2072+
2073+
$this->manager->flush();
2074+
}
2075+
20512076
private function isOrm(): bool
20522077
{
20532078
return null !== $this->schemaTool;
@@ -2382,4 +2407,14 @@ private function buildMultiRelationsRelatedDummy(): MultiRelationsRelatedDummy|M
23822407
{
23832408
return $this->isOrm() ? new MultiRelationsRelatedDummy() : new MultiRelationsRelatedDummyDocument();
23842409
}
2410+
2411+
private function buildMusicGroup(): MusicGroup|MusicGroupDocument
2412+
{
2413+
return $this->isOrm() ? new MusicGroup() : new MusicGroupDocument();
2414+
}
2415+
2416+
private function buildVideoGame(): VideoGame|VideoGameDocument
2417+
{
2418+
return $this->isOrm() ? new VideoGame() : new VideoGameDocument();
2419+
}
23852420
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Doctrine\Orm\Metadata\Resource;
15+
16+
use ApiPlatform\Api\ResourceClassResolverInterface;
17+
use ApiPlatform\Doctrine\Orm\Metadata\Resource\DoctrineOrmLinkFactory;
18+
use ApiPlatform\Metadata\ApiResource;
19+
use ApiPlatform\Metadata\Get;
20+
use ApiPlatform\Metadata\Link;
21+
use ApiPlatform\Metadata\Operation;
22+
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
23+
use ApiPlatform\Metadata\Property\PropertyNameCollection;
24+
use ApiPlatform\Metadata\Resource\Factory\LinkFactoryInterface;
25+
use ApiPlatform\Metadata\Resource\Factory\PropertyLinkFactoryInterface;
26+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy;
27+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy;
28+
use ApiPlatform\Tests\Fixtures\TestBundle\Model\Car;
29+
use Doctrine\ORM\EntityManagerInterface;
30+
use Doctrine\Persistence\ManagerRegistry;
31+
use Doctrine\Persistence\Mapping\ClassMetadata;
32+
use PHPUnit\Framework\TestCase;
33+
use Prophecy\PhpUnit\ProphecyTrait;
34+
35+
final class DoctrineOrmLinkFactoryTest extends TestCase
36+
{
37+
use ProphecyTrait;
38+
39+
public function testCreateLinksFromRelations(): void
40+
{
41+
$class = Dummy::class;
42+
$operation = (new Get())->withClass($class);
43+
44+
$classMetadataProphecy = $this->prophesize(ClassMetadata::class);
45+
$classMetadataProphecy->hasAssociation('name')->willReturn(false);
46+
$classMetadataProphecy->hasAssociation('relatedNonResource')->willReturn(true);
47+
$classMetadataProphecy->hasAssociation('relatedDummy')->willReturn(true);
48+
$classMetadataProphecy->hasAssociation('relatedDummies')->willReturn(true);
49+
$classMetadataProphecy->getAssociationTargetClass('relatedNonResource')->willReturn(Car::class);
50+
$classMetadataProphecy->getAssociationTargetClass('relatedDummy')->willReturn(RelatedDummy::class);
51+
$classMetadataProphecy->getAssociationTargetClass('relatedDummies')->willReturn(RelatedDummy::class);
52+
$classMetadataProphecy->getAssociationMappedByTargetField('relatedNonResource')->willReturn('dummies');
53+
$classMetadataProphecy->getAssociationMappedByTargetField('relatedDummy')->willReturn(null);
54+
$classMetadataProphecy->getAssociationMappedByTargetField('relatedDummies')->willReturn('dummies');
55+
$entityManagerProphecy = $this->prophesize(EntityManagerInterface::class);
56+
$entityManagerProphecy->getClassMetadata($class)->willReturn($classMetadataProphecy->reveal());
57+
$managerRegistryProphecy = $this->prophesize(ManagerRegistry::class);
58+
$managerRegistryProphecy->getManagerForClass($class)->willReturn($entityManagerProphecy->reveal());
59+
$propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class);
60+
$propertyNameCollectionFactoryProphecy->create($class)->willReturn(new PropertyNameCollection(['name', 'relatedNonResource', 'relatedDummy', 'relatedDummies']));
61+
$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
62+
$resourceClassResolverProphecy->isResourceClass(Car::class)->willReturn(false);
63+
$resourceClassResolverProphecy->isResourceClass(RelatedDummy::class)->willReturn(true);
64+
65+
$doctrineOrmLinkFactory = new DoctrineOrmLinkFactory($managerRegistryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $resourceClassResolverProphecy->reveal(), new LinkFactoryStub());
66+
67+
self::assertEquals([
68+
new Link(
69+
fromProperty: 'relatedDummies',
70+
toProperty: 'dummies',
71+
fromClass: Dummy::class,
72+
toClass: RelatedDummy::class,
73+
),
74+
], $doctrineOrmLinkFactory->createLinksFromRelations($operation));
75+
}
76+
}
77+
78+
class LinkFactoryStub implements LinkFactoryInterface, PropertyLinkFactoryInterface
79+
{
80+
public function createLinkFromProperty(ApiResource|Operation $operation, string $property): Link
81+
{
82+
return new Link();
83+
}
84+
85+
public function createLinksFromIdentifiers(ApiResource|Operation $operation): array
86+
{
87+
return [];
88+
}
89+
90+
public function createLinksFromRelations(ApiResource|Operation $operation): array
91+
{
92+
return [];
93+
}
94+
95+
public function createLinksFromAttributes(ApiResource|Operation $operation): array
96+
{
97+
return [];
98+
}
99+
100+
public function completeLink(Link $link): Link
101+
{
102+
return $link;
103+
}
104+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Document;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use Doctrine\Common\Collections\ArrayCollection;
18+
use Doctrine\Common\Collections\Collection;
19+
use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
20+
21+
#[ApiResource]
22+
#[ODM\Document]
23+
class MusicGroup
24+
{
25+
#[ODM\Id(strategy: 'INCREMENT', type: 'int')]
26+
private ?int $id = null;
27+
28+
#[ODM\Field]
29+
public string $name;
30+
31+
/** @var Collection<VideoGame> */
32+
#[ODM\ReferenceMany(targetDocument: VideoGame::class, mappedBy: 'musicGroups')]
33+
private Collection $videoGames;
34+
35+
public function __construct()
36+
{
37+
$this->videoGames = new ArrayCollection();
38+
}
39+
40+
public function getId(): ?int
41+
{
42+
return $this->id;
43+
}
44+
45+
/** @return Collection<VideoGame> */
46+
public function getVideoGames(): Collection
47+
{
48+
return $this->videoGames;
49+
}
50+
51+
public function addVideoGame(VideoGame $videoGame): void
52+
{
53+
$this->videoGames[] = $videoGame;
54+
}
55+
}

0 commit comments

Comments
 (0)