Completed
Push — master ( 3aac06...366db0 )
by Adrien
01:53
created

AbstractFieldsConfigurationFactory::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

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