Completed
Push — master ( 185174...32c283 )
by Adrien
01:31
created

FieldsConfigurationFactory::create()   B

Complexity

Conditions 6
Paths 5

Size

Total Lines 29
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 29
rs 8.439
c 0
b 0
f 0
cc 6
eloc 15
nc 5
nop 1
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
    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
        $this->types = $types;
49
        $this->entityManager = $entityManager;
50
    }
51
52
    /**
53
     * Create a configuration for all fields of Doctrine entity
54
     * @param string $className
55
     * @return array
56
     */
57
    public function create(string $className): array
58
    {
59
        $this->findIdentityField($className);
60
61
        $class = new ReflectionClass($className);
62
        $methods = $class->getMethods(ReflectionMethod::IS_PUBLIC);
63
        $fieldConfigurations = [];
64
        foreach ($methods as $method) {
65
            // Skip non-callable, non-instance or non-getter methods
66
            if ($method->isAbstract() || $method->isStatic()) {
67
                continue;
68
            }
69
70
            // Skip non-getter methods
71
            $name = $method->getName();
72
            if (!preg_match('~^(get|is|has)[A-Z]~', $name)) {
73
                continue;
74
            }
75
76
            // Skip exclusion specified by user
77
            if ($this->isExcluded($method)) {
78
                continue;
79
            }
80
81
            $fieldConfigurations[] = $this->methodToConfiguration($method);
82
        }
83
84
        return $fieldConfigurations;
85
    }
86
87
    /**
88
     * Returns whether the getter is excluded
89
     * @param ReflectionMethod $method
90
     * @return bool
91
     */
92
    private function isExcluded(ReflectionMethod $method): bool
