Completed
Pull Request — master (#4)
by
unknown
02:06
created

findIdentityField()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 3

Importance

Changes 0
Metric Value
dl 0
loc 9
ccs 6
cts 6
cp 1
rs 9.6666
c 0
b 0
f 0
cc 3
eloc 5
nc 3
nop 1
crap 3
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
        } else if ($mappingDriver instanceof MappingDriverChain) {
139
            foreach ($mappingDriver->getDrivers() as $driver) {
140
                if ($driver instanceof AnnotationDriver) {
141
                    return $driver->getReader();
142
                }
143
            }
144
        }
145
146
        throw new \Exception('AnnotationDriver not found in the entityManager');
147
    }
148
149
    /**
150
     * Get instance of GraphQL type from a PHP class name
151
     *
152
     * Supported syntaxes are the following:
153
     *
154
     *  - `?MyType`
155
     *  - `null|MyType`
156
     *  - `MyType|null`
157
     *  - `MyType[]`
158
     *  - `?MyType[]`
159
     *  - `null|MyType[]`
160
     *  - `MyType[]|null`
161
     *
162
     * @param ReflectionMethod $method
163
     * @param null|string $typeDeclaration
164
     * @param bool $isEntityId
165
     *
166
     * @return null|Type
167
     */
168 12
    protected function getTypeFromPhpDeclaration(ReflectionMethod $method, ?string $typeDeclaration, bool $isEntityId = false): ?Type
169
    {
170 12
        if (!$typeDeclaration) {
171 12
            return null;
172
        }
173
174 8
        $isNullable = 0;
175 8
        $name = preg_replace('~(^\?|^null\||\|null$)~', '', $typeDeclaration, -1, $isNullable);
176
177 8
        $isList = 0;
178 8
        $name = preg_replace('~^(.*)\[\]$~', '$1', $name, -1, $isList);
179 8
        $name = $this->adjustNamespace($method, $name);
180 8
        $type = $this->getTypeFromRegistry($name, $isEntityId);
181
182 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...
183 4
            $type = Type::listOf($type);
184
        }
185
186 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...
187 8
            $type = Type::nonNull($type);
188
        }
189
190 8
        return $type;
191
    }
192
193
    /**
194
     * Prepend namespace of the method if the class actually exists
195
     *
196
     * @param ReflectionMethod $method
197
     * @param string $type
198
     *
199
     * @return string
200
     */
201 8
    private function adjustNamespace(ReflectionMethod $method, string $type): string
202
    {
203 8
        $namespace = $method->getDeclaringClass()->getNamespaceName();
204 8
        if ($namespace) {
205 8
            $namespace = $namespace . '\\';
206
        }
207 8
        $namespacedType = $namespace . $type;
208
209 8
        return class_exists($namespacedType) ? $namespacedType : $type;
210
    }
211
212
    /**
213
     * Get a GraphQL type instance from PHP type hinted type, possibly looking up the content of collections
214
     *
215
     * @param ReflectionMethod $method
216
     * @param string $fieldName
217
     *
218
     * @throws Exception
219
     *
220
     * @return null|Type
221
     */
222 6
    protected function getTypeFromReturnTypeHint(ReflectionMethod $method, string $fieldName): ?Type
223
    {
224 6
        $returnType = $method->getReturnType();
225 6
        if (!$returnType) {
226 1
            return null;
227
        }
228
229 5
        $returnTypeName = (string) $returnType;
230 5
        if (is_a($returnTypeName, Collection::class, true) || $returnTypeName === 'array') {
231 3
            $targetEntity = $this->getTargetEntity($fieldName);
232 3
            if (!$targetEntity) {
233 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.');
234
            }
235
236 2
            $type = Type::listOf($this->getTypeFromRegistry($targetEntity, false));
237 2
            if (!$returnType->allowsNull()) {
238 2
                $type = Type::nonNull($type);
239
            }
240
241 2
            return $type;
242
        }
243
244 4
        return $this->reflectionTypeToType($returnType);
245
    }
