Completed
Pull Request — master (#2)
by Adrien
02:16
created

AbstractFieldsConfigurationFactory::throwIfArray()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 6
ccs 4
cts 4
cp 1
rs 9.4285
c 0
b 0
f 0
cc 2
eloc 3
nc 2
nop 2
crap 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace GraphQL\Doctrine\Factory;
6
7
use Doctrine\Common\Annotations\AnnotationReader;
8
use Doctrine\Common\Annotations\Reader;
9
use Doctrine\Common\Collections\Collection;
10
use Doctrine\Common\Persistence\Mapping\Driver\AnnotationDriver;
11
use Doctrine\Common\Persistence\Mapping\Driver\MappingDriverChain;
12
use Doctrine\ORM\EntityManager;
13
use Doctrine\ORM\Mapping\ClassMetadata;
14
use GraphQL\Doctrine\Annotation\Exclude;
15
use GraphQL\Doctrine\Exception;
16
use GraphQL\Doctrine\Types;
17
use GraphQL\Type\Definition\InputType;
18
use GraphQL\Type\Definition\NonNull;
19
use GraphQL\Type\Definition\Type;
20
use GraphQL\Type\Definition\WrappingType;
21
use ReflectionClass;
22
use ReflectionMethod;
23
use ReflectionParameter;
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
    private $types;
35
36
    /**
37
     * @var EntityManager
38
     */
39
    private $entityManager;
40
41
    /**
42
     * Doctrine metadata for the entity
43
     * @var ClassMetadata
44
     */
45
    private $metadata;
46
47
    /**
48
     * The identity field name, eg: "id"
49
     * @var string
50
     */
51
    private $identityField;
52
53
    public function __construct(Types $types, EntityManager $entityManager)
54
    {
55
        $this->types = $types;
56
        $this->entityManager = $entityManager;
57
    }
58
59
    /**
60
     * Returns the regexp pattern to filter method names
61
     */
62
    abstract protected function getMethodPattern(): string;
63
64
    /**
65
     * Get the entire configuration for a method
66
     * @param ReflectionMethod $method
67
     * @return array
68
     */
69
    abstract protected function methodToConfiguration(ReflectionMethod $method): ?array;
70
71
    /**
72
     * Create a configuration for all fields of Doctrine entity
73
     * @param string $className
74
     * @return array
75
     */
76
    public function create(string $className): array
77
    {
78
        $this->findIdentityField($className);
79
80
        $class = new ReflectionClass($className);
81
        $methods = $class->getMethods(ReflectionMethod::IS_PUBLIC);
82
        $fieldConfigurations = [];
83
        foreach ($methods as $method) {
84
            // Skip non-callable, non-instance or non-getter methods
85
            if ($method->isAbstract() || $method->isStatic()) {
86
                continue;
87
            }
88
89
            // Skip non-getter methods
90
            $name = $method->getName();
91
            if (!preg_match($this->getMethodPattern(), $name)) {
92
                continue;
93
            }
94
95
            // Skip exclusion specified by user
96
            if ($this->isExcluded($method)) {
97
                continue;
98
            }
99
100
            $configuration = $this->methodToConfiguration($method);
101
            if ($configuration) {
102
                $fieldConfigurations[] = $configuration;
103
            }
104
        }
105
106
        return $fieldConfigurations;
107
    }
108
109
    /**
110
     * Returns whether the getter is excluded
111
     * @param ReflectionMethod $method
112
     * @return bool
113
     */
114
    private function isExcluded(ReflectionMethod $method): bool
115
    {
116
        $exclude = $this->getAnnotationReader()->getMethodAnnotation($method, Exclude::class);
117
118
        return $exclude !== null;
119
    }
120
121
    /**
122
     * Get annotation reader
123
     * @return Reader
124
     */
125
    protected function getAnnotationReader(): Reader
126
    {
127
        $driver = $this->entityManager->getConfiguration()->getMetadataDriverImpl();
128
        if ($driver instanceof MappingDriverChain::class) {
0 ignored issues
show
Bug introduced by
This code did not parse for me. Apparently, there is an error somewhere around this line:

Syntax error, unexpected T_CLASS, expecting T_VARIABLE or '$'
Loading history...
129
            $drivers = $driver->getDrivers();
130
            foreach ($drivers as $driver) {
131
                if ($driver instanceof AnnotationDriver::class) {
132
                    break;
133
                }
134
            }
135
        }
136
        
137
        if ($driver instanceof AnnotationDriver::class) {
138
            return $driver->getReader();
139
        }
140
        
141
        return new AnnotationReader();
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 string|null $typeDeclaration
159
     * @param bool $isEntityId
160
     * @return Type|null
161
     */
162
    protected function getTypeFromPhpDeclaration(ReflectionMethod $method, ?string $typeDeclaration, bool $isEntityId = false): ?Type
163
    {
164
        if (!$typeDeclaration) {
165
            return null;
166
        }
167
168
        $isNullable = 0;
169
        $name = preg_replace('~(^\?|^null\||\|null$)~', '', $typeDeclaration, -1, $isNullable);
170
171
        $isList = 0;
172
        $name = preg_replace('~^(.*)\[\]$~', '$1', $name, -1, $isList);
173
        $name = $this->adjustNamespace($method, $name);
174
        $type = $this->getTypeFromRegistry($name, $isEntityId);
175
176
        if ($isList) {
177
            $type = Type::listOf($type);
178
        }
179
180
        if (!$isNullable) {
181
            $type = Type::nonNull($type);
182
        }
183
184
        return $type;
185
    }
186
187
    /**
188
     * Prepend namespace of the method if the class actually exists
189
     * @param ReflectionMethod $method
190
     * @param string $type
191
     * @return string
192
     */
193
    private function adjustNamespace(ReflectionMethod $method, string $type): string
194
    {
195
        $namespace = $method->getDeclaringClass()->getNamespaceName();
196
        if ($namespace) {
197
            $namespace = $namespace . '\\';
198
        }
199
        $namespacedType = $namespace . $type;
200
201
        return class_exists($namespacedType) ? $namespacedType : $type;
202
    }
203
204
    /**
205
     * Get a GraphQL type instance from PHP type hinted type, possibly looking up the content of collections
206
     * @param ReflectionMethod $method
207
     * @param string $fieldName
208
     * @throws Exception
209
     * @return Type|null
210
     */
211
    protected function getTypeFromReturnTypeHint(ReflectionMethod $method, string $fieldName): ?Type
212
    {
213
        $returnType = $method->getReturnType();
214
        if (!$returnType) {
215
            return null;
216
        }
217
218
        $returnTypeName = (string) $returnType;
219
        if (is_a($returnTypeName, Collection::class, true) || $returnTypeName === 'array') {
220
            $targetEntity = $this->getTargetEntity($fieldName);
221
            if (!$targetEntity) {
222
                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.');
223
            }
224
225
            $type = Type::listOf($this->getTypeFromRegistry($targetEntity, false));
226
            if (!$returnType->allowsNull()) {
227
                $type = Type::nonNull($type);
228
            }
229
230
            return $type;
231
        }
232
233
        return $this->refelectionTypeToType($returnType);
234
    }
235
236
    /**
237
     * Convert a reflected type to GraphQL Type
238
     * @param ReflectionType $reflectionType
239
     * @param bool $isEntityId
240
     * @return Type
241
     */
242
    protected function refelectionTypeToType(ReflectionType $reflectionType, bool $isEntityId = false): Type
243
    {
244
        $type = $this->getTypeFromRegistry((string) $reflectionType, $isEntityId);
245
        if (!$reflectionType->allowsNull()) {
246
            $type = Type::nonNull($type);
247
        }
248
249
        return $type;
250
    }
251
252
    /**
253
     * Look up which field is the ID
254
     * @param string $className
255
     */
256
    private function findIdentityField(string $className)
257
    {
258
        $this->metadata = $this->entityManager->getClassMetadata($className);
259
        foreach ($this->metadata->fieldMappings as $meta) {
260
            if ($meta['id'] ?? false) {
261
                $this->identityField = $meta['fieldName'];
262
            }
263
        }
264
    }
265
266
    /**
267
     * Returns the fully qualified method name
268
     * @param ReflectionMethod $method
269
     * @return string
270
     */
271
    protected function getMethodFullName(ReflectionMethod $method): string
272
    {
273
        return '`' . $method->getDeclaringClass()->getName() . '::' . $method->getName() . '()`';
274
    }
275
276
    /**
277
     * Throws exception if type is an array
278
     * @param ReflectionParameter $param
279
     * @param string|null $type
280
     * @throws Exception
281
     */
282
    protected function throwIfArray(ReflectionParameter $param, ?string $type)
283
    {
284
        if ($type === 'array') {
285
            throw new Exception('The parameter `$' . $param->getName() . '` on method ' . $this->getMethodFullName($param->getDeclaringFunction()) . ' is type hinted as `array` and is not overriden via `@API\Argument` annotation. Either change the type hint or specify the type with `@API\Argument` annotation.');
286
        }
287
    }
288
289
    /**
290
     * Returns whether the given field name is the identity for the entity
291
     * @param string $fieldName
292
     * @return bool
293
     */
294
    protected function isIdentityField(string $fieldName): bool
295
    {
296
        return $this->identityField === $fieldName;
297
    }
298
299
    /**
300
     * Finds the target entity in the given association
301
     * @param string $fieldName
302
     * @return string|null
303
     */
304
    private function getTargetEntity(string $fieldName): ?string
305
    {
306
        return $this->metadata->associationMappings[$fieldName]['targetEntity'] ?? null;
307
    }
308
309
    /**
310
     * Returns a type from our registry
311
     * @param string $type
312
     * @param bool $isEntityid
313
     * @return Type
314
     */
315
    private function getTypeFromRegistry(string $type, bool $isEntityid): Type
316
    {
317
        if (!$this->types->isEntity($type) || !$isEntityid) {
318
            return $this->types->get($type);
319
        }
320
321
        return $this->types->getId($type);
322
    }
323
324
    /**
325
     * Input with default values cannot be non-null
326
     * @param ReflectionParameter $param
327
     * @param Type $type
328
     * @return Type
329
     */
330
    protected function nonNullIfHasDefault(ReflectionParameter $param, ?Type $type): ?Type
331
    {
332
        if ($type instanceof NonNull && $param->isDefaultValueAvailable()) {
333
            return $type->getWrappedType();
334
        }
335
336
        return $type;
337
    }
338
339
    /**
340
     * Throws exception if argument type is invalid
341
     * @param ReflectionParameter $param
342
     * @param Type $type
343
     * @throws Exception
344
     */
345
    protected function throwIfNotInputType(ReflectionParameter $param, ?Type $type, string $annotation)
346
    {
347
        if (!$type) {
348
            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.');
349
        }
350
351
        if ($type instanceof WrappingType) {
352
            $type = $type->getWrappedType(true);
353
        }
354
355
        if (!($type instanceof InputType)) {
356
            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.');
357
        }
358
    }
359
}
360