Failed Conditions
Pull Request — master (#10)
by Adrien
02:25
created

AbstractFieldsConfigurationFactory::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

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