1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
declare(strict_types=1); |
4
|
|
|
|
5
|
|
|
namespace GraphQL\Doctrine; |
6
|
|
|
|
7
|
|
|
use Doctrine\Common\Annotations\Reader; |
8
|
|
|
use Doctrine\Common\Collections\Collection; |
9
|
|
|
use Doctrine\ORM\EntityManager; |
10
|
|
|
use Doctrine\ORM\Mapping\ClassMetadata; |
11
|
|
|
use GraphQL\Doctrine\Annotation\Argument; |
12
|
|
|
use GraphQL\Doctrine\Annotation\Exclude; |
13
|
|
|
use GraphQL\Doctrine\Annotation\Field; |
14
|
|
|
use GraphQL\Type\Definition\Type; |
15
|
|
|
use ReflectionClass; |
16
|
|
|
use ReflectionMethod; |
17
|
|
|
use ReflectionParameter; |
18
|
|
|
|
19
|
|
|
/** |
20
|
|
|
* A factory to create a configuration for all fields of an entity |
21
|
|
|
*/ |
22
|
|
|
class FieldsConfigurationFactory |
23
|
|
|
{ |
24
|
|
|
/** |
25
|
|
|
* @var Types |
26
|
|
|
*/ |
27
|
|
|
private $types; |
28
|
|
|
|
29
|
|
|
/** |
30
|
|
|
* @var EntityManager |
31
|
|
|
*/ |
32
|
|
|
private $entityManager; |
33
|
|
|
|
34
|
|
|
/** |
35
|
|
|
* Doctrine metadata for the entity |
36
|
|
|
* @var ClassMetadata |
37
|
|
|
*/ |
38
|
|
|
private $metadata; |
39
|
|
|
|
40
|
|
|
/** |
41
|
|
|
* The identity field name, eg: "id" |
42
|
|
|
* @var string |
43
|
|
|
*/ |
44
|
|
|
private $identityField; |
45
|
|
|
|
46
|
|
|
public function __construct(Types $types, EntityManager $entityManager) |
|
|
|
|
47
|
|
|
{ |
48
|
|
|
$this->types = $types; |
49
|
|
|
$this->entityManager = $entityManager; |
50
|
|
|
} |
51
|
|
|
|
52
|
|
|
/** |
53
|
|
|
* Create a configuration for all fields of Doctrine entity |
54
|
|
|
* @param string $className |
55
|
|
|
* @return array |
56
|
|
|
*/ |
57
|
|
|
public function create(string $className): array |
58
|
|
|
{ |
59
|
|
|
$this->findIdentityField($className); |
60
|
|
|
|
61
|
|
|
$class = new ReflectionClass($className); |
62
|
|
|
$methods = $class->getMethods(ReflectionMethod::IS_PUBLIC); |
63
|
|
|
$fieldConfigurations = []; |
64
|
|
|
foreach ($methods as $method) { |
65
|
|
|
// Skip non-callable, non-instance or non-getter methods |
66
|
|
|
if ($method->isAbstract() || $method->isStatic()) { |
67
|
|
|
continue; |
68
|
|
|
} |
69
|
|
|
|
70
|
|
|
// Skip non-getter methods |
71
|
|
|
$name = $method->getName(); |
72
|
|
|
if (!preg_match('~^(get|is|has)[A-Z]~', $name)) { |
73
|
|
|
continue; |
74
|
|
|
} |
75
|
|
|
|
76
|
|
|
// Skip exclusion specified by user |
77
|
|
|
if ($this->isExcluded($method)) { |
78
|
|
|
continue; |
79
|
|
|
} |
80
|
|
|
|
81
|
|
|
$fieldConfigurations[] = $this->methodToConfiguration($method); |
82
|
|
|
} |
83
|
|
|
|
84
|
|
|
return $fieldConfigurations; |
85
|
|
|
} |
86
|
|
|
|
87
|
|
|
/** |
88
|
|
|
* Returns whether the getter is excluded |
89
|
|
|
* @param ReflectionMethod $method |
90
|
|
|
* @return bool |
91
|
|
|
*/ |
92
|
|
|
private function isExcluded(ReflectionMethod $method): bool |
93
|
|
|
{ |
94
|
|
|
$exclude = $this->getAnnotationReader()->getMethodAnnotation($method, Exclude::class); |
95
|
|
|
|
96
|
|
|
return $exclude !== null; |
97
|
|
|
} |
98
|
|
|
|
99
|
|
|
/** |
100
|
|
|
* Get the description of a method from the docblock |
101
|
|
|
* @param ReflectionMethod $method |
102
|
|
|
* @return string|null |
103
|
|
|
*/ |
104
|
|
|
private function getFieldDescription(ReflectionMethod $method): ?string |
105
|
|
|
{ |
106
|
|
|
$comment = $method->getDocComment(); |
107
|
|
|
|
108
|
|
|
// Remove the comment markers |
109
|
|
|
$comment = preg_replace('~^\s*(/\*\*|\* ?|\*/)~m', '', $comment); |
110
|
|
|
|
111
|
|
|
// Keep everything before the first annotation |
112
|
|
|
$comment = trim(explode('@', $comment)[0]); |
113
|
|
|
|
114
|
|
|
// Drop common "Get" or "Return" in front of comment |
115
|
|
|
$comment = ucfirst(preg_replace('~^(get|return)s? ~i', '', $comment)); |
116
|
|
|
|
117
|
|
|
if (!$comment) { |
118
|
|
|
$comment = null; |
119
|
|
|
} |
120
|
|
|
|
121
|
|
|
return $comment; |
122
|
|
|
} |
123
|
|
|
|
124
|
|
|
private function getArgumentDescription(ReflectionParameter $param): ?string |
125
|
|
|
{ |
126
|
|
|
$comment = $param->getDeclaringFunction()->getDocComment(); |
127
|
|
|
$name = preg_quote($param->getName()); |
128
|
|
|
|
129
|
|
|
if ($comment && preg_match('~@param\s+\S+\s+\$' . $name . '\s+(.*)~', $comment, $m)) { |
130
|
|
|
return ucfirst(trim($m[1])); |
131
|
|
|
} |
132
|
|
|
|
133
|
|
|
return null; |
134
|
|
|
} |
135
|
|
|
|
136
|
|
|
/** |
137
|
|
|
* Get annotation reader |
138
|
|
|
* @return Reader |
139
|
|
|
*/ |
140
|
|
|
private function getAnnotationReader(): Reader |
141
|
|
|
{ |
142
|
|
|
return $this->entityManager->getConfiguration()->getMetadataDriverImpl()->getReader(); |
143
|
|
|
} |
144
|
|
|
|
145
|
|
|
/** |
146
|
|
|
* Get a field from annotation, or an empty one |
147
|
|
|
* All its types will be converted from string to real instance of Type |
148
|
|
|
* |
149
|
|
|
* @param ReflectionMethod $method |
150
|
|
|
* @return Field |
151
|
|
|
*/ |
152
|
|
|
private function getFieldFromAnnotation(ReflectionMethod $method): Field |
153
|
|
|
{ |
154
|
|
|
$field = $this->getAnnotationReader()->getMethodAnnotation($method, Field::class) ?? new Field(); |
155
|
|
|
|
156
|
|
|
$field->type = $this->phpDeclarationToInstance($field->type); |
157
|
|
|
$args = []; |
158
|
|
|
foreach ($field->args as $arg) { |
159
|
|
|
$arg->type = $this->phpDeclarationToInstance($arg->type); |
160
|
|
|
$args[$arg->name] = $arg; |
161
|
|
|
} |
162
|
|
|
$field->args = $args; |
163
|
|
|
|
164
|
|
|
return $field; |
165
|
|
|
} |
166
|
|
|
|
167
|
|
|
/** |
168
|
|
|
* Get real instance of type, possibly non-nullable according to PHP syntax (eg: `?MyType` or `null|MyType`) |
169
|
|
|
* @param string|null $typeDeclaration |
170
|
|
|
* @return Type|null |
171
|
|
|
*/ |
172
|
|
|
private function phpDeclarationToInstance(?string $typeDeclaration): ?Type |
173
|
|
|
{ |
174
|
|
|
if (!$typeDeclaration) { |
175
|
|
|
return null; |
176
|
|
|
} |
177
|
|
|
|
178
|
|
|
$name = preg_replace('~(^\?|^null\||\|null$)~', '', $typeDeclaration); |
179
|
|
|
$type = $this->types->get($name); |
180
|
|
|
if ($name === $typeDeclaration) { |
181
|
|
|
$type = Type::nonNull($type); |
182
|
|
|
} |
183
|
|
|
|
184
|
|
|
return $type; |
185
|
|
|
} |
186
|
|
|
|
187
|
|
|
/** |
188
|
|
|
* Get the entire configuration for a method |
189
|
|
|
* @param ReflectionMethod $method |
190
|
|
|
* @throws Exception |
191
|
|
|
* @return array |
192
|
|
|
*/ |
193
|
|
|
private function methodToConfiguration(ReflectionMethod $method): array |
194
|
|
|
{ |
195
|
|
|
// First get user specified values |
196
|
|
|
$field = $this->getFieldFromAnnotation($method); |
197
|
|
|
|
198
|
|
|
$fieldName = lcfirst(preg_replace('~^get~', '', $method->getName())); |
199
|
|
|
if (!$field->name) { |
200
|
|
|
$field->name = $fieldName; |
201
|
|
|
} |
202
|
|
|
|
203
|
|
|
if (!$field->description) { |
204
|
|
|
$field->description = $this->getFieldDescription($method); |
205
|
|
|
} |
206
|
|
|
|
207
|
|
|
if ($fieldName === $this->identityField) { |
208
|
|
|
$field->type = Type::nonNull(Type::id()); |
209
|
|
|
} |
210
|
|
|
|
211
|
|
|
// If still no type, look for type hint |
212
|
|
|
if (!$field->type) { |
213
|
|
|
$field->type = $this->getTypeFromTypeHint($method, $fieldName); |
|
|
|
|
214
|
|
|
} |
215
|
|
|
|
216
|
|
|
// If still no args, look for type hint |
217
|
|
|
$field->args = $this->getArgumentsFromTypeHint($method, $field->args); |
218
|
|
|
|
219
|
|
|
// If still no type, cannot continue |
220
|
|
|
if (!$field->type) { |
221
|
|
|
throw new Exception('Could not find type for method `' . $method->getDeclaringClass()->getName() . '::' . $method->getName() . '()`. Either type hint the return value, or specify the type with `@API\Field` annotation.'); |
222
|
|
|
} |
223
|
|
|
|
224
|
|
|
return $field->toArray(); |
225
|
|
|
} |
226
|
|
|
|
227
|
|
|
/** |
228
|
|
|
* Get a GraphQL type instance from PHP type hinted type, possibly looking up the content of collections |
229
|
|
|
* @param ReflectionMethod $method |
230
|
|
|
* @param string $fieldName |
231
|
|
|
* @throws Exception |
232
|
|
|
* @return Type|null |
233
|
|
|
*/ |
234
|
|
|
private function getTypeFromTypeHint(ReflectionMethod $method, string $fieldName): ?Type |
235
|
|
|
{ |
236
|
|
|
$returnType = $method->getReturnType(); |
237
|
|
|
if (!$returnType) { |
238
|
|
|
return null; |
239
|
|
|
} |
240
|
|
|
|
241
|
|
|
$returnTypeName = (string) $returnType; |
242
|
|
|
if (is_a($returnTypeName, Collection::class, true)) { |
243
|
|
|
$mapping = $this->metadata->associationMappings[$fieldName] ?? false; |
244
|
|
|
if (!$mapping) { |
245
|
|
|
throw new Exception('The method `' . $method->getDeclaringClass()->getName() . '::' . $method->getName() . '()` is type hinted with a return type of `' . $returnTypeName . '`, but the entity contained in that collection could not be automatically detected. Either fix the type hint, fix the doctrine mapping, or specify the type with `@API\Field` annotation.'); |
246
|
|
|
} |
247
|
|
|
|
248
|
|
|
return Type::listOf($this->types->get($mapping['targetEntity'])); |
249
|
|
|
} |
250
|
|
|
|
251
|
|
|
return $this->refelectionTypeToType($returnType); |
252
|
|
|
} |
253
|
|
|
|
254
|
|
|
/** |
255
|
|
|
* Convert a reflected type to GraphQL Type |
256
|
|
|
* @param \ReflectionType $returnType |
257
|
|
|
* @return Type |
258
|
|
|
*/ |
259
|
|
|
private function refelectionTypeToType(\ReflectionType $returnType): Type |
260
|
|
|
{ |
261
|
|
|
$type = $this->types->get((string) $returnType); |
262
|
|
|
if (!$returnType->allowsNull()) { |
263
|
|
|
$type = Type::nonNull($type); |
264
|
|
|
} |
265
|
|
|
|
266
|
|
|
return $type; |
267
|
|
|
} |
268
|
|
|
|
269
|
|
|
/** |
270
|
|
|
* Complete arguments configuration from existing type hints |
271
|
|
|
* @param ReflectionMethod $method |
272
|
|
|
* @param array $argsFromAnnotations |
273
|
|
|
* @throws Exception |
274
|
|
|
* @return array |
275
|
|
|
*/ |
276
|
|
|
private function getArgumentsFromTypeHint(ReflectionMethod $method, array $argsFromAnnotations): array |
277
|
|
|
{ |
278
|
|
|
$args = []; |
279
|
|
|
foreach ($method->getParameters() as $param) { |
280
|
|
|
//Either get existing, or create new argument |
281
|
|
|
$arg = $argsFromAnnotations[$param->getName()] ?? new Argument(); |
282
|
|
|
$args[$param->getName()] = $arg; |
283
|
|
|
|
284
|
|
|
$this->completeArgumentFromTypeHint($method, $param, $arg); |
285
|
|
|
} |
286
|
|
|
|
287
|
|
|
$extraAnnotations = array_diff(array_keys($argsFromAnnotations), array_keys($args)); |
288
|
|
|
if ($extraAnnotations) { |
289
|
|
|
throw new Exception('The following arguments were declared via `@API\Argument` annotation but do not match actual parameter names on method `' . $method->getDeclaringClass()->getName() . '::' . $method->getName() . '()`. Either rename or remove the annotations: ' . implode(', ', $extraAnnotations)); |
290
|
|
|
} |
291
|
|
|
|
292
|
|
|
return $args; |
293
|
|
|
} |
294
|
|
|
|
295
|
|
|
/** |
296
|
|
|
* Complete a single argument from its type hint |
297
|
|
|
* @param ReflectionMethod $method |
298
|
|
|
* @param ReflectionParameter $param |
299
|
|
|
* @param Argument $arg |
300
|
|
|
* @throws Exception |
301
|
|
|
*/ |
302
|
|
|
private function completeArgumentFromTypeHint(ReflectionMethod $method, ReflectionParameter $param, Argument $arg) |
303
|
|
|
{ |
304
|
|
|
if (!$arg->name) { |
305
|
|
|
$arg->name = $param->getName(); |
306
|
|
|
} |
307
|
|
|
|
308
|
|
|
if (!$arg->description) { |
309
|
|
|
$arg->description = $this->getArgumentDescription($param); |
310
|
|
|
} |
311
|
|
|
|
312
|
|
|
if (!isset($arg->defaultValue) && $param->isDefaultValueAvailable()) { |
313
|
|
|
$arg->defaultValue = $param->getDefaultValue(); |
314
|
|
|
} |
315
|
|
|
|
316
|
|
|
$type = $param->getType(); |
317
|
|
|
if (!$arg->type && $type) { |
318
|
|
|
$arg->type = $this->refelectionTypeToType($type); |
319
|
|
|
} |
320
|
|
|
|
321
|
|
|
if (!$arg->type) { |
322
|
|
|
throw new Exception('Could not find type for argument `' . $arg->name . '` for method `' . $method->getDeclaringClass()->getName() . '::' . $method->getName() . '()`. Either type hint the parameter, or specify the type with `@API\Argument` annotation.'); |
323
|
|
|
} |
324
|
|
|
} |
325
|
|
|
|
326
|
|
|
/** |
327
|
|
|
* Look up which field is the ID |
328
|
|
|
* @param string $className |
329
|
|
|
*/ |
330
|
|
|
private function findIdentityField(string $className) |
331
|
|
|
{ |
332
|
|
|
$this->metadata = $this->entityManager->getClassMetadata($className); |
333
|
|
|
foreach ($this->metadata->fieldMappings as $meta) { |
334
|
|
|
if ($meta['id'] ?? false) { |
335
|
|
|
$this->identityField = $meta['fieldName']; |
336
|
|
|
} |
337
|
|
|
} |
338
|
|
|
} |
339
|
|
|
} |
340
|
|
|
|
The
EntityManager
might become unusable for example if a transaction is rolled back and it gets closed. Let’s assume that somewhere in your application, or in a third-party library, there is code such as the following:If that code throws an exception and the
EntityManager
is closed. Any other code which depends on the same instance of theEntityManager
during this request will fail.On the other hand, if you instead inject the
ManagerRegistry
, thegetManager()
method guarantees that you will always get a usable manager instance.