Failed Conditions
Push — master ( 86a480...4ac9be )
by Adrien
08:28
created

getMethodFullName()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

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