Completed
Pull Request — master (#2)
by Adrien
02:46
created

AbstractFieldsConfigurationFactory   B

Complexity

Total Complexity 47

Size/Duplication

Total Lines 331
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 9

Test Coverage

Coverage 95.24%

Importance

Changes 0
Metric Value
wmc 47
lcom 1
cbo 9
dl 0
loc 331
ccs 100
cts 105
cp 0.9524
rs 8.439
c 0
b 0
f 0

18 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 5 1
getMethodPattern() 0 1 ?
methodToConfiguration() 0 1 ?
C create() 0 32 7
A isExcluded() 0 6 1
B getAnnotationReader() 0 18 5
B getTypeFromPhpDeclaration() 0 24 4
A adjustNamespace() 0 10 3
B getTypeFromReturnTypeHint() 0 24 6
A refelectionTypeToType() 0 9 2
A findIdentityField() 0 9 3
A getMethodFullName() 0 4 1
A throwIfArray() 0 6 2
A isIdentityField() 0 4 1
A getTargetEntity() 0 4 1
A getTypeFromRegistry() 0 8 3
A nonNullIfHasDefault() 0 8 3
A throwIfNotInputType() 0 14 4

How to fix   Complexity   

Complex Class

Complex classes like AbstractFieldsConfigurationFactory often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use AbstractFieldsConfigurationFactory, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace GraphQL\Doctrine\Factory;
6
7
use Doctrine\Common\Annotations\AnnotationReader;
8
use Doctrine\Common\Annotations\Reader;
9
use Doctrine\Common\Collections\Collection;
10
use Doctrine\Common\Persistence\Mapping\Driver\AnnotationDriver;
11
use Doctrine\Common\Persistence\Mapping\Driver\MappingDriverChain;
12
use Doctrine\ORM\EntityManager;
13
use Doctrine\ORM\Mapping\ClassMetadata;
14
use GraphQL\Doctrine\Annotation\Exclude;
15
use GraphQL\Doctrine\Exception;
16
use GraphQL\Doctrine\Types;
17
use GraphQL\Type\Definition\InputType;
18
use GraphQL\Type\Definition\NonNull;
19
use GraphQL\Type\Definition\Type;
20
use GraphQL\Type\Definition\WrappingType;
21
use ReflectionClass;
22
use ReflectionMethod;
23
use ReflectionParameter;
24
use ReflectionType;
25
26
/**
27
 * A factory to create a configuration for all fields of an entity
28
 */
29
abstract class AbstractFieldsConfigurationFactory
30
{
31
    /**
32
     * @var Types
33
     */
34
    private $types;
35
36
    /**
37
     * @var EntityManager
38
     */
39
    private $entityManager;
40
41
    /**
42
     * Doctrine metadata for the entity
43
     * @var ClassMetadata
44
     */
45
    private $metadata;
46
47
    /**
48
     * The identity field name, eg: "id"
49
     * @var string
50
     */
51
    private $identityField;
52
53 11
    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...
54
    {
55 11
        $this->types = $types;
56 11
        $this->entityManager = $entityManager;
57 11
    }
58
59
    /**
60
     * Returns the regexp pattern to filter method names
61
     */
62
    abstract protected function getMethodPattern(): string;
63
64
    /**
65
     * Get the entire configuration for a method
66
     * @param ReflectionMethod $method
67
     * @return array
68
     */
69
    abstract protected function methodToConfiguration(ReflectionMethod $method): ?array;
70
71
    /**
72
     * Create a configuration for all fields of Doctrine entity
73
     * @param string $className
74
     * @return array
75
     */
76 11
    public function create(string $className): array
77
    {
78 11
        $this->findIdentityField($className);
79
80 11
        $class = new ReflectionClass($className);
81 11
        $methods = $class->getMethods(ReflectionMethod::IS_PUBLIC);
82 11
        $fieldConfigurations = [];
83 11
        foreach ($methods as $method) {
84
            // Skip non-callable, non-instance or non-getter methods
85 11
            if ($method->isAbstract() || $method->isStatic()) {
86 1
                continue;
87
            }
88
89
            // Skip non-getter methods
90 11
            $name = $method->getName();
91 11
            if (!preg_match($this->getMethodPattern(), $name)) {
92 3
                continue;
93
            }
94
95
            // Skip exclusion specified by user
96 11
            if ($this->isExcluded($method)) {
97 2
                continue;
98
            }
99
100 11
            $configuration = $this->methodToConfiguration($method);
101 4
            if ($configuration) {
102 4
                $fieldConfigurations[] = $configuration;
103
            }
104
        }
105
106 4
        return $fieldConfigurations;
107
    }
108
109
    /**
110
     * Returns whether the getter is excluded
111
     * @param ReflectionMethod $method
112
     * @return bool
113
     */
114 11
    private function isExcluded(ReflectionMethod $method): bool
115
    {
116 11
        $exclude = $this->getAnnotationReader()->getMethodAnnotation($method, Exclude::class);
117
118 11
        return $exclude !== null;
119
    }
120
121
    /**
122
     * Get annotation reader
123
     * @return Reader
124
     */
125 11
    protected function getAnnotationReader(): Reader
126
    {
127 11
        $driver = $this->entityManager->getConfiguration()->getMetadataDriverImpl();
128 11
        if ($driver instanceof MappingDriverChain) {
129
            $drivers = $driver->getDrivers();
130
            foreach ($drivers as $driver) {
131
                if ($driver instanceof AnnotationDriver) {
132
                    break;
133
                }
134
            }
135
        }
136
        
137 11
        if ($driver instanceof AnnotationDriver) {
138 11
            return $driver->getReader();
139
        }
140
        
141
        return new AnnotationReader();
142
    }
143
144
    /**
145
     * Get instance of GraphQL type from a PHP class name
146
     *
147
     * Supported syntaxes are the following:
148
     *
149
     *  - `?MyType`
150
     *  - `null|MyType`
151
     *  - `MyType|null`
152
     *  - `MyType[]`
153
     *  - `?MyType[]`
154
     *  - `null|MyType[]`
155
     *  - `MyType[]|null`
156
     *
157
     * @param ReflectionMethod $method
158
     * @param string|null $typeDeclaration
159
     * @param bool $isEntityId
160
     * @return Type|null
161
     */
162 11
    protected function getTypeFromPhpDeclaration(ReflectionMethod $method, ?string $typeDeclaration, bool $isEntityId = false): ?Type
163
    {
164 11
        if (!$typeDeclaration) {
165 11
            return null;
166
        }
167
168 7
        $isNullable = 0;
169 7
        $name = preg_replace('~(^\?|^null\||\|null$)~', '', $typeDeclaration, -1, $isNullable);
170
171 7
        $isList = 0;
172 7
        $name = preg_replace('~^(.*)\[\]$~', '$1', $name, -1, $isList);
173 7
        $name = $this->adjustNamespace($method, $name);
174 7
        $type = $this->getTypeFromRegistry($name, $isEntityId);
175
176 7
        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...
177 3
            $type = Type::listOf($type);
178
        }
179
180 7
        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...
181 7
            $type = Type::nonNull($type);
182
        }
183
184 7
        return $type;
185
    }
