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\InputType; |
15
|
|
|
use GraphQL\Type\Definition\Type; |
16
|
|
|
use GraphQL\Type\Definition\WrappingType; |
17
|
|
|
use ReflectionClass; |
18
|
|
|
use ReflectionMethod; |
19
|
|
|
use ReflectionParameter; |
20
|
|
|
use ReflectionType; |
21
|
|
|
|
22
|
|
|
/** |
23
|
|
|
* A factory to create a configuration for all fields of an entity |
24
|
|
|
*/ |
25
|
|
|
class FieldsConfigurationFactory |
26
|
|
|
{ |
27
|
|
|
/** |
28
|
|
|
* @var Types |
29
|
|
|
*/ |
30
|
|
|
private $types; |
31
|
|
|
|
32
|
|
|
/** |
33
|
|
|
* @var EntityManager |
34
|
|
|
*/ |
35
|
|
|
private $entityManager; |
36
|
|
|
|
37
|
|
|
/** |
38
|
|
|
* Doctrine metadata for the entity |
39
|
|
|
* @var ClassMetadata |
40
|
|
|
*/ |
41
|
|
|
private $metadata; |
42
|
|
|
|
43
|
|
|
/** |
44
|
|
|
* The identity field name, eg: "id" |
45
|
|
|
* @var string |
46
|
|
|
*/ |
47
|
|
|
private $identityField; |
48
|
|
|
|
49
|
9 |
|
public function __construct(Types $types, EntityManager $entityManager) |
|
|
|
|
50
|
|
|
{ |
51
|
9 |
|
$this->types = $types; |
52
|
9 |
|
$this->entityManager = $entityManager; |
53
|
9 |
|
} |
54
|
|
|
|
55
|
|
|
/** |
56
|
|
|
* Create a configuration for all fields of Doctrine entity |
57
|
|
|
* @param string $className |
58
|
|
|
* @return array |
59
|
|
|
*/ |
60
|
9 |
|
public function create(string $className): array |
61
|
|
|
{ |
62
|
9 |
|
$this->findIdentityField($className); |
63
|
|
|
|
64
|
9 |
|
$class = new ReflectionClass($className); |
65
|
9 |
|
$methods = $class->getMethods(ReflectionMethod::IS_PUBLIC); |
66
|
9 |
|
$fieldConfigurations = []; |
67
|
9 |
|
foreach ($methods as $method) { |
68
|
|
|
// Skip non-callable, non-instance or non-getter methods |
69
|
9 |
|
if ($method->isAbstract() || $method->isStatic()) { |
70
|
1 |
|
continue; |
71
|
|
|
} |
72
|
|
|
|
73
|
|
|
// Skip non-getter methods |
74
|
9 |
|
$name = $method->getName(); |
75
|
9 |
|
if (!preg_match('~^(get|is|has)[A-Z]~', $name)) { |
76
|
2 |
|
continue; |
77
|
|
|
} |
78
|
|
|
|
79
|
|
|
// Skip exclusion specified by user |
80
|
9 |
|
if ($this->isExcluded($method)) { |
81
|
1 |
|
continue; |
82
|
|
|
} |
83
|
|
|
|
84
|
9 |
|
$fieldConfigurations[] = $this->methodToConfiguration($method); |
85
|
|
|
} |
86
|
|
|
|
87
|
3 |
|
return $fieldConfigurations; |
88
|
|
|
} |
89
|
|
|
|
90
|
|
|
/** |
91
|
|
|
* Returns whether the getter is excluded |
92
|
|
|
* @param ReflectionMethod $method |
93
|
|
|
* @return bool |
94
|
|
|
*/ |
95
|
9 |
|
private function isExcluded(ReflectionMethod $method): bool |
96
|
|
|
{ |
97
|
9 |
|
$exclude = $this->getAnnotationReader()->getMethodAnnotation($method, Exclude::class); |
98
|
|
|
|
99
|
9 |
|
return $exclude !== null; |
100
|
|
|
} |
101
|
|
|
|
102
|
|
|
/** |
103
|
|
|
* Get annotation reader |
104
|
|
|
* @return Reader |
105
|
|
|
*/ |
106
|
9 |
|
private function getAnnotationReader(): Reader |
107
|
|
|
{ |
108
|
9 |
|
return $this->entityManager->getConfiguration()->getMetadataDriverImpl()->getReader(); |
109
|
|
|
} |
110
|
|
|
|
111
|
|
|
/** |
112
|
|
|
* Get a field from annotation, or an empty one |
113
|
|
|
* All its types will be converted from string to real instance of Type |
114
|
|
|
* |
115
|
|
|
* @param ReflectionMethod $method |
116
|
|
|
* @return Field |
117
|
|
|
*/ |
118
|
9 |
|
private function getFieldFromAnnotation(ReflectionMethod $method): Field |
119
|
|
|
{ |
120
|
9 |
|
$field = $this->getAnnotationReader()->getMethodAnnotation($method, Field::class) ?? new Field(); |
121
|
|
|
|
122
|
9 |
|
$field->type = $this->phpDeclarationToInstance($method, $field->type); |
123
|
9 |
|
$args = []; |
124
|
9 |
|
foreach ($field->args as $arg) { |
125
|
2 |
|
$arg->type = $this->phpDeclarationToInstance($method, $arg->type); |
126
|
2 |
|
$args[$arg->name] = $arg; |
127
|
|
|
} |
128
|
9 |
|
$field->args = $args; |
129
|
|
|
|
130
|
9 |
|
return $field; |
131
|
|
|
} |
132
|
|
|
|
133
|
|
|
/** |
134
|
|
|
* Get instance of GraphQL type from a PHP class name |
135
|
|
|
* |
136
|
|
|
* Supported syntaxes are the following: |
137
|
|
|
* |
138
|
|
|
* - `?MyType` |
139
|
|
|
* - `null|MyType` |
140
|
|
|
* - `MyType|null` |
141
|
|
|
* - `MyType[]` |
142
|
|
|
* - `?MyType[]` |
143
|
|
|
* - `null|MyType[]` |
144
|
|
|
* - `MyType[]|null` |
145
|
|
|
* |
146
|
|
|
* @param string|null $typeDeclaration |
147
|
|
|
* @return Type|null |
148
|
|
|
*/ |
149
|
9 |
|
private function phpDeclarationToInstance(ReflectionMethod $method, ?string $typeDeclaration): ?Type |
150
|
|
|
{ |
151
|
9 |
|
if (!$typeDeclaration) { |
152
|
9 |
|
return null; |
153
|
|
|
} |
154
|
|
|
|
155
|
4 |
|
$isNullable = 0; |
156
|
4 |
|
$name = preg_replace('~(^\?|^null\||\|null$)~', '', $typeDeclaration, -1, $isNullable); |
157
|
|
|
|
158
|
4 |
|
$isList = 0; |
159
|
4 |
|
$name = preg_replace('~^(.*)\[\]$~', '$1', $name, -1, $isList); |
160
|
4 |
|
$name = $this->adjustNamespace($method, $name); |
161
|
4 |
|
$type = $this->types->get($name); |
162
|
|
|
|
163
|
4 |
|
if ($isList) { |
|
|
|
|
164
|
2 |
|
$type = Type::listOf($type); |
165
|
|
|
} |
166
|
|
|
|
167
|
4 |
|
if (!$isNullable) { |
|
|
|
|
168
|
4 |
|
$type = Type::nonNull($type); |
169
|
|
|
} |
170
|
|
|
|
171
|
4 |
|
return $type; |
172
|
|
|
} |
173
|
|
|
|
174
|
|
|
/** |
175
|
|
|
* Prepend namespace of the method if the class actually exists |
176
|
|
|
* @param ReflectionMethod $method |
177
|
|
|
* @param string $type |
178
|
|
|
* @return string |
179
|
|
|
*/ |
180
|
4 |
|
private function adjustNamespace(ReflectionMethod $method, string $type): string |
181
|
|
|
{ |
182
|
4 |
|
$namespace = $method->getDeclaringClass()->getNamespaceName(); |
183
|
4 |
|
if ($namespace) { |
184
|
4 |
|
$namespace = $namespace . '\\'; |
185
|
|
|
} |
186
|
4 |
|
$namespacedType = $namespace . $type; |
187
|
|
|
|
188
|
4 |
|
return class_exists($namespacedType) ? $namespacedType : $type; |
189
|
|
|
} |
190
|
|
|
|
191
|
|
|
/** |
192
|
|
|
* Get the entire configuration for a method |
193
|
|
|
* @param ReflectionMethod $method |
194
|
|
|
* @throws Exception |
195
|
|
|
* @return array |
196
|
|
|
*/ |
197
|
9 |
|
private function methodToConfiguration(ReflectionMethod $method): array |
198
|
|
|
{ |
199
|
|
|
// First get user specified values |
200
|
9 |
|
$field = $this->getFieldFromAnnotation($method); |
201
|
|
|
|
202
|
9 |
|
$fieldName = lcfirst(preg_replace('~^get~', '', $method->getName())); |
203
|
9 |
|
if (!$field->name) { |
204
|
9 |
|
$field->name = $fieldName; |
205
|
|
|
} |
206
|
|
|
|
207
|
9 |
|
$docBlock = new DocBlockReader($method); |
208
|
9 |
|
if (!$field->description) { |
209
|
9 |
|
$field->description = $docBlock->getMethodDescription(); |
210
|
|
|
} |
211
|
|
|
|
212
|
9 |
|
if ($fieldName === $this->identityField) { |
213
|
3 |
|
$field->type = Type::nonNull(Type::id()); |
214
|
|
|
} |
215
|
|
|
|
216
|
|
|
// If still no type, look for docblock |
217
|
9 |
|
if (!$field->type) { |
218
|
8 |
|
$field->type = $this->getTypeFromDocBock($method, $docBlock); |
219
|
|
|
} |
220
|
|
|
|
221
|
|
|
// If still no type, look for type hint |
222
|
9 |
|
if (!$field->type) { |
223
|
6 |
|
$field->type = $this->getTypeFromTypeHint($method, $fieldName); |
224
|
|
|
} |
225
|
|
|
|
226
|
|
|
// If still no args, look for type hint |
227
|
8 |
|
$field->args = $this->getArgumentsFromTypeHint($method, $field->args, $docBlock); |
228
|
|
|
|
229
|
|
|
// If still no type, cannot continue |
230
|
4 |
|
if (!$field->type) { |
231
|
1 |
|
throw new Exception('Could not find type for method ' . $this->getMethodFullName($method) . '. Either type hint the return value, or specify the type with `@API\Field` annotation.'); |
232
|
|
|
} |
233
|
|
|
|
234
|
3 |
|
return $field->toArray(); |
235
|
|
|
} |
236
|
|
|
|
237
|
|
|
/** |
238
|
|
|
* Get a GraphQL type instance from PHP type hinted type, possibly looking up the content of collections |
239
|
|
|
* @param ReflectionMethod $method |
240
|
|
|
* @param string $fieldName |
241
|
|
|
* @throws Exception |
242
|
|
|
* @return Type|null |
243
|
|
|
*/ |
244
|
6 |
|
private function getTypeFromTypeHint(ReflectionMethod $method, string $fieldName): ?Type |
245
|
|
|
{ |
246
|
6 |
|
$returnType = $method->getReturnType(); |
247
|
6 |
|
if (!$returnType) { |
248
|
1 |
|
return null; |
249
|
|
|
} |
250
|
|
|
|
251
|
5 |
|
$returnTypeName = (string) $returnType; |
252
|
5 |
|
if (is_a($returnTypeName, Collection::class, true) || $returnTypeName === 'array') { |
253
|
2 |
|
$mapping = $this->metadata->associationMappings[$fieldName] ?? false; |
254
|
2 |
|
if (!$mapping) { |
255
|
1 |
|
throw new Exception('The method ' . $this->getMethodFullName($method) . ' 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.'); |
256
|
|
|
} |
257
|
|
|
|
258
|
1 |
|
$type = Type::listOf($this->types->get($mapping['targetEntity'])); |
259
|
1 |
|
if (!$returnType->allowsNull()) { |
260
|
1 |
|
$type = Type::nonNull($type); |
261
|
|
|
} |
262
|
|
|
|
263
|
1 |
|
return $type; |
264
|
|
|
} |
265
|
|
|
|
266
|
3 |
|
return $this->refelectionTypeToType($returnType); |
267
|
|
|
} |
268
|
|
|
|
269
|
|
|
/** |
270
|
|
|
* Convert a reflected type to GraphQL Type |
271
|
|
|
* @param ReflectionType $reflectionType |
272
|
|
|
* @return Type |
273
|
|
|
*/ |
274
|
3 |
|
private function refelectionTypeToType(ReflectionType $reflectionType): Type |
275
|
|
|
{ |
276
|
3 |
|
$type = $this->types->get((string) $reflectionType); |
277
|
3 |
|
if (!$reflectionType->allowsNull()) { |
278
|
3 |
|
$type = Type::nonNull($type); |
279
|
|
|
} |
280
|
|
|
|
281
|
3 |
|
return $type; |
282
|
|
|
} |
283
|
|
|
|
284
|
|
|
/** |
285
|
|
|
* Complete arguments configuration from existing type hints |
286
|
|
|
* @param ReflectionMethod $method |
287
|
|
|
* @param Argument[] $argsFromAnnotations |
288
|
|
|
* @throws Exception |
289
|
|
|
* @return array |
290
|
|
|
*/ |
291
|
8 |
|
private function getArgumentsFromTypeHint(ReflectionMethod $method, array $argsFromAnnotations, DocBlockReader $docBlock): array |
292
|
|
|
{ |
293
|
8 |
|
$args = []; |
294
|
8 |
|
foreach ($method->getParameters() as $param) { |
295
|
|
|
//Either get existing, or create new argument |
296
|
6 |
|
$arg = $argsFromAnnotations[$param->getName()] ?? new Argument(); |
297
|
6 |
|
$args[$param->getName()] = $arg; |
298
|
|
|
|
299
|
6 |
|
$this->completeArgumentFromTypeHint($method, $param, $arg, $docBlock); |
300
|
|
|
} |
301
|
|
|
|
302
|
5 |
|
$extraAnnotations = array_diff(array_keys($argsFromAnnotations), array_keys($args)); |
303
|
5 |
|
if ($extraAnnotations) { |
304
|
1 |
|
throw new Exception('The following arguments were declared via `@API\Argument` annotation but do not match actual parameter names on method ' . $this->getMethodFullName($method) . '. Either rename or remove the annotations: ' . implode(', ', $extraAnnotations)); |
305
|
|
|
} |
306
|
|
|
|
307
|
4 |
|
return $args; |
308
|
|
|
} |
309
|
|
|
|
310
|
|
|
/** |
311
|
|
|
* Complete a single argument from its type hint |
312
|
|
|
* @param ReflectionMethod $method |
313
|
|
|
* @param ReflectionParameter $param |
314
|
|
|
* @param Argument $arg |
315
|
|
|
* @throws Exception |
316
|
|
|
*/ |
317
|
6 |
|
private function completeArgumentFromTypeHint(ReflectionMethod $method, ReflectionParameter $param, Argument $arg, DocBlockReader $docBlock) |
318
|
|
|
{ |
319
|
6 |
|
if (!$arg->name) { |
320
|
6 |
|
$arg->name = $param->getName(); |
321
|
|
|
} |
322
|
|
|
|
323
|
6 |
|
if (!$arg->description) { |
324
|
6 |
|
$arg->description = $docBlock->getParameterDescription($param); |
325
|
|
|
} |
326
|
|
|
|
327
|
6 |
|
if (!isset($arg->defaultValue) && $param->isDefaultValueAvailable()) { |
328
|
1 |
|
$arg->defaultValue = $param->getDefaultValue(); |
329
|
|
|
} |
330
|
|
|
|
331
|
6 |
|
if (!$arg->type) { |
332
|
6 |
|
$typeDeclaration = $docBlock->getParameterType($param); |
333
|
6 |
|
$this->throwIfArray($param, $typeDeclaration); |
334
|
5 |
|
$arg->type = $this->phpDeclarationToInstance($method, $typeDeclaration); |
335
|
|
|
} |
336
|
|
|
|
337
|
5 |
|
$type = $param->getType(); |
338
|
5 |
|
if (!$arg->type && $type) { |
339
|
2 |
|
$this->throwIfArray($param, (string) $type); |
340
|
2 |
|
$arg->type = $this->refelectionTypeToType($type); |
341
|
|
|
} |
342
|
|
|
|
343
|
5 |
|
if (!$arg->type) { |
344
|
1 |
|
throw new Exception('Could not find type for parameter `$' . $arg->name . '` for method ' . $this->getMethodFullName($method) . '. Either type hint the parameter, or specify the type with `@API\Argument` annotation.'); |
345
|
|
|
} |
346
|
|
|
|
347
|
4 |
|
$this->throwIfNotInputType($method, $arg); |
348
|
3 |
|
} |
349
|
|
|
|
350
|
|
|
/** |
351
|
|
|
* Look up which field is the ID |
352
|
|
|
* @param string $className |
353
|
|
|
*/ |
354
|
9 |
|
private function findIdentityField(string $className) |
355
|
|
|
{ |
356
|
9 |
|
$this->metadata = $this->entityManager->getClassMetadata($className); |
357
|
9 |
|
foreach ($this->metadata->fieldMappings as $meta) { |
358
|
9 |
|
if ($meta['id'] ?? false) { |
359
|
9 |
|
$this->identityField = $meta['fieldName']; |
360
|
|
|
} |
361
|
|
|
} |
362
|
9 |
|
} |
363
|
|
|
|
364
|
|
|
/** |
365
|
|
|
* Returns the fully qualified method name |
366
|
|
|
* @param ReflectionMethod $method |
367
|
|
|
* @return string |
368
|
|
|
*/ |
369
|
6 |
|
private function getMethodFullName(ReflectionMethod $method): string |
370
|
|
|
{ |
371
|
6 |
|
return '`' . $method->getDeclaringClass()->getName() . '::' . $method->getName() . '()`'; |
372
|
|
|
} |
373
|
|
|
|
374
|
|
|
/** |
375
|
|
|
* Get a GraphQL type instance from dock block return type |
376
|
|
|
* @param ReflectionMethod $method |
377
|
|
|
* @param \GraphQL\Doctrine\DocBlockReader $docBlock |
378
|
|
|
* @return Type|null |
379
|
|
|
*/ |
380
|
8 |
|
private function getTypeFromDocBock(ReflectionMethod $method, DocBlockReader $docBlock): ?Type |
381
|
|
|
{ |
382
|
8 |
|
$typeDeclaration = $docBlock->getReturnType(); |
383
|
|
|
$blacklist = [ |
384
|
8 |
|
'Collection', |
385
|
|
|
'array', |
386
|
|
|
]; |
387
|
|
|
|
388
|
8 |
|
if ($typeDeclaration && !in_array($typeDeclaration, $blacklist, true)) { |
389
|
3 |
|
return $this->phpDeclarationToInstance($method, $typeDeclaration); |
390
|
|
|
} |
391
|
|
|
|
392
|
6 |
|
return null; |
393
|
|
|
} |
394
|
|
|
|
395
|
|
|
/** |
396
|
|
|
* Throws exception if type is an array |
397
|
|
|
* @param ReflectionParameter $param |
398
|
|
|
* @param string|null $type |
399
|
|
|
* @throws Exception |
400
|
|
|
*/ |
401
|
6 |
|
private function throwIfArray(ReflectionParameter $param, ?string $type) |
402
|
|
|
{ |
403
|
6 |
|
if ($type === 'array') { |
404
|
1 |
|
throw new Exception('The parameter `$' . $param->getName() . '` on method ' . $this->getMethodFullName($param->getDeclaringFunction()) . ' is type hinted as `array` and is not overriden via `@API\Argument` annotation. Either change the type hint or specify the type with `@API\Argument` annotation.'); |
|
|
|
|
405
|
|
|
} |
406
|
5 |
|
} |
407
|
|
|
|
408
|
|
|
/** |
409
|
|
|
* Throws exception if argument type is invalid |
410
|
|
|
* @param ReflectionMethod $method |
411
|
|
|
* @param Argument $arg |
412
|
|
|
* @throws Exception |
413
|
|
|
*/ |
414
|
4 |
|
private function throwIfNotInputType(ReflectionMethod $method, Argument $arg) |
415
|
|
|
{ |
416
|
4 |
|
$type = $arg->type; |
417
|
4 |
|
if ($type instanceof WrappingType) { |
418
|
4 |
|
$type = $type->getWrappedType(true); |
419
|
|
|
} |
420
|
|
|
|
421
|
4 |
|
if (!($type instanceof InputType)) { |
422
|
1 |
|
throw new Exception('Type for parameter `$' . $arg->name . '` for method ' . $this->getMethodFullName($method) . ' must be an instance of `' . InputType::class . '`, but was `' . get_class($type) . '`. Use `@API\Argument` annotation to specify a custom InputType.'); |
423
|
|
|
} |
424
|
3 |
|
} |
425
|
|
|
} |
426
|
|
|
|
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.