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

  A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

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