Completed
Push — master ( e5aa0e...0244fb )
by Adrien
01:59
created

phpDeclarationToInstance()   B

Complexity

Conditions 4
Paths 5

Size

Total Lines 23
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 4

Importance

Changes 0
Metric Value
dl 0
loc 23
ccs 13
cts 13
cp 1
rs 8.7972
c 0
b 0
f 0
cc 4
eloc 13
nc 5
nop 1
crap 4
1
<?php
2
3
declare(strict_types=1);
4
5
namespace GraphQL\Doctrine;
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\Argument;
12
use GraphQL\Doctrine\Annotation\Exclude;
13
use GraphQL\Doctrine\Annotation\Field;
14
use GraphQL\Type\Definition\Type;
15
use ReflectionClass;
16
use ReflectionMethod;
17
use ReflectionParameter;
18
19
/**
20
 * A factory to create a configuration for all fields of an entity
21
 */
22
class FieldsConfigurationFactory
23
{
24
    /**
25
     * @var Types
26
     */
27
    private $types;
28
29
    /**
30
     * @var EntityManager
31
     */
32
    private $entityManager;
33
34
    /**
35
     * Doctrine metadata for the entity
36
     * @var ClassMetadata
37
     */
38
    private $metadata;
39
40
    /**
41
     * The identity field name, eg: "id"
42
     * @var string
43
     */
44
    private $identityField;
45
46 7
    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...
47
    {
48 7
        $this->types = $types;
49 7
        $this->entityManager = $entityManager;
50 7
    }
51
52
    /**
53
     * Create a configuration for all fields of Doctrine entity
54
     * @param string $className
55
     * @return array
56
     */
57 7
    public function create(string $className): array
58
    {
59 7
        $this->findIdentityField($className);
60
61 7
        $class = new ReflectionClass($className);
62 7
        $methods = $class->getMethods(ReflectionMethod::IS_PUBLIC);
63 7
        $fieldConfigurations = [];
64 7
        foreach ($methods as $method) {
65
            // Skip non-callable, non-instance or non-getter methods
66 7
            if ($method->isAbstract() || $method->isStatic()) {
67 1
                continue;
68
            }
69
70
            // Skip non-getter methods
71 7
            $name = $method->getName();
72 7
            if (!preg_match('~^(get|is|has)[A-Z]~', $name)) {
73 2
                continue;
74
            }
75
76
            // Skip exclusion specified by user
77 7
            if ($this->isExcluded($method)) {
78 1
                continue;
79
            }
80
81 7
            $fieldConfigurations[] = $this->methodToConfiguration($method);
82
        }
83
84 3
        return $fieldConfigurations;
85
    }
86
87
    /**
88
     * Returns whether the getter is excluded
89
     * @param ReflectionMethod $method
90
     * @return bool
91
     */
92 7
    private function isExcluded(ReflectionMethod $method): bool
93
    {
94 7
        $exclude = $this->getAnnotationReader()->getMethodAnnotation($method, Exclude::class);
95
96 7
        return $exclude !== null;
97
    }
98
99
    /**
100
     * Get the description of a method from the docblock
101
     * @param ReflectionMethod $method
102
     * @return string|null
103
     */
104 7
    private function getFieldDescription(ReflectionMethod $method): ?string
105
    {
106 7
        $comment = $method->getDocComment();
107
108
        // Remove the comment markers
109 7
        $comment = preg_replace('~^\s*(/\*\*|\* ?|\*/)~m', '', $comment);
110
111
        // Keep everything before the first annotation
112 7
        $comment = trim(explode('@', $comment)[0]);
113
114
        // Drop common "Get" or "Return" in front of comment
115 7
        $comment = ucfirst(preg_replace('~^(get|return)s? ~i', '', $comment));
116
117 7
        if (!$comment) {
118 7
            $comment = null;
119
        }
120
121 7
        return $comment;
122
    }
123
124 4
    private function getArgumentDescription(ReflectionParameter $param): ?string
125
    {
126 4
        $comment = $param->getDeclaringFunction()->getDocComment();
127 4
        $name = preg_quote($param->getName());
128
129 4
        if ($comment && preg_match('~@param\s+\S+\s+\$' . $name . '\s+(.*)~', $comment, $m)) {
130 2
            return ucfirst(trim($m[1]));
131
        }
132
133 2
        return null;
134
    }
135
136
    /**
137
     * Get annotation reader
138
     * @return Reader
139
     */
140 7
    private function getAnnotationReader(): Reader
141
    {
142 7
        return $this->entityManager->getConfiguration()->getMetadataDriverImpl()->getReader();
143
    }
144
145
    /**
146
     * Get a field from annotation, or an empty one
147
     * All its types will be converted from string to real instance of Type
148
     *
149
     * @param ReflectionMethod $method
150
     * @return Field
151
     */
152 7
    private function getFieldFromAnnotation(ReflectionMethod $method): Field
153
    {
154 7
        $field = $this->getAnnotationReader()->getMethodAnnotation($method, Field::class) ?? new Field();
155
156 7
        $field->type = $this->phpDeclarationToInstance($field->type);
157 7
        $args = [];
158 7
        foreach ($field->args as $arg) {
159 2
            $arg->type = $this->phpDeclarationToInstance($arg->type);
160 2
            $args[$arg->name] = $arg;
161
        }
162 7
        $field->args = $args;
163
164 7
        return $field;
165
    }
166
167
    /**
168
     * Get instance of GraphQL type from a PHP class name
169
     *
170
     * Supported syntaxes are the following:
171
     *
172
     *  - `?MyType`
173
     *  - `null|MyType`
174
     *  - `MyType|null`
175
     *  - `array<MyType>`
176
     *  - `?array<MyType>`
177
     *  - `null|array<MyType>`
178
     *  - `array<MyType>|null`
179
     *
180
     * @param string|null $typeDeclaration
181
     * @return Type|null
182
     */
183 7
    private function phpDeclarationToInstance(?string $typeDeclaration): ?Type
184
    {
185 7
        if (!$typeDeclaration) {
186 7
            return null;
187
        }
188
189 2
        $isNullable = 0;
190 2
        $name = preg_replace('~(^\?|^null\||\|null$)~', '', $typeDeclaration, -1, $isNullable);
191
192 2
        $isList = 0;
193 2
        $name = preg_replace('~^array<(.*)>$~', '$1', $name, -1, $isList);
194 2
        $type = $this->types->get($name);
195
196 2
        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...
197 1
            $type = Type::listOf($type);
198
        }
199
200 2
        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...
201 2
            $type = Type::nonNull($type);
202
        }