246
247
    /**
248
     * Convert a reflected type to GraphQL Type
249
     *
250
     * @param ReflectionType $reflectionType
251
     * @param bool $isEntityId
252
     *
253
     * @return Type
254
     */
255 5
    protected function reflectionTypeToType(ReflectionType $reflectionType, bool $isEntityId = false): Type
256
    {
257 5
        $type = $this->getTypeFromRegistry((string) $reflectionType, $isEntityId);
258 5
        if (!$reflectionType->allowsNull()) {
259 5
            $type = Type::nonNull($type);
260
        }
261
262 5
        return $type;
263
    }
264
265
    /**
266
     * Look up which field is the ID
267
     *
268
     * @param string $className
269
     */
270 12
    private function findIdentityField(string $className): void
271
    {
272 12
        $this->metadata = $this->entityManager->getClassMetadata($className);
273 12
        foreach ($this->metadata->fieldMappings as $meta) {
274 12
            if ($meta['id'] ?? false) {
275 12
                $this->identityField = $meta['fieldName'];
276
            }
277
        }
278 12
    }
279
280
    /**
281
     * Returns the fully qualified method name
282
     *
283
     * @param ReflectionMethod $method
284
     *
285
     * @return string
286
     */
287 7
    protected function getMethodFullName(ReflectionMethod $method): string
288
    {
289 7
        return '`' . $method->getDeclaringClass()->getName() . '::' . $method->getName() . '()`';
290
    }
291
292
    /**
293
     * Throws exception if type is an array
294
     *
295
     * @param ReflectionParameter $param
296
     * @param null|string $type
297
     *
298
     * @throws Exception
299
     */
300 8
    protected function throwIfArray(ReflectionParameter $param, ?string $type): void
301
    {
302 8
        if ($type === 'array') {
303 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...
304
        }
305 7
    }
306
307
    /**
308
     * Returns whether the given field name is the identity for the entity
309
     *
310
     * @param string $fieldName
311
     *
312
     * @return bool
313
     */
314 10
    protected function isIdentityField(string $fieldName): bool
315
    {
316 10
        return $this->identityField === $fieldName;
317
    }
318
319
    /**
320
     * Finds the target entity in the given association
321
     *
322
     * @param string $fieldName
323
     *
324
     * @return null|string
325
     */
326 3
    private function getTargetEntity(string $fieldName): ?string
327
    {
328 3
        return $this->metadata->associationMappings[$fieldName]['targetEntity'] ?? null;
329
    }
330
331
    /**
332
     * Returns a type from our registry
333
     *
334
     * @param string $type
335
     * @param bool $isEntityId
336
     *
337
     * @return Type
338
     */
339 9
    private function getTypeFromRegistry(string $type, bool $isEntityId): Type
340
    {
341 9
        if (!$this->types->isEntity($type) || !$isEntityId) {
342 9
            return $this->types->get($type);
343
        }
344
345 3
        return $this->types->getId($type);
346
    }
347
348
    /**
349
     * Input with default values cannot be non-null
350
     *
351
     * @param ReflectionParameter $param
352
     * @param Type $type
353
     *
354
     * @return Type
355
     */
356 8
    protected function nonNullIfHasDefault(ReflectionParameter $param, ?Type $type): ?Type
357
    {
358 8
        if ($type instanceof NonNull && $param->isDefaultValueAvailable()) {
359 4
            return $type->getWrappedType();
360
        }
361
362 8
        return $type;
363
    }
364
365
    /**
366
     * Throws exception if argument type is invalid
367
     *
368
     * @param ReflectionParameter $param
369
     * @param Type $type
370
     * @param string $annotation
371
     *
372
     * @throws Exception
373
     */
374 8
    protected function throwIfNotInputType(ReflectionParameter $param, ?Type $type, string $annotation): void
375
    {
376 8
        if (!$type) {
377 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...
378
        }
379
380 6
        if ($type instanceof WrappingType) {
381 6
            $type = $type->getWrappedType(true);
382
        }
383
384 6
        if (!($type instanceof InputType)) {
385 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...
386
        }
387 5
    }
388
}
389