Completed
Pull Request — master (#4)
by
unknown
01:26
created

getAnnotationReader()   B

Complexity

Conditions 5
Paths 4

Size

Total Lines 16
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 9.2876

Importance

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