186
187
    /**
188
     * Prepend namespace of the method if the class actually exists
189
     * @param ReflectionMethod $method
190
     * @param string $type
191
     * @return string
192
     */
193 7
    private function adjustNamespace(ReflectionMethod $method, string $type): string
194
    {
195 7
        $namespace = $method->getDeclaringClass()->getNamespaceName();
196 7
        if ($namespace) {
197 7
            $namespace = $namespace . '\\';
198
        }
199 7
        $namespacedType = $namespace . $type;
200
201 7
        return class_exists($namespacedType) ? $namespacedType : $type;
202
    }
203
204
    /**
205
     * Get a GraphQL type instance from PHP type hinted type, possibly looking up the content of collections
206
     * @param ReflectionMethod $method
207
     * @param string $fieldName
208
     * @throws Exception
209
     * @return Type|null
210
     */
211 5
    protected function getTypeFromReturnTypeHint(ReflectionMethod $method, string $fieldName): ?Type
212
    {
213 5
        $returnType = $method->getReturnType();
214 5
        if (!$returnType) {
215 1
            return null;
216
        }
217
218 4
        $returnTypeName = (string) $returnType;
219 4
        if (is_a($returnTypeName, Collection::class, true) || $returnTypeName === 'array') {
220 2
            $targetEntity = $this->getTargetEntity($fieldName);
221 2
            if (!$targetEntity) {
222 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.');
223
            }
224
225 1
            $type = Type::listOf($this->getTypeFromRegistry($targetEntity, false));
226 1
            if (!$returnType->allowsNull()) {
227 1
                $type = Type::nonNull($type);
228
            }
229
230 1
            return $type;
231
        }
232
233 3
        return $this->refelectionTypeToType($returnType);
234
    }
