Completed
Push — master ( e148c5...0b9e42 )
by Adrien
02:15
created

getPropertyDefaultValue()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

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