Completed
Push — master ( 9e11ce...2484f0 )
by Adrien
01:55
created

methodToConfiguration()   C

Complexity

Conditions 7
Paths 64

Size

Total Lines 39
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 7

Importance

Changes 0
Metric Value
dl 0
loc 39
ccs 18
cts 18
cp 1
rs 6.7272
c 0
b 0
f 0
cc 7
eloc 18
nc 64
nop 1
crap 7
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)
0 ignored issues
show
Bug introduced by
You have injected the EntityManager via parameter $entityManager. This is generally not recommended as it might get closed and become unusable. Instead, it is recommended to inject the ManagerRegistry and retrieve the EntityManager via getManager() each time you need it.

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:

function someFunction(ManagerRegistry $registry) {
    $em = $registry->getManager();
    $em->getConnection()->beginTransaction();
    try {
        // Do something.
        $em->getConnection()->commit();
    } catch (\Exception $ex) {
        $em->getConnection()->rollback();
        $em->close();

        throw $ex;
    }
}

If that code throws an exception and the EntityManager is closed. Any other code which depends on the same instance of the EntityManager during this request will fail.

On the other hand, if you instead inject the ManagerRegistry, the getManager() method guarantees that you will always get a usable manager instance.

Loading history...
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) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $isList of type integer|null is loosely compared to true; this is ambiguous if the integer can be zero. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
164 2
            $type = Type::listOf($type);
165
        }
166
167 4
        if (!$isNullable) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $isNullable of type integer|null is loosely compared to false; this is ambiguous if the integer can be zero. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
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.');
0 ignored issues
show
Documentation introduced by
$param->getDeclaringFunction() is of type object<ReflectionFunction>, but the function expects a object<ReflectionMethod>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
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