235
236
    /**
237
     * Convert a reflected type to GraphQL Type
238
     * @param ReflectionType $reflectionType
239
     * @param bool $isEntityId
240
     * @return Type
241
     */
242 4
    protected function refelectionTypeToType(ReflectionType $reflectionType, bool $isEntityId = false): Type
243
    {
244 4
        $type = $this->getTypeFromRegistry((string) $reflectionType, $isEntityId);
245 4
        if (!$reflectionType->allowsNull()) {
246 4
            $type = Type::nonNull($type);
247
        }
248
249 4
        return $type;
250
    }
251
252
    /**
253
     * Look up which field is the ID
254
     * @param string $className
255
     */
256 11
    private function findIdentityField(string $className)
257
    {
258 11
        $this->metadata = $this->entityManager->getClassMetadata($className);
259 11
        foreach ($this->metadata->fieldMappings as $meta) {
260 11
            if ($meta['id'] ?? false) {
261 11
                $this->identityField = $meta['fieldName'];
262
            }
263
        }
264 11
    }
265
266
    /**
267
     * Returns the fully qualified method name
268
     * @param ReflectionMethod $method
269
     * @return string
270
     */
271 7
    protected function getMethodFullName(ReflectionMethod $method): string
272
    {
273 7
        return '`' . $method->getDeclaringClass()->getName() . '::' . $method->getName() . '()`';
274
    }
275
276
    /**
277
     * Throws exception if type is an array
278
     * @param ReflectionParameter $param
279
     * @param string|null $type
280
     * @throws Exception
281
     */
282 7
    protected function throwIfArray(ReflectionParameter $param, ?string $type)
283
    {
284 7
        if ($type === 'array') {
285 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...
286
        }
287 6
    }
288
289
    /**
290
     * Returns whether the given field name is the identity for the entity
291
     * @param string $fieldName
292
     * @return bool
293
     */
294 9
    protected function isIdentityField(string $fieldName): bool
295
    {
296 9
        return $this->identityField === $fieldName;
297
    }
298
299
    /**
300
     * Finds the target entity in the given association
301
     * @param string $fieldName
302
     * @return string|null
303
     */
304 2
    private function getTargetEntity(string $fieldName): ?string
305
    {
306 2
        return $this->metadata->associationMappings[$fieldName]['targetEntity'] ?? null;
307
    }
308
309
    /**
310
     * Returns a type from our registry
311
     * @param string $type
312
     * @param bool $isEntityid
313
     * @return Type
314
     */
315 8
    private function getTypeFromRegistry(string $type, bool $isEntityid): Type
316
    {
317 8
        if (!$this->types->isEntity($type) || !$isEntityid) {
318 8
            return $this->types->get($type);
319
        }
320
321 2
        return $this->types->getId($type);
322
    }
323
324
    /**
325
     * Input with default values cannot be non-null
326
     * @param ReflectionParameter $param
327
     * @param Type $type
328
     * @return Type
329
     */
330 7
    protected function nonNullIfHasDefault(ReflectionParameter $param, ?Type $type): ?Type
331
    {
332 7
        if ($type instanceof NonNull && $param->isDefaultValueAvailable()) {
333 3
            return $type->getWrappedType();
334
        }
335
336 7
        return $type;
337
    }
338
339
    /**
340
     * Throws exception if argument type is invalid
341
     * @param ReflectionParameter $param
342
     * @param Type $type
343
     * @throws Exception
344
     */
345 7
    protected function throwIfNotInputType(ReflectionParameter $param, ?Type $type, string $annotation)
346
    {
347 7
        if (!$type) {
348 2
            throw new Exception('Could not find type for parameter `$' . $param->name . '` for method ' . $this->getMethodFullName($param->getDeclaringFunction()) . '. Either type hint the parameter, or specify the type with `@API\\' . $annotation . '` 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...
349
        }
350
351 5
        if ($type instanceof WrappingType) {
352 5
            $type = $type->getWrappedType(true);
353
        }
354
355 5
        if (!($type instanceof InputType)) {
356 1
            throw new Exception('Type for parameter `$' . $param->name . '` for method ' . $this->getMethodFullName($param->getDeclaringFunction()) . ' must be an instance of `' . InputType::class . '`, but was `' . get_class($type) . '`. Use `@API\\' . $annotation . '` annotation to specify a custom InputType.');
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...
357
        }
358 4
    }
359
}
360