Passed
Push — master ( 88bd6b...18ff69 )
by Adrien
02:03
created

AbstractFieldsConfigurationFactory   A

Complexity

Total Complexity 34

Size/Duplication

Total Lines 215
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
wmc 34
eloc 66
c 1
b 0
f 0
dl 0
loc 215
rs 9.68
ccs 78
cts 78
cp 1

12 Methods

Rating   Name   Duplication   Size   Complexity  
B create() 0 31 7
A isExcluded() 0 5 1
A getTypeFromReturnTypeHint() 0 23 6
A findIdentityField() 0 7 3
A nonNullIfHasDefault() 0 5 3
A getMethodFullName() 0 3 1
A throwIfNotInputType() 0 16 4
A getTargetEntity() 0 3 1
A getPropertyDefaultValue() 0 9 2
A reflectionTypeToType() 0 13 3
A throwIfArray() 0 4 2
A isIdentityField() 0 3 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 a GraphQL type instance from PHP type hinted type, possibly looking up the content of collections
99
     */
100 9
    final protected function getTypeFromReturnTypeHint(ReflectionMethod $method, string $fieldName): ?Type
101
    {
102 9
        $returnType = $method->getReturnType();
103 9
        if (!$returnType instanceof ReflectionNamedType) {
104 1
            return null;
105
        }
106
107 8
        $returnTypeName = $returnType->getName();
108 8
        if (is_a($returnTypeName, Collection::class, true) || $returnTypeName === 'array') {
109 3
            $targetEntity = $this->getTargetEntity($fieldName);
110 3
            if (!$targetEntity) {
111 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.');
112
            }
113
114 2
            $type = Type::listOf(Type::nonNull($this->getTypeFromRegistry($targetEntity, false)));
115 2
            if (!$returnType->allowsNull()) {
116 2
                $type = Type::nonNull($type);
117
            }
118
119 2
            return $type;
120
        }
121
122 7
        return $this->reflectionTypeToType($returnType);
123
    }
124
125
    /**
126
     * Convert a reflected type to GraphQL Type
127
     */
128 12
    final protected function reflectionTypeToType(ReflectionNamedType $reflectionType, bool $isEntityId = false): Type
129
    {
130 12
        $name = $reflectionType->getName();
131 12
        if ($name === 'self') {
132 5
            $name = $this->metadata->name;
133
        }
134
135 12
        $type = $this->getTypeFromRegistry($name, $isEntityId);
136 12
        if (!$reflectionType->allowsNull()) {
137 11
            $type = Type::nonNull($type);
138
        }
139
140 12
        return $type;
141
    }
142
143
    /**
144
     * Look up which field is the ID
145
     */
146 21
    private function findIdentityField(string $className): void
147
    {
148 21
        $this->metadata = $this->entityManager->getClassMetadata($className);
149
        /** @var array $meta */
150 21
        foreach ($this->metadata->fieldMappings as $meta) {
151 21
            if ($meta['id'] ?? false) {
152 21
                $this->identityField = $meta['fieldName'];
153
            }
154
        }
155 21
    }
156
157
    /**
158
     * Returns the fully qualified method name
159
     */
160 7
    final protected function getMethodFullName(ReflectionMethod $method): string
161
    {
162 7
        return '`' . $method->getDeclaringClass()->getName() . '::' . $method->getName() . '()`';
163
    }
164
165
    /**
166
     * Throws exception if type is an array
167
     */
168 14
    final protected function throwIfArray(ReflectionParameter $param, ?string $type): void
169
    {
170 14
        if ($type === 'array') {
171 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.');
172
        }
173 14
    }
174
175
    /**
176
     * Returns whether the given field name is the identity for the entity
177
     */
178 10
    final protected function isIdentityField(string $fieldName): bool
179
    {
180 10
        return $this->identityField === $fieldName;
181
    }
182
183
    /**
184
     * Finds the target entity in the given association
185
     */
186 3
    private function getTargetEntity(string $fieldName): ?string
187
    {
188 3
        return $this->metadata->associationMappings[$fieldName]['targetEntity'] ?? null;
189
    }
190
191
    /**
192
     * Return the default value, if any, of the property for the current entity
193
     *
194
     * It does take into account that the property might be defined on a parent class
195
     * of entity. And it will find it if that is the case.
196
     *
197
     * @return mixed
198
     */
199 6
    final protected function getPropertyDefaultValue(string $fieldName)
200
    {
201
        /** @var null|ReflectionProperty $property */
202 6
        $property = $this->metadata->getReflectionProperties()[$fieldName] ?? null;
203 6
        if (!$property) {
204 4
            return null;
205
        }
206
207 5
        return $property->getDeclaringClass()->getDefaultProperties()[$fieldName] ?? null;
208
    }
209
210
    /**
211
     * Input with default values cannot be non-null
212
     */
213 14
    final protected function nonNullIfHasDefault(AbstractAnnotation $annotation): void
214
    {
215 14
        $type = $annotation->getTypeInstance();
216 14
        if ($type instanceof NonNull && $annotation->hasDefaultValue()) {
217 9
            $annotation->setTypeInstance($type->getWrappedType());
218
        }
219 14
    }
220
221
    /**
222
     * Throws exception if argument type is invalid
223
     */
224 14
    final protected function throwIfNotInputType(ReflectionParameter $param, AbstractAnnotation $annotation): void
225
    {
226 14
        $type = $annotation->getTypeInstance();
227 14
        $class = new ReflectionClass($annotation);
228 14
        $annotationName = $class->getShortName();
229
230 14
        if (!$type) {
231 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.');
232
        }
233
234 12
        if ($type instanceof WrappingType) {
235 12
            $type = $type->getWrappedType(true);
236
        }
237
238 12
        if (!($type instanceof InputType)) {
239 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.');
240
        }
241 11
    }
242
}
243