Passed
Pull Request — master (#79)
by David
02:19
created

RecursiveTypeMapper::mapClassToInputType()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 1
dl 0
loc 3
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 1
1
<?php
2
3
4
namespace TheCodingMachine\GraphQL\Controllers\Mappers;
5
6
7
use function array_flip;
8
use function array_reverse;
9
use function get_parent_class;
10
use GraphQL\Type\Definition\InputObjectType;
11
use GraphQL\Type\Definition\InputType;
12
use GraphQL\Type\Definition\InterfaceType;
13
use GraphQL\Type\Definition\ObjectType;
14
use GraphQL\Type\Definition\OutputType;
15
use GraphQL\Type\Definition\Type;
16
use Psr\SimpleCache\CacheInterface;
17
use TheCodingMachine\GraphQL\Controllers\NamingStrategyInterface;
18
use TheCodingMachine\GraphQL\Controllers\TypeRegistry;
19
use TheCodingMachine\GraphQL\Controllers\Types\InterfaceFromObjectType;
20
use TheCodingMachine\GraphQL\Controllers\Types\MutableObjectType;
21
22
/**
23
 * This class wraps a TypeMapperInterface into a RecursiveTypeMapperInterface.
24
 * While the wrapped class does only tests one given class, the recursive type mapper
25
 * tests the class and all its parents.
26
 */
27
class RecursiveTypeMapper implements RecursiveTypeMapperInterface
28
{
29
    /**
30
     * @var TypeMapperInterface
31
     */
32
    private $typeMapper;
33
34
    /**
35
     * An array mapping a class name to the MappedClass instance (useful to know if the class has children)
36
     *
37
     * @var array<string,MappedClass>|null
38
     */
39
    private $mappedClasses;
40
41
    /**
42
     * An array of interfaces OR object types if no interface matching.
43
     *
44
     * @var array<string,OutputType&Type>
45
     */
46
    private $interfaces = [];
47
48
    /**
49
     * @var NamingStrategyInterface
50
     */
51
    private $namingStrategy;
52
53
    /**
54
     * @var CacheInterface
55
     */
56
    private $cache;
57
58
    /**
59
     * @var int|null
60
     */
61
    private $ttl;
62
63
    /**
64
     * @var array<string, string> An array mapping a GraphQL interface name to the PHP class name that triggered its generation.
65
     */
66
    private $interfaceToClassNameMap;
67
68
    /**
69
     * @var TypeRegistry
70
     */
71
    private $typeRegistry;
72
73
74
    public function __construct(TypeMapperInterface $typeMapper, NamingStrategyInterface $namingStrategy, CacheInterface $cache, TypeRegistry $typeRegistry, ?int $ttl = null)
75
    {
76
        $this->typeMapper = $typeMapper;
77
        $this->namingStrategy = $namingStrategy;
78
        $this->cache = $cache;
79
        $this->ttl = $ttl;
80
        $this->typeRegistry = $typeRegistry;
81
    }
82
83
    /**
84
     * Returns true if this type mapper can map the $className FQCN to a GraphQL type.
85
     *
86
     * @param string $className The class name to look for (this function looks into parent classes if the class does not match a type).
87
     * @return bool
88
     */
89
    public function canMapClassToType(string $className): bool
90
    {
91
        return $this->findClosestMatchingParent($className) !== null;
92
    }
93
94
    /**
95
     * Maps a PHP fully qualified class name to a GraphQL type.
96
     *
97
     * @param string $className The class name to look for (this function looks into parent classes if the class does not match a type)
98
     * @param (OutputType&MutableObjectType)|(OutputType&InterfaceType)|null $subType An optional sub-type if the main class is an iterator that needs to be typed.
99
     * @return MutableObjectType
100
     * @throws CannotMapTypeExceptionInterface
101
     */
102
    public function mapClassToType(string $className, ?OutputType $subType): MutableObjectType
103
    {
104
        $closestClassName = $this->findClosestMatchingParent($className);
105
        if ($closestClassName === null) {
106
            throw CannotMapTypeException::createForType($className);
107
        }
108
        $type = $this->typeMapper->mapClassToType($closestClassName, $subType, $this);
109
110
        // In the event this type was already part of cache, let's not extend it.
111
        if ($this->typeRegistry->hasType($type->name)) {
112
            $cachedType = $this->typeRegistry->getType($type->name);
113
            if ($cachedType !== $type) {
0 ignored issues
show
introduced by
The condition $cachedType !== $type is always true.
Loading history...
114
                throw new \RuntimeException('Cached type in registry is not the type returned by type mapper.');
115
            }
116
            //if ($cachedType->getStatus() === MutableObjectType::STATUS_FROZEN) {
117
                return $type;
118
            //}
119
        }
120
121
        $this->typeRegistry->registerType($type);
122
123
        $this->extendType($className, $type);
124
125
        $type->freeze();
126
127
        return $type;
128
    }
129
130
    /**
131
     * Returns the closest parent that can be mapped, or null if nothing can be matched.
132
     *
133
     * @param string $className
134
     * @return string|null
135
     */
136
    private function findClosestMatchingParent(string $className): ?string
137
    {
138
        do {
139
            if ($this->typeMapper->canMapClassToType($className)) {
140
                return $className;
141
            }
142
        } while ($className = get_parent_class($className));
143
        return null;
144
    }
145
146
    /**
147
     * Extends a type using available type extenders.
148
     *
149
     * @param string $className
150
     * @param MutableObjectType $type
151
     * @throws CannotMapTypeExceptionInterface
152
     */
153
    private function extendType(string $className, MutableObjectType $type): void
154
    {
155
        $classes = [];
156
        do {
157
            if ($this->typeMapper->canExtendTypeForClass($className, $type, $this)) {
158
                $classes[] = $className;
159
            }
160
        } while ($className = get_parent_class($className));
161
162
        // Let's apply extenders from the most basic type.
163
        $classes = array_reverse($classes);
164
        foreach ($classes as $class) {
165
            $this->typeMapper->extendTypeForClass($class, $type, $this);
166
        }
167
    }
168
169
    /**
170
     * Maps a PHP fully qualified class name to a GraphQL type. Returns an interface if possible (if the class
171
     * has children) or returns an output type otherwise.
172
     *
173
     * @param string $className The exact class name to look for (this function does not look into parent classes).
174
     * @param (OutputType&ObjectType)|(OutputType&InterfaceType)|null $subType A subtype (if the main className is an iterator)
175
     * @return OutputType&Type
176
     * @throws CannotMapTypeExceptionInterface
177
     */
178
    public function mapClassToInterfaceOrType(string $className, ?OutputType $subType): OutputType
179
    {
180
        $closestClassName = $this->findClosestMatchingParent($className);
181
        if ($closestClassName === null) {
182
            throw CannotMapTypeException::createForType($className);
183
        }
184
        if (!isset($this->interfaces[$closestClassName])) {
185
            $objectType = $this->mapClassToType($className, $subType);
186
187
            $supportedClasses = $this->getClassTree();
188
            if (isset($supportedClasses[$closestClassName]) && !empty($supportedClasses[$closestClassName]->getChildren())) {
189
                // Cast as an interface
190
                $this->interfaces[$closestClassName] = new InterfaceFromObjectType($this->namingStrategy->getInterfaceNameFromConcreteName($objectType->name), $objectType, $subType, $this);
191
                $this->typeRegistry->registerType($this->interfaces[$closestClassName]);
192
            } else {
193
                $this->interfaces[$closestClassName] = $objectType;
194
            }
195
        }
196
        return $this->interfaces[$closestClassName];
197
    }
198
199
    /**
200
     * Build a map mapping GraphQL interface names to the PHP class name of the object creating this interface.
201
     *
202
     * @return array<string, string>
203
     */
204
    private function buildInterfaceToClassNameMap(): array
205
    {
206
        $map = [];
207
        $supportedClasses = $this->getClassTree();
208
        foreach ($supportedClasses as $className => $mappedClass) {
209
            if (!empty($mappedClass->getChildren())) {
210
                $objectType = $this->mapClassToType($className, null);
211
                $interfaceName = $this->namingStrategy->getInterfaceNameFromConcreteName($objectType->name);
212
                $map[$interfaceName] = $className;
213
            }
214
        }
215
        return $map;
216
    }
217
218
    /**
219
     * Returns a map mapping GraphQL interface names to the PHP class name of the object creating this interface.
220
     * The map may come from the cache.
221
     *
222
     * @return array<string, string>
223
     */
224
    private function getInterfaceToClassNameMap(): array
225
    {
226
        if ($this->interfaceToClassNameMap === null) {
227
            $key = 'recursiveTypeMapper_interfaceToClassNameMap';
228
            $this->interfaceToClassNameMap = $this->cache->get($key);
229
            if ($this->interfaceToClassNameMap === null) {
230
                $this->interfaceToClassNameMap = $this->buildInterfaceToClassNameMap();
231
                // This is a very short lived cache. Useful to avoid overloading a server in case of heavy load.
232
                // Defaults to 2 seconds.
233
                $this->cache->set($key, $this->interfaceToClassNameMap, $this->ttl);
234
            }
235
        }
236
        return $this->interfaceToClassNameMap;
237
    }
238
239
    /**
240
     * Finds the list of interfaces returned by $className.
241
     *
242
     * @param string $className
243
     * @return InterfaceType[]
244
     */
245
    public function findInterfaces(string $className): array
246
    {
247
        $interfaces = [];
248
        while ($className = $this->findClosestMatchingParent($className)) {
249
            $type = $this->mapClassToInterfaceOrType($className, null);
250
            if ($type instanceof InterfaceType) {
251
                $interfaces[] = $type;
252
            }
253
            $className = get_parent_class($className);
254
            if ($className === false) {
255
                break;
256
            }
257
        }
258
        return $interfaces;
259
    }
260
261
    /**
262
     * @return array<string,MappedClass>
263
     */
264
    private function getClassTree(): array
265
    {
266
        if ($this->mappedClasses === null) {
267
            $supportedClasses = array_flip($this->typeMapper->getSupportedClasses());
268
            foreach ($supportedClasses as $supportedClass => $foo) {
269
                $this->getMappedClass($supportedClass, $supportedClasses);
270
            }
271
        }
272
        return $this->mappedClasses;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->mappedClasses could return the type null which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
273
    }
274
275
    /**
276
     * @param string $className
277
     * @param array<string,int> $supportedClasses
278
     * @return MappedClass
279
     */
280
    private function getMappedClass(string $className, array $supportedClasses): MappedClass
281
    {
282
        if (!isset($this->mappedClasses[$className])) {
283
            $mappedClass = new MappedClass(/*$className*/);
284
            $this->mappedClasses[$className] = $mappedClass;
285
            $parentClassName = $className;
286
            while ($parentClassName = get_parent_class($parentClassName)) {
287
                if (isset($supportedClasses[$parentClassName])) {
288
                    $parentMappedClass = $this->getMappedClass($parentClassName, $supportedClasses);
289
                    //$mappedClass->setParent($parentMappedClass);
290
                    $parentMappedClass->addChild($mappedClass);
291
                    break;
292
                }
293
            }
294
        }
295
        return $this->mappedClasses[$className];
296
    }
297
298
    /**
299
     * Returns true if this type mapper can map the $className FQCN to a GraphQL input type.
300
     *
301
     * @param string $className
302
     * @return bool
303
     */
304
    public function canMapClassToInputType(string $className): bool
305
    {
306
        return $this->typeMapper->canMapClassToInputType($className);
307
    }
308
309
    /**
310
     * Maps a PHP fully qualified class name to a GraphQL input type.
311
     *
312
     * @param string $className
313
     * @return InputObjectType
314
     * @throws CannotMapTypeExceptionInterface
315
     */
316
    public function mapClassToInputType(string $className): InputObjectType
317
    {
318
        return $this->typeMapper->mapClassToInputType($className, $this);
319
    }
320
321
    /**
322
     * Returns an array containing all OutputTypes.
323
     * Needed for introspection because of interfaces.
324
     *
325
     * @return array<string, OutputType>
326
     */
327
    public function getOutputTypes(): array
328
    {
329
        $types = [];
330
        foreach ($this->typeMapper->getSupportedClasses() as $supportedClass) {
331
            $types[$supportedClass] = $this->mapClassToType($supportedClass, null);
332
        }
333
        return $types;
334
    }
335
336
    /**
337
     * Returns true if this type mapper can map the $typeName GraphQL name to a GraphQL type.
338
     *
339
     * @param string $typeName The name of the GraphQL type
340
     * @return bool
341
     */
342
    public function canMapNameToType(string $typeName): bool
343
    {
344
        $result = $this->typeMapper->canMapNameToType($typeName);
345
        if ($result === true) {
346
            return true;
347
        }
348
349
        // Maybe the type is an interface?
350
        $interfaceToClassNameMap = $this->getInterfaceToClassNameMap();
351
        if (isset($interfaceToClassNameMap[$typeName])) {
352
            return true;
353
        }
354
355
        return false;
356
    }
357
358
    /**
359
     * Returns a GraphQL type by name (can be either an input or output type)
360
     *
361
     * @param string $typeName The name of the GraphQL type
362
     * @return Type&(InputType|OutputType)
363
     */
364
    public function mapNameToType(string $typeName): Type
365
    {
366
        if ($this->typeRegistry->hasType($typeName)) {
367
            return $this->typeRegistry->getType($typeName);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->typeRegistry->getType($typeName) returns the type GraphQL\Type\Definition\NamedType which is incompatible with the type-hinted return GraphQL\Type\Definition\Type.
Loading history...
368
        }
369
        if ($this->typeMapper->canMapNameToType($typeName)) {
370
            $type = $this->typeMapper->mapNameToType($typeName, $this);
371
372
            if ($this->typeRegistry->hasType($typeName)) {
373
                $cachedType = $this->typeRegistry->getType($typeName);
374
                if ($cachedType !== $type) {
0 ignored issues
show
introduced by
The condition $cachedType !== $type is always true.
Loading history...
375
                    throw new \RuntimeException('Cached type in registry is not the type returned by type mapper.');
376
                }
377
                if ($cachedType instanceof MutableObjectType && $cachedType->getStatus() === MutableObjectType::STATUS_FROZEN) {
378
                    return $type;
379
                }
380
            }
381
382
            if (!$this->typeRegistry->hasType($typeName)) {
383
                $this->typeRegistry->registerType($type);
0 ignored issues
show
Bug introduced by
$type of type GraphQL\Type\Definition\Type is incompatible with the type GraphQL\Type\Definition\NamedType expected by parameter $type of TheCodingMachine\GraphQL...egistry::registerType(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

383
                $this->typeRegistry->registerType(/** @scrutinizer ignore-type */ $type);
Loading history...
384
            }
385
            if ($type instanceof MutableObjectType) {
386
                if ($this->typeMapper->canExtendTypeForName($typeName, $type, $this)) {
387
                    $this->typeMapper->extendTypeForName($typeName, $type, $this);
388
                }
389
                $type->freeze();
390
            }
391
            return $type;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $type could return the type GraphQL\Type\Definition\NamedType which is incompatible with the type-hinted return GraphQL\Type\Definition\Type. Consider adding an additional type-check to rule them out.
Loading history...
392
        }
393
394
        // Maybe the type is an interface?
395
        $interfaceToClassNameMap = $this->getInterfaceToClassNameMap();
396
        if (isset($interfaceToClassNameMap[$typeName])) {
397
            $className = $interfaceToClassNameMap[$typeName];
398
            return $this->mapClassToInterfaceOrType($className, null);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->mapClassTo...rType($className, null) returns the type GraphQL\Type\Definition\OutputType which is incompatible with the type-hinted return GraphQL\Type\Definition\Type.
Loading history...
399
        }
400
401
        throw CannotMapTypeException::createForName($typeName);
402
    }
403
}
404