93
    {
94
        $exclude = $this->getAnnotationReader()->getMethodAnnotation($method, Exclude::class);
95
96
        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
    private function getFieldDescription(ReflectionMethod $method): ?string
105
    {
106
        $comment = $method->getDocComment();
107
108
        // Remove the comment markers
109
        $comment = preg_replace('~^\s*(/\*\*|\* ?|\*/)~m', '', $comment);
110
111
        // Keep everything before the first annotation
112
        $comment = trim(explode('@', $comment)[0]);
113
114
        // Drop common "Get" or "Return" in front of comment
115
        $comment = ucfirst(preg_replace('~^(get|return)s? ~i', '', $comment));
116
117
        if (!$comment) {
118
            $comment = null;
119
        }
120
121
        return $comment;
122
    }
123
124
    private function getArgumentDescription(ReflectionParameter $param): ?string
125
    {
126
        $comment = $param->getDeclaringFunction()->getDocComment();
127
        $name = preg_quote($param->getName());
128
129
        if ($comment && preg_match('~@param\s+\S+\s+\$' . $name . '\s+(.*)~', $comment, $m)) {
130
            return ucfirst(trim($m[1]));
131
        }
132
133
        return null;
134
    }
135
136
    /**
137
     * Get annotation reader
138
     * @return Reader
139
     */
140
    private function getAnnotationReader(): Reader
141
    {
142
        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
    private function getFieldFromAnnotation(ReflectionMethod $method): Field
153
    {
154
        $field = $this->getAnnotationReader()->getMethodAnnotation($method, Field::class) ?? new Field();
155
156
        $field->type = $this->phpDeclarationToInstance($field->type);
157
        $args = [];
158
        foreach ($field->args as $arg) {
159
            $arg->type = $this->phpDeclarationToInstance($arg->type);
160
            $args[$arg->name] = $arg;
161
        }
162
        $field->args = $args;
163
164
        return $field;
165
    }
166
167
    /**
168
     * Get real instance of type, possibly non-nullable according to PHP syntax (eg: `?MyType` or `null|MyType`)
169
     * @param string|null $typeDeclaration
170
     * @return Type|null
171
     */
172
    private function phpDeclarationToInstance(?string $typeDeclaration): ?Type
173
    {
174
        if (!$typeDeclaration) {
175
            return null;
176
        }
177
178
        $name = preg_replace('~(^\?|^null\||\|null$)~', '', $typeDeclaration);
179
        $type = $this->types->get($name);
180
        if ($name === $typeDeclaration) {
181
            $type = Type::nonNull($type);
182
        }
183
184
        return $type;
185
    }
186
187
    /**
188
     * Get the entire configuration for a method
189
     * @param ReflectionMethod $method
190
     * @throws Exception
191
     * @return array
192
     */
193
    private function methodToConfiguration(ReflectionMethod $method): array
194
    {
195
        // First get user specified values
196
        $field = $this->getFieldFromAnnotation($method);
197
198
        $fieldName = lcfirst(preg_replace('~^get~', '', $method->getName()));
199
        if (!$field->name) {
200
            $field->name = $fieldName;
201
        }
202
203
        if (!$field->description) {
204
            $field->description = $this->getFieldDescription($method);
205
        }
206
207
        if ($fieldName === $this->identityField) {
208
            $field->type = Type::nonNull(Type::id());
209
        }
210
211
        // If still no type, look for type hint
212
        if (!$field->type) {
213
            $field->type = $this->getTypeFromTypeHint($method, $fieldName);
1 ignored issue
show
Documentation Bug introduced by
It seems like $this->getTypeFromTypeHint($method, $fieldName) can also be of type object<GraphQL\Type\Definition\ListOfType> or object<GraphQL\Type\Definition\Type>. However, the property $type is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
214
        }
215
216
        // If still no args, look for type hint
217
        $field->args = $this->getArgumentsFromTypeHint($method, $field->args);
218
219
        // If still no type, cannot continue
220
        if (!$field->type) {
221
            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.');
222
        }
223
224
        return $field->toArray();
225
    }
226
227
    /**
228
     * Get a GraphQL type instance from PHP type hinted type, possibly looking up the content of collections
229
     * @param ReflectionMethod $method
230
     * @param string $fieldName
231
     * @throws Exception
232
     * @return Type|null
233
     */
234
    private function getTypeFromTypeHint(ReflectionMethod $method, string $fieldName): ?Type
235
    {
236
        $returnType = $method->getReturnType();
237
        if (!$returnType) {
238
            return null;
239
        }
240
241
        $returnTypeName = (string) $returnType;
242
        if (is_a($returnTypeName, Collection::class, true)) {
243
            $mapping = $this->metadata->associationMappings[$fieldName] ?? false;
244
            if (!$mapping) {
245
                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.');
246
            }
247
248
            return Type::listOf($this->types->get($mapping['targetEntity']));
249
        }
250
251
        return $this->refelectionTypeToType($returnType);
252
    }
253
254
    /**
255
     * Convert a reflected type to GraphQL Type
256
     * @param \ReflectionType $returnType
257
     * @return Type
258
     */
259
    private function refelectionTypeToType(\ReflectionType $returnType): Type
260
    {
261
        $type = $this->types->get((string) $returnType);
262
        if (!$returnType->allowsNull()) {
263
            $type = Type::nonNull($type);
264
        }
265
266
        return $type;
267
    }
268
269
    /**
270
     * Complete arguments configuration from existing type hints
271
     * @param ReflectionMethod $method
272
     * @param array $argsFromAnnotations
273
     * @throws Exception
274
     * @return array
275
     */
276
    private function getArgumentsFromTypeHint(ReflectionMethod $method, array $argsFromAnnotations): array
277
    {
278
        $args = [];
279
        foreach ($method->getParameters() as $param) {
280
            //Either get existing, or create new argument
281
            $arg = $argsFromAnnotations[$param->getName()] ?? new Argument();
282
            $args[$param->getName()] = $arg;
283
284
            $this->completeArgumentFromTypeHint($method, $param, $arg);
285
        }
286
287
        $extraAnnotations = array_diff(array_keys($argsFromAnnotations), array_keys($args));
288
        if ($extraAnnotations) {
289
            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));
290
        }
291
292
        return $args;
293
    }
294
295
    /**
296
     * Complete a single argument from its type hint
297
     * @param ReflectionMethod $method
298
     * @param ReflectionParameter $param
299
     * @param Argument $arg
300
     * @throws Exception
301
     */
302
    private function completeArgumentFromTypeHint(ReflectionMethod $method, ReflectionParameter $param, Argument $arg)
303
    {
304
        if (!$arg->name) {
305
            $arg->name = $param->getName();
306
        }
307
308
        if (!$arg->description) {
309
            $arg->description = $this->getArgumentDescription($param);
310
        }
311
312
        if (!isset($arg->defaultValue) && $param->isDefaultValueAvailable()) {
313
            $arg->defaultValue = $param->getDefaultValue();
314
        }
315
316
        $type = $param->getType();
317
        if (!$arg->type && $type) {
318
            $arg->type = $this->refelectionTypeToType($type);
319
        }
320
321
        if (!$arg->type) {
322
            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.');
323
        }
324
    }
325
326
    /**
327
     * Look up which field is the ID
328
     * @param string $className
329
     */
330
    private function findIdentityField(string $className)
331
    {
332
        $this->metadata = $this->entityManager->getClassMetadata($className);
333
        foreach ($this->metadata->fieldMappings as $meta) {
334
            if ($meta['id'] ?? false) {
335
                $this->identityField = $meta['fieldName'];
336
            }
337
        }
338
    }
339
}
340