Completed
Push — master ( 6c2ef1...d70f56 )
by David
15s queued 10s
created

RecursiveTypeMapper::findInterfaces()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 14
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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

394
                $this->typeRegistry->registerType(/** @scrutinizer ignore-type */ $type);
Loading history...
395
            }
396
            if ($type instanceof MutableObjectType) {
397
                if ($this->typeMapper->canExtendTypeForName($typeName, $type, $this)) {
398
                    $this->typeMapper->extendTypeForName($typeName, $type, $this);
399
                }
400
                $type->freeze();
401
            }
402
            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...
403
        }
404
405
        // Maybe the type is an interface?
406
        $interfaceToClassNameMap = $this->getInterfaceToClassNameMap();
407
        if (isset($interfaceToClassNameMap[$typeName])) {
408
            $className = $interfaceToClassNameMap[$typeName];
409
            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...
410
        }
411
412
        throw CannotMapTypeException::createForName($typeName);
413
    }
414
}
415