203
204 2
        return $type;
205
    }
206
207
    /**
208
     * Get the entire configuration for a method
209
     * @param ReflectionMethod $method
210
     * @throws Exception
211
     * @return array
212
     */
213 7
    private function methodToConfiguration(ReflectionMethod $method): array
214
    {
215
        // First get user specified values
216 7
        $field = $this->getFieldFromAnnotation($method);
217
218 7
        $fieldName = lcfirst(preg_replace('~^get~', '', $method->getName()));
219 7
        if (!$field->name) {
220 7
            $field->name = $fieldName;
221
        }
222
223 7
        if (!$field->description) {
224 7
            $field->description = $this->getFieldDescription($method);
225
        }
226
227 7
        if ($fieldName === $this->identityField) {
228 3
            $field->type = Type::nonNull(Type::id());
229
        }
230
231
        // If still no type, look for type hint
232 7
        if (!$field->type) {
233 6
            $field->type = $this->getTypeFromTypeHint($method, $fieldName);
234
        }
235
236
        // If still no args, look for type hint
237 6
        $field->args = $this->getArgumentsFromTypeHint($method, $field->args);
238
239
        // If still no type, cannot continue
240 4
        if (!$field->type) {
241 1
            throw new Exception('Could not find type for method `' . $method->getDeclaringClass()->getName() . '::' . $method->getName() . '()`. Either type hint the return value, or specify the type with `@API\Field` annotation.');
242
        }
243
244 3
        return $field->toArray();
245
    }
246
247
    /**
248
     * Get a GraphQL type instance from PHP type hinted type, possibly looking up the content of collections
249
     * @param ReflectionMethod $method
250
     * @param string $fieldName
251
     * @throws Exception
252
     * @return Type|null
253
     */
