Skip to content

Commit 2decc7b

Browse files
committed
feat: link implementation
1 parent 2625c81 commit 2decc7b

37 files changed

+1101
-461
lines changed

docs/adr/0004-link.md

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# Link
2+
3+
* Status: accepted
4+
* Deciders: @dunglas, @soyuka, @alanpoulain
5+
6+
Implementation: [#4536][pull/4536]
7+
8+
## Context and Problem Statement
9+
10+
The [URI Variables](0003-uri-variables.md) ADR introduces a new `UriVariable` POPO.
11+
In GraphQL, having URI variables make no sense: this object needs either an alias or needs to be named differently.
12+
13+
## Considered Options
14+
15+
* Create a `Traverser` alias for GraphQL.
16+
* Rename `UriVariable` to `Link`.
17+
18+
## Decision Outcome
19+
20+
We chose to rename `UriVariable` to `Link` in order to simplify the codebase.
21+
However the `uriVariables` parameter in the REST operations will not be renamed since it makes sense to have this name.
22+
GraphQL operations don't need to have links at the operation level, a `Link` attribute on the property will be used instead if necessary (the main use case is when a `toProperty` is necessary).
23+
24+
To follow this renaming, the properties in `Link` are also renamed:
25+
- `targetClass` becomes `fromClass`
26+
- `inverseProperty` becomes `fromProperty`
27+
- `property` becomes `toProperty`
28+
29+
New properties are also necessary:
30+
- `toClass` for GraphQL because GraphQL needs to find the right `Link`
31+
- `expandedValue` for REST in order to convert an URI variable to the corresponding route part (for instance in the case of the URI template `/questions/{questionId}/{questionAnswer}/related_questions`, the expanded value for `questionAnswer` could be `answer`)
32+
33+
### Classical Example
34+
35+
```php
36+
<?php
37+
38+
#[Query]
39+
#[Get]
40+
class Company
41+
{
42+
public $id;
43+
44+
#[ORM\OneToMany(targetEntity: Employee::class, mappedBy: 'company')]
45+
// will automatically create:
46+
#[Link(fromClass: Company::class, fromProperty: 'employees')]
47+
/** @var Employee[] */
48+
public iterable $employees;
49+
}
50+
```
51+
52+
```php
53+
<?php
54+
55+
#[Query]
56+
#[GetCollection('/companies/{companyId}/employees', uriVariables: [
57+
'companyId' => new Link(
58+
fromClass: Company::class,
59+
fromProperty: 'employees'
60+
)
61+
])]
62+
class Employee
63+
{
64+
public $id;
65+
66+
#[ORM\ManyToOne(targetEntity: Company::class, inversedBy: 'employees')]
67+
public Company $company;
68+
}
69+
```
70+
71+
The GraphQL query equivalent to a `GET` to `/companies/2/employees` can now be done:
72+
73+
```graphql
74+
{
75+
companies(id: "/companies/2") {
76+
employees {
77+
edges {
78+
node {
79+
id
80+
}
81+
}
82+
}
83+
}
84+
}
85+
```
86+
87+
### Inverted Example
88+
89+
In this example, the relation between the employee and the company is only hold by the employee.
90+
91+
```php
92+
<?php
93+
94+
#[Query]
95+
#[GetCollection('/companies/{companyId}/employees', uriVariables: [
96+
'companyId' => new Link(
97+
fromClass: Company::class,
98+
toProperty: 'company'
99+
)
100+
])]
101+
class Employee
102+
{
103+
public $id;
104+
105+
#[ORM\ManyToMany(targetEntity: Company::class)]
106+
#[ORM\JoinTable(name: 'employees_companies')]
107+
#[ORM\JoinColumn(name: 'employee_id', referencedColumnName: 'id')]
108+
#[ORM\InverseJoinColumn(name: 'company_id', referencedColumnName: 'id', unique: true)]
109+
public Company $company;
110+
}
111+
```
112+
113+
```php
114+
<?php
115+
116+
#[Query]
117+
#[Get]
118+
class Company
119+
{
120+
public $id;
121+
122+
#[Link('company')]
123+
// equivalent to:
124+
#[Link(fromClass: Company::class, toClass: Employee::class, toProperty: 'company')]
125+
/** @var Employee[] */
126+
public iterable $employees;
127+
}
128+
```
129+
130+
The GraphQL query equivalent to a `GET` to `/companies/2/employees` can now be done:
131+
132+
```graphql
133+
{
134+
companies(id: "/companies/2") {
135+
employees {
136+
edges {
137+
node {
138+
id
139+
}
140+
}
141+
}
142+
}
143+
}
144+
```
145+
146+
[pull/4536]: https://github.com/api-platform/core/pull/4536 "Link implementation"

src/Api/IdentifiersExtractor.php

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
2020
use ApiPlatform\Core\Util\ResourceClassInfoTrait;
2121
use ApiPlatform\Exception\RuntimeException;
22+
use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation;
2223
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2324
use Symfony\Component\PropertyAccess\PropertyAccess;
2425
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
@@ -56,18 +57,19 @@ public function getIdentifiersFromItem($item, string $operationName = null, arra
5657
$resourceClass = $this->getResourceClass($item, true);
5758
$operation = $context['operation'] ?? $this->resourceMetadataFactory->create($resourceClass)->getOperation($operationName);
5859

59-
foreach ($operation->getUriVariables() ?? [] as $parameterName => $uriVariableDefinition) {
60-
if (1 < \count($uriVariableDefinition->getIdentifiers())) {
60+
$links = $operation instanceof GraphQlOperation ? $operation->getLinks() : $operation->getUriVariables();
61+
foreach ($links ?? [] as $link) {
62+
if (1 < \count($link->getIdentifiers())) {
6163
$compositeIdentifiers = [];
62-
foreach ($uriVariableDefinition->getIdentifiers() as $identifier) {
63-
$compositeIdentifiers[$identifier] = $this->getIdentifierValue($item, $uriVariableDefinition->getTargetClass() ?? $resourceClass, $identifier, $parameterName);
64+
foreach ($link->getIdentifiers() as $identifier) {
65+
$compositeIdentifiers[$identifier] = $this->getIdentifierValue($item, $link->getFromClass() ?? $resourceClass, $identifier, $link->getParameterName());
6466
}
6567

66-
$identifiers[($operation->getExtraProperties()['is_legacy_resource_metadata'] ?? false) ? 'id' : $parameterName] = CompositeIdentifierParser::stringify($compositeIdentifiers);
68+
$identifiers[($operation->getExtraProperties()['is_legacy_resource_metadata'] ?? false) ? 'id' : $link->getParameterName()] = CompositeIdentifierParser::stringify($compositeIdentifiers);
6769
continue;
6870
}
6971

70-
$identifiers[$parameterName] = $this->getIdentifierValue($item, $uriVariableDefinition->getTargetClass(), $uriVariableDefinition->getIdentifiers()[0], $parameterName);
72+
$identifiers[$link->getParameterName()] = $this->getIdentifierValue($item, $link->getFromClass(), $link->getIdentifiers()[0], $link->getParameterName());
7173
}
7274

7375
return $identifiers;
@@ -126,9 +128,9 @@ private function resolveIdentifierValue($identifierValue, string $parameterName)
126128
if ($this->isResourceClass($relatedResourceClass = $this->getObjectClass($identifierValue))) {
127129
trigger_deprecation('api-platform/core', '2.7', 'Using a resource class as identifier is deprecated, please make this identifier Stringable');
128130
$relatedOperation = $this->resourceMetadataFactory->create($relatedResourceClass)->getOperation();
129-
$relatedIdentifiers = $relatedOperation->getUriVariables();
130-
if (1 === \count($relatedIdentifiers)) {
131-
$identifierValue = $this->getIdentifierValue($identifierValue, $relatedResourceClass, current($relatedIdentifiers)->getIdentifiers()[0], $parameterName);
131+
$relatedLinks = $relatedOperation instanceof GraphQlOperation ? $relatedOperation->getLinks() : $relatedOperation->getUriVariables();
132+
if (1 === \count($relatedLinks)) {
133+
$identifierValue = $this->getIdentifierValue($identifierValue, $relatedResourceClass, current($relatedLinks)->getIdentifiers()[0], $parameterName);
132134

133135
if ($identifierValue instanceof \Stringable || is_scalar($identifierValue) || method_exists($identifierValue, '__toString')) {
134136
return (string) $identifierValue;

src/Api/UriVariablesConverter.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ public function convert(array $uriVariables, string $class, array $context = [])
4949
$uriVariablesDefinition = $operation->getUriVariables() ?? [];
5050

5151
foreach ($uriVariables as $parameterName => $value) {
52-
if ([] === $types = $this->getIdentifierTypes($uriVariablesDefinition[$parameterName]->getTargetClass() ?? $class, $uriVariablesDefinition[$parameterName]->getIdentifiers() ?? [$parameterName])) {
52+
if ([] === $types = $this->getIdentifierTypes($uriVariablesDefinition[$parameterName]->getFromClass() ?? $class, $uriVariablesDefinition[$parameterName]->getIdentifiers() ?? [$parameterName])) {
5353
continue;
5454
}
5555

src/Bridge/Doctrine/Orm/State/CollectionProvider.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
*/
3131
final class CollectionProvider implements ProviderInterface
3232
{
33-
use UriVariablesHandlerTrait;
33+
use LinksHandlerTrait;
3434

3535
private $resourceMetadataCollectionFactory;
3636
private $managerRegistry;
@@ -59,7 +59,7 @@ public function provide(string $resourceClass, array $identifiers = [], ?string
5959
$queryBuilder = $repository->createQueryBuilder('o');
6060
$queryNameGenerator = new QueryNameGenerator();
6161

62-
$this->handleUriVariables($queryBuilder, $identifiers, $queryNameGenerator, $context, $resourceClass, $operationName);
62+
$this->handleLinks($queryBuilder, $identifiers, $queryNameGenerator, $context, $resourceClass, $operationName);
6363

6464
foreach ($this->collectionExtensions as $extension) {
6565
$extension->applyToCollection($queryBuilder, $queryNameGenerator, $resourceClass, $operationName, $context);

src/Bridge/Doctrine/Orm/State/ItemProvider.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
*/
3131
final class ItemProvider implements ProviderInterface
3232
{
33-
use UriVariablesHandlerTrait;
33+
use LinksHandlerTrait;
3434

3535
private $resourceMetadataCollectionFactory;
3636
private $managerRegistry;
@@ -64,7 +64,7 @@ public function provide(string $resourceClass, array $identifiers = [], ?string
6464
$queryBuilder = $repository->createQueryBuilder('o');
6565
$queryNameGenerator = new QueryNameGenerator();
6666

67-
$this->handleUriVariables($queryBuilder, $identifiers, $queryNameGenerator, $context, $resourceClass, $operationName);
67+
$this->handleLinks($queryBuilder, $identifiers, $queryNameGenerator, $context, $resourceClass, $operationName);
6868

6969
foreach ($this->itemExtensions as $extension) {
7070
$extension->applyToItem($queryBuilder, $queryNameGenerator, $resourceClass, $identifiers, $operationName, $context);
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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\Bridge\Doctrine\Orm\State;
15+
16+
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGenerator;
17+
use ApiPlatform\Exception\RuntimeException;
18+
use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation;
19+
use ApiPlatform\Metadata\Link;
20+
use Doctrine\ORM\QueryBuilder;
21+
use Doctrine\Persistence\Mapping\ClassMetadata;
22+
23+
trait LinksHandlerTrait
24+
{
25+
private function handleLinks(QueryBuilder $queryBuilder, array $identifiers, QueryNameGenerator $queryNameGenerator, array $context, string $resourceClass, ?string $operationName = null): void
26+
{
27+
$operation = $context['operation'] ?? $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation($operationName);
28+
$manager = $this->managerRegistry->getManagerForClass($resourceClass);
29+
$doctrineClassMetadata = $manager->getClassMetadata($resourceClass);
30+
$alias = $queryBuilder->getRootAliases()[0];
31+
32+
$links = $operation instanceof GraphQlOperation ? $operation->getLinks() : $operation->getUriVariables();
33+
34+
if ($linkClass = $context['linkClass'] ?? false) {
35+
foreach ($links as $link) {
36+
if ($linkClass === $link->getFromClass()) {
37+
foreach ($identifiers as $identifier => $value) {
38+
$this->applyLink($queryBuilder, $queryNameGenerator, $doctrineClassMetadata, $alias, $link, $identifier, $value);
39+
}
40+
41+
return;
42+
}
43+
}
44+
45+
$operation = $this->resourceMetadataCollectionFactory->create($linkClass)->getOperation($operationName);
46+
$links = $operation instanceof GraphQlOperation ? $operation->getLinks() : $operation->getUriVariables();
47+
foreach ($links as $link) {
48+
if ($resourceClass === $link->getFromClass()) {
49+
$link = $link->withFromProperty($link->getToProperty())->withFromClass($linkClass);
50+
foreach ($identifiers as $identifier => $value) {
51+
$this->applyLink($queryBuilder, $queryNameGenerator, $doctrineClassMetadata, $alias, $link, $identifier, $value);
52+
}
53+
54+
return;
55+
}
56+
}
57+
58+
throw new RuntimeException(sprintf('The class "%s" cannot be retrieved from "%s".', $resourceClass, $linkClass));
59+
}
60+
61+
if (!$links) {
62+
return;
63+
}
64+
65+
foreach ($identifiers as $identifier => $value) {
66+
$link = $links[$identifier] ?? $links['id'];
67+
68+
$this->applyLink($queryBuilder, $queryNameGenerator, $doctrineClassMetadata, $alias, $link, $identifier, $value);
69+
}
70+
}
71+
72+
private function applyLink(QueryBuilder $queryBuilder, QueryNameGenerator $queryNameGenerator, ClassMetadata $doctrineClassMetadata, string $alias, Link $link, string $identifier, $value)
73+
{
74+
$placeholder = ':id_'.$identifier;
75+
if ($fromProperty = $link->getFromProperty()) {
76+
$propertyIdentifier = $link->getIdentifiers()[0];
77+
$joinAlias = $queryNameGenerator->generateJoinAlias($fromProperty);
78+
79+
$queryBuilder->join(
80+
$link->getFromClass(),
81+
$joinAlias,
82+
'with',
83+
"$alias.$propertyIdentifier = $joinAlias.$fromProperty"
84+
);
85+
86+
$expression = $queryBuilder->expr()->eq(
87+
"{$joinAlias}.{$propertyIdentifier}",
88+
$placeholder
89+
);
90+
} elseif ($property = $link->getToProperty()) {
91+
$propertyIdentifier = $link->getIdentifiers()[0];
92+
$joinAlias = $queryNameGenerator->generateJoinAlias($property);
93+
94+
$queryBuilder->join(
95+
"$alias.$property",
96+
$joinAlias,
97+
);
98+
99+
$expression = $queryBuilder->expr()->eq(
100+
"{$joinAlias}.{$propertyIdentifier}",
101+
$placeholder
102+
);
103+
} else {
104+
$expression = $queryBuilder->expr()->eq(
105+
"{$alias}.{$identifier}", $placeholder
106+
);
107+
}
108+
$queryBuilder->andWhere($expression);
109+
$queryBuilder->setParameter($placeholder, $value, $doctrineClassMetadata->getTypeOfField($identifier));
110+
}
111+
}

0 commit comments

Comments
 (0)