254 6
    private function getTypeFromTypeHint(ReflectionMethod $method, string $fieldName): ?Type
255
    {
256 6
        $returnType = $method->getReturnType();
257 6
        if (!$returnType) {
258 1
            return null;
259
        }
260
261 5
        $returnTypeName = (string) $returnType;
262 5
        if (is_a($returnTypeName, Collection::class, true) || $returnTypeName === 'array') {
263 2
            $mapping = $this->metadata->associationMappings[$fieldName] ?? false;
264 2
            if (!$mapping) {
265 1
                throw new Exception('The method `' . $method->getDeclaringClass()->getName() . '::' . $method->getName() . '()` 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.');
266
            }
267
268 1
            return Type::listOf($this->types->get($mapping['targetEntity']));
269
        }
270
271 4
        return $this->refelectionTypeToType($returnType);
272
    }
273
274
    /**
275
     * Convert a reflected type to GraphQL Type
276
     * @param \ReflectionType $returnType
277
     * @return Type
278
     */
279 4
    private function refelectionTypeToType(\ReflectionType $returnType): Type
280
    {
281 4
        $type = $this->types->get((string) $returnType);
282 4
        if (!$returnType->allowsNull()) {
283 4
            $type = Type::nonNull($type);
284
        }
285
286 4
        return $type;
287
    }
288
289
    /**
290
     * Complete arguments configuration from existing type hints
291
     * @param ReflectionMethod $method
292
     * @param Argument[] $argsFromAnnotations
293
     * @throws Exception
294
     * @return array
295
     */
296 6
    private function getArgumentsFromTypeHint(ReflectionMethod $method, array $argsFromAnnotations): array
297
    {
298 6
        $args = [];
299 6
        foreach ($method->getParameters() as $param) {
300
            //Either get existing, or create new argument
301 4
            $arg = $argsFromAnnotations[$param->getName()] ?? new Argument();
302 4
            $args[$param->getName()] = $arg;
303
304 4
            $this->completeArgumentFromTypeHint($method, $param, $arg);
305
        }
306
307 5
        $extraAnnotations = array_diff(array_keys($argsFromAnnotations), array_keys($args));
308 5
        if ($extraAnnotations) {
309 1
            throw new Exception('The following arguments were declared via `@API\Argument` annotation but do not match actual parameter names on method `' . $method->getDeclaringClass()->getName() . '::' . $method->getName() . '()`. Either rename or remove the annotations: ' . implode(', ', $extraAnnotations));
310
        }
311
312 4
        return $args;
313
    }
314
315
    /**
316
     * Complete a single argument from its type hint
317
     * @param ReflectionMethod $method
318
     * @param ReflectionParameter $param
319
     * @param Argument $arg
320
     * @throws Exception
321
     */
322 4
    private function completeArgumentFromTypeHint(ReflectionMethod $method, ReflectionParameter $param, Argument $arg)
323
    {
324 4
        if (!$arg->name) {
325 3
            $arg->name = $param->getName();
326
        }
327
328 4
        if (!$arg->description) {
329 4
            $arg->description = $this->getArgumentDescription($param);
330
        }
331
332 4
        if (!isset($arg->defaultValue) && $param->isDefaultValueAvailable()) {
333 1
            $arg->defaultValue = $param->getDefaultValue();
334
        }
335
336 4
        $type = $param->getType();
337 4
        if (!$arg->type && $type) {
338 2
            $arg->type = $this->refelectionTypeToType($type);
339
        }
340
341 4
        if (!$arg->type) {
342 1
            throw new Exception('Could not find type for argument `' . $arg->name . '` for method `' . $method->getDeclaringClass()->getName() . '::' . $method->getName() . '()`. Either type hint the parameter, or specify the type with `@API\Argument` annotation.');
343
        }
344 3
    }
345
346
    /**
347
     * Look up which field is the ID
348
     * @param string $className
349
     */
350 7
    private function findIdentityField(string $className)
351
    {
352 7
        $this->metadata = $this->entityManager->getClassMetadata($className);
353 7
        foreach ($this->metadata->fieldMappings as $meta) {
354 7
            if ($meta['id'] ?? false) {
355 7
                $this->identityField = $meta['fieldName'];
356
            }
357
        }
358 7
    }
359
}
360