Scrutinizer GitHub App not installed

We could not synchronize checks via GitHub's checks API since Scrutinizer's GitHub App is not installed for this repository.

Install GitHub App

Passed
Pull Request — master (#735)
by Bart
08:29
created

AnnotationParser::resolveTypeFromPhpType()   B

Complexity

Conditions 8
Paths 8

Size

Total Lines 16
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 8

Importance

Changes 0
Metric Value
eloc 14
dl 0
loc 16
ccs 13
cts 13
cp 1
rs 8.4444
c 0
b 0
f 0
cc 8
nc 8
nop 1
crap 8
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Overblog\GraphQLBundle\Config\Parser;
6
7
use Doctrine\Common\Annotations\AnnotationException;
8
use Doctrine\ORM\Mapping\Column;
9
use Doctrine\ORM\Mapping\JoinColumn;
10
use Doctrine\ORM\Mapping\ManyToMany;
11
use Doctrine\ORM\Mapping\ManyToOne;
12
use Doctrine\ORM\Mapping\OneToMany;
13
use Doctrine\ORM\Mapping\OneToOne;
14
use Exception;
15
use Overblog\GraphQLBundle\Annotation as GQL;
16
use Overblog\GraphQLBundle\Config\Parser\Annotation\GraphClass;
17
use Overblog\GraphQLBundle\Relay\Connection\ConnectionInterface;
18
use Overblog\GraphQLBundle\Relay\Connection\EdgeInterface;
19
use ReflectionException;
20
use ReflectionMethod;
21
use ReflectionNamedType;
22
use ReflectionProperty;
23
use Reflector;
24
use RuntimeException;
25
use SplFileInfo;
26
use Symfony\Component\Config\Resource\FileResource;
27
use Symfony\Component\DependencyInjection\ContainerBuilder;
28
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
29
use function array_filter;
30
use function array_keys;
31
use function array_map;
32
use function array_unshift;
33
use function current;
34
use function file_get_contents;
35
use function get_class;
36
use function implode;
37
use function in_array;
38
use function is_array;
39
use function is_string;
40
use function preg_match;
41
use function sprintf;
42
use function str_replace;
43
use function strlen;
44
use function strpos;
45
use function substr;
46
use function trim;
47
48
class AnnotationParser implements PreParserInterface
49
{
50
    private static array $classesMap = [];
51
    private static array $providers = [];
52
    private static array $doctrineMapping = [];
53
    private static array $graphClassCache = [];
54
55
    private const GQL_SCALAR = 'scalar';
56
    private const GQL_ENUM = 'enum';
57
    private const GQL_TYPE = 'type';
58
    private const GQL_INPUT = 'input';
59
    private const GQL_UNION = 'union';
60
    private const GQL_INTERFACE = 'interface';
61
62
    /**
63
     * @see https://facebook.github.io/graphql/draft/#sec-Input-and-Output-Types
64
     */
65
    private const VALID_INPUT_TYPES = [self::GQL_SCALAR, self::GQL_ENUM, self::GQL_INPUT];
66
    private const VALID_OUTPUT_TYPES = [self::GQL_SCALAR, self::GQL_TYPE, self::GQL_INTERFACE, self::GQL_UNION, self::GQL_ENUM];
67
68
    /**
69
     * {@inheritdoc}
70
     *
71
     * @throws InvalidArgumentException
72
     * @throws ReflectionException
73
     */
74 25
    public static function preParse(SplFileInfo $file, ContainerBuilder $container, array $configs = []): void
75
    {
76 25
        $container->setParameter('overblog_graphql_types.classes_map', self::processFile($file, $container, $configs, true));
77 25
    }
78
79
    /**
80
     * @throws InvalidArgumentException
81
     * @throws ReflectionException
82
     */
83 25
    public static function parse(SplFileInfo $file, ContainerBuilder $container, array $configs = []): array
84
    {
85 25
        return self::processFile($file, $container, $configs, false);
86
    }
87
88
    /**
89
     * @internal
90
     */
91 63
    public static function reset(): void
92
    {
93 63
        self::$classesMap = [];
94 63
        self::$providers = [];
95 63
        self::$graphClassCache = [];
96 63
    }
97
98
    /**
99
     * Process a file.
100
     *
101
     * @throws InvalidArgumentException|ReflectionException|AnnotationException
102
     */
103 25
    private static function processFile(SplFileInfo $file, ContainerBuilder $container, array $configs, bool $preProcess): array
104
    {
105 25
        self::$doctrineMapping = $configs['doctrine']['types_mapping'];
106 25
        $container->addResource(new FileResource($file->getRealPath()));
107
108
        try {
109 25
            $className = $file->getBasename('.php');
110 25
            if (preg_match('#namespace (.+);#', file_get_contents($file->getRealPath()), $matches)) {
111 25
                $className = trim($matches[1]).'\\'.$className;
112
            }
113
114 25
            $gqlTypes = [];
115 25
            $graphClass = self::getGraphClass($className);
116
117 25
            foreach ($graphClass->getAnnotations() as $classAnnotation) {
118 25
                $gqlTypes = self::classAnnotationsToGQLConfiguration(
119 25
                    $graphClass,
120
                    $classAnnotation,
121
                    $configs,
122
                    $gqlTypes,
123
                    $preProcess
124
                );
125
            }
126
127 25
            return $preProcess ? self::$classesMap : $gqlTypes;
128 10
        } catch (\InvalidArgumentException $e) {
129 10
            throw new InvalidArgumentException(sprintf('Failed to parse GraphQL annotations from file "%s".', $file), $e->getCode(), $e);
130
        }
131
    }
132
133 25
    private static function classAnnotationsToGQLConfiguration(
134
        GraphClass $graphClass,
135
        object $classAnnotation,
136
        array $configs,
137
        array $gqlTypes,
138
        bool $preProcess
139
    ): array {
140 25
        $gqlConfiguration = $gqlType = $gqlName = null;
141
142
        switch (true) {
143 25
            case $classAnnotation instanceof GQL\Type:
144 25
                $gqlType = self::GQL_TYPE;
145 25
                $gqlName = $classAnnotation->name ?? $graphClass->getShortName();
146 25
                if (!$preProcess) {
147 25
                    $gqlConfiguration = self::typeAnnotationToGQLConfiguration($graphClass, $classAnnotation, $gqlName, $configs);
148
149 25
                    if ($classAnnotation instanceof GQL\Relay\Connection) {
150 24
                        if (!$graphClass->implementsInterface(ConnectionInterface::class)) {
151
                            throw new InvalidArgumentException(sprintf('The annotation @Connection on class "%s" can only be used on class implementing the ConnectionInterface.', $graphClass->getName()));
152
                        }
153
154 24
                        if (!(isset($classAnnotation->edge) xor isset($classAnnotation->node))) {
155
                            throw new InvalidArgumentException(sprintf('The annotation @Connection on class "%s" is invalid. You must define either the "edge" OR the "node" attribute, but not both.', $graphClass->getName()));
156
                        }
157
158 24
                        $edgeType = $classAnnotation->edge ?? false;
159 24
                        if (!$edgeType) {
160 24
                            $edgeType = $gqlName.'Edge';
161 24
                            $gqlTypes[$edgeType] = [
162 24
                                'type' => 'object',
163
                                'config' => [
164
                                    'builders' => [
165 24
                                        ['builder' => 'relay-edge', 'builderConfig' => ['nodeType' => $classAnnotation->node]],
166
                                    ],
167
                                ],
168
                            ];
169
                        }
170
171 24
                        if (!isset($gqlConfiguration['config']['builders'])) {
172 24
                            $gqlConfiguration['config']['builders'] = [];
173
                        }
174
175 24
                        array_unshift($gqlConfiguration['config']['builders'], ['builder' => 'relay-connection', 'builderConfig' => ['edgeType' => $edgeType]]);
176
                    }
177
                }
178 25
                break;
179
180 24
            case $classAnnotation instanceof GQL\Input:
181 24
                $gqlType = self::GQL_INPUT;
182 24
                $gqlName = $classAnnotation->name ?? self::suffixName($graphClass->getShortName(), 'Input');
183 24
                if (!$preProcess) {
184 24
                    $gqlConfiguration = self::inputAnnotationToGQLConfiguration($graphClass, $classAnnotation);
185
                }
186 24
                break;
187
188 24
            case $classAnnotation instanceof GQL\Scalar:
189 24
                $gqlType = self::GQL_SCALAR;
190 24
                if (!$preProcess) {
191 24
                    $gqlConfiguration = self::scalarAnnotationToGQLConfiguration($graphClass, $classAnnotation);
192
                }
193 24
                break;
194
195 24
            case $classAnnotation instanceof GQL\Enum:
196 24
                $gqlType = self::GQL_ENUM;
197 24
                if (!$preProcess) {
198 24
                    $gqlConfiguration = self::enumAnnotationToGQLConfiguration($graphClass, $classAnnotation);
199
                }
200 24
                break;
201
202 24
            case $classAnnotation instanceof GQL\Union:
203 24
                $gqlType = self::GQL_UNION;
204 24
                if (!$preProcess) {
205 24
                    $gqlConfiguration = self::unionAnnotationToGQLConfiguration($graphClass, $classAnnotation);
206
                }
207 24
                break;
208
209 24
            case $classAnnotation instanceof GQL\TypeInterface:
210 24
                $gqlType = self::GQL_INTERFACE;
211 24
                if (!$preProcess) {
212 24
                    $gqlConfiguration = self::typeInterfaceAnnotationToGQLConfiguration($graphClass, $classAnnotation);
213
                }
214 24
                break;
215
216 24
            case $classAnnotation instanceof GQL\Provider:
217 24
                if ($preProcess) {
218 24
                    self::$providers[] = ['metadata' => $graphClass, 'annotation' => $classAnnotation];
219
                }
220
221 24
                return [];
222
        }
223
224 25
        if (null !== $gqlType) {
225 25
            if (!$gqlName) {
226 24
                $gqlName = isset($classAnnotation->name) ? $classAnnotation->name : $graphClass->getShortName();
227
            }
228
229 25
            if ($preProcess) {
230 25
                if (isset(self::$classesMap[$gqlName])) {
231 1
                    throw new InvalidArgumentException(sprintf('The GraphQL type "%s" has already been registered in class "%s"', $gqlName, self::$classesMap[$gqlName]['class']));
232
                }
233 25
                self::$classesMap[$gqlName] = ['type' => $gqlType, 'class' => $graphClass->getName()];
234
            } else {
235 25
                $gqlTypes = [$gqlName => $gqlConfiguration] + $gqlTypes;
236
            }
237
        }
238
239 25
        return $gqlTypes;
240
    }
241
242
    /**
243
     * @throws ReflectionException
244
     */
245 25
    private static function getGraphClass(string $className): GraphClass
246
    {
247 25
        self::$graphClassCache[$className] ??= new GraphClass($className);
248
249 25
        return self::$graphClassCache[$className];
250
    }
251
252 25
    private static function typeAnnotationToGQLConfiguration(
253
        GraphClass $graphClass,
254
        GQL\Type $classAnnotation,
255
        string $gqlName,
256
        array $configs
257
    ): array {
258 25
        $isMutation = $isDefault = $isRoot = false;
259 25
        if (isset($configs['definitions']['schema'])) {
260 24
            $defaultSchemaName = isset($configs['definitions']['schema']['default']) ? 'default' : array_key_first($configs['definitions']['schema']);
261 24
            foreach ($configs['definitions']['schema'] as $schemaName => $schema) {
262 24
                $schemaQuery = $schema['query'] ?? null;
263 24
                $schemaMutation = $schema['mutation'] ?? null;
264
265 24
                if ($gqlName === $schemaQuery) {
266 24
                    $isRoot = true;
267 24
                    if ($defaultSchemaName === $schemaName) {
268 24
                        $isDefault = true;
269
                    }
270 24
                } elseif ($gqlName === $schemaMutation) {
271 24
                    $isMutation = true;
272 24
                    $isRoot = true;
273 24
                    if ($defaultSchemaName === $schemaName) {
274 24
                        $isDefault = true;
275
                    }
276
                }
277
            }
278
        }
279
280 25
        $currentValue = $isRoot ? sprintf("service('%s')", self::formatNamespaceForExpression($graphClass->getName())) : 'value';
281
282 25
        $gqlConfiguration = self::graphQLTypeConfigFromAnnotation($graphClass, $classAnnotation, $currentValue);
283
284 25
        $providerFields = self::getGraphQLFieldsFromProviders($graphClass, $isMutation ? GQL\Mutation::class : GQL\Query::class, $gqlName, $isDefault);
285 25
        $gqlConfiguration['config']['fields'] = array_merge($gqlConfiguration['config']['fields'], $providerFields);
286
287 25
        if ($classAnnotation instanceof GQL\Relay\Edge) {
288 24
            if (!$graphClass->implementsInterface(EdgeInterface::class)) {
289
                throw new InvalidArgumentException(sprintf('The annotation @Edge on class "%s" can only be used on class implementing the EdgeInterface.', $graphClass->getName()));
290
            }
291 24
            if (!isset($gqlConfiguration['config']['builders'])) {
292 24
                $gqlConfiguration['config']['builders'] = [];
293
            }
294 24
            array_unshift($gqlConfiguration['config']['builders'], ['builder' => 'relay-edge', 'builderConfig' => ['nodeType' => $classAnnotation->node]]);
295
        }
296
297 25
        return $gqlConfiguration;
298
    }
299
300 25
    private static function graphQLTypeConfigFromAnnotation(GraphClass $graphClass, GQL\Type $typeAnnotation, string $currentValue): array
301
    {
302 25
        $typeConfiguration = [];
303 25
        $fieldsFromProperties = self::getGraphQLTypeFieldsFromAnnotations($graphClass, $graphClass->getPropertiesExtended(), GQL\Field::class, $currentValue);
304 25
        $fieldsFromMethods = self::getGraphQLTypeFieldsFromAnnotations($graphClass, $graphClass->getMethods(), GQL\Field::class, $currentValue);
305
306 25
        $typeConfiguration['fields'] = array_merge($fieldsFromProperties, $fieldsFromMethods);
307 25
        $typeConfiguration = self::getDescriptionConfiguration($graphClass->getAnnotations()) + $typeConfiguration;
308
309 25
        if (isset($typeAnnotation->interfaces)) {
310 24
            $typeConfiguration['interfaces'] = $typeAnnotation->interfaces;
311
        } else {
312 25
            $interfaces = array_keys(self::searchClassesMapBy(function ($gqlType, $configuration) use ($graphClass) {
313 24
                ['class' => $interfaceClassName] = $configuration;
314
315 24
                $interfaceMetadata = self::getGraphClass($interfaceClassName);
316 24
                if ($interfaceMetadata->isInterface() && $graphClass->implementsInterface($interfaceMetadata->getName())) {
317 24
                    return true;
318
                }
319
320 24
                return $graphClass->isSubclassOf($interfaceClassName);
321 25
            }, self::GQL_INTERFACE));
322
323 25
            sort($interfaces);
324 25
            $typeConfiguration['interfaces'] = $interfaces;
325
        }
326
327 25
        if (isset($typeAnnotation->resolveField)) {
328 24
            $typeConfiguration['resolveField'] = self::formatExpression($typeAnnotation->resolveField);
329
        }
330
331 25
        if (isset($typeAnnotation->builders) && !empty($typeAnnotation->builders)) {
332 24
            $typeConfiguration['builders'] = array_map(function ($fieldsBuilderAnnotation) {
333 24
                return ['builder' => $fieldsBuilderAnnotation->builder, 'builderConfig' => $fieldsBuilderAnnotation->builderConfig];
334 24
            }, $typeAnnotation->builders);
335
        }
336
337 25
        if (isset($typeAnnotation->isTypeOf)) {
338 24
            $typeConfiguration['isTypeOf'] = $typeAnnotation->isTypeOf;
339
        }
340
341 25
        $publicAnnotation = self::getFirstAnnotationMatching($graphClass->getAnnotations(), GQL\IsPublic::class);
342 25
        if (null !== $publicAnnotation) {
343 24
            $typeConfiguration['fieldsDefaultPublic'] = self::formatExpression($publicAnnotation->value);
344
        }
345
346 25
        $accessAnnotation = self::getFirstAnnotationMatching($graphClass->getAnnotations(), GQL\Access::class);
347 25
        if (null !== $accessAnnotation) {
348 24
            $typeConfiguration['fieldsDefaultAccess'] = self::formatExpression($accessAnnotation->value);
349
        }
350
351 25
        return ['type' => $typeAnnotation->isRelay ? 'relay-mutation-payload' : 'object', 'config' => $typeConfiguration];
352
    }
353
354
    /**
355
     * Create a GraphQL Interface type configuration from annotations on properties.
356
     */
357 24
    private static function typeInterfaceAnnotationToGQLConfiguration(GraphClass $graphClass, GQL\TypeInterface $interfaceAnnotation): array
358
    {
359 24
        $interfaceConfiguration = [];
360
361 24
        $fieldsFromProperties = self::getGraphQLTypeFieldsFromAnnotations($graphClass, $graphClass->getPropertiesExtended());
362 24
        $fieldsFromMethods = self::getGraphQLTypeFieldsFromAnnotations($graphClass, $graphClass->getMethods());
363
364 24
        $interfaceConfiguration['fields'] = array_merge($fieldsFromProperties, $fieldsFromMethods);
365 24
        $interfaceConfiguration = self::getDescriptionConfiguration($graphClass->getAnnotations()) + $interfaceConfiguration;
366
367 24
        $interfaceConfiguration['resolveType'] = self::formatExpression($interfaceAnnotation->resolveType);
368
369 24
        return ['type' => 'interface', 'config' => $interfaceConfiguration];
370
    }
371
372
    /**
373
     * Create a GraphQL Input type configuration from annotations on properties.
374
     */
375 24
    private static function inputAnnotationToGQLConfiguration(GraphClass $graphClass, GQL\Input $inputAnnotation): array
376
    {
377 24
        $inputConfiguration = array_merge([
378 24
            'fields' => self::getGraphQLInputFieldsFromAnnotations($graphClass, $graphClass->getPropertiesExtended()),
379 24
        ], self::getDescriptionConfiguration($graphClass->getAnnotations()));
380
381 24
        return ['type' => $inputAnnotation->isRelay ? 'relay-mutation-input' : 'input-object', 'config' => $inputConfiguration];
382
    }
383
384
    /**
385
     * Get a GraphQL scalar configuration from given scalar annotation.
386
     */
387 24
    private static function scalarAnnotationToGQLConfiguration(GraphClass $graphClass, GQL\Scalar $scalarAnnotation): array
388
    {
389 24
        $scalarConfiguration = [];
390
391 24
        if (isset($scalarAnnotation->scalarType)) {
392 24
            $scalarConfiguration['scalarType'] = self::formatExpression($scalarAnnotation->scalarType);
393
        } else {
394
            $scalarConfiguration = [
395 24
                'serialize' => [$graphClass->getName(), 'serialize'],
396 24
                'parseValue' => [$graphClass->getName(), 'parseValue'],
397 24
                'parseLiteral' => [$graphClass->getName(), 'parseLiteral'],
398
            ];
399
        }
400
401 24
        $scalarConfiguration = self::getDescriptionConfiguration($graphClass->getAnnotations()) + $scalarConfiguration;
402
403 24
        return ['type' => 'custom-scalar', 'config' => $scalarConfiguration];
404
    }
405
406
    /**
407
     * Get a GraphQL Enum configuration from given enum annotation.
408
     */
409 24
    private static function enumAnnotationToGQLConfiguration(GraphClass $graphClass, GQL\Enum $enumAnnotation): array
410
    {
411 24
        $enumValues = $enumAnnotation->values ?? [];
412
413 24
        $values = [];
414
415 24
        foreach ($graphClass->getConstants() as $name => $value) {
416 24
            $valueAnnotation = current(array_filter($enumValues, fn ($enumValueAnnotation) => $enumValueAnnotation->name == $name));
417 24
            $valueConfig = [];
418 24
            $valueConfig['value'] = $value;
419
420 24
            if ($valueAnnotation && isset($valueAnnotation->description)) {
421 24
                $valueConfig['description'] = $valueAnnotation->description;
422
            }
423
424 24
            if ($valueAnnotation && isset($valueAnnotation->deprecationReason)) {
425 24
                $valueConfig['deprecationReason'] = $valueAnnotation->deprecationReason;
426
            }
427
428 24
            $values[$name] = $valueConfig;
429
        }
430
431 24
        $enumConfiguration = ['values' => $values];
432 24
        $enumConfiguration = self::getDescriptionConfiguration($graphClass->getAnnotations()) + $enumConfiguration;
433
434 24
        return ['type' => 'enum', 'config' => $enumConfiguration];
435
    }
436
437
    /**
438
     * Get a GraphQL Union configuration from given union annotation.
439
     */
440 24
    private static function unionAnnotationToGQLConfiguration(GraphClass $graphClass, GQL\Union $unionAnnotation): array
441
    {
442 24
        $unionConfiguration = [];
443 24
        if (isset($unionAnnotation->types)) {
444 24
            $unionConfiguration['types'] = $unionAnnotation->types;
445
        } else {
446 24
            $types = array_keys(self::searchClassesMapBy(function ($gqlType, $configuration) use ($graphClass) {
447 24
                $typeClassName = $configuration['class'];
448 24
                $typeMetadata = self::getGraphClass($typeClassName);
449
450 24
                if ($graphClass->isInterface() && $typeMetadata->implementsInterface($graphClass->getName())) {
451 24
                    return true;
452
                }
453
454 24
                return $typeMetadata->isSubclassOf($graphClass->getName());
455 24
            }, self::GQL_TYPE));
456 24
            sort($types);
457 24
            $unionConfiguration['types'] = $types;
458
        }
459
460 24
        $unionConfiguration = self::getDescriptionConfiguration($graphClass->getAnnotations()) + $unionConfiguration;
461
462 24
        if (isset($unionAnnotation->resolveType)) {
463 24
            $unionConfiguration['resolveType'] = self::formatExpression($unionAnnotation->resolveType);
464
        } else {
465 24
            if ($graphClass->hasMethod('resolveType')) {
466 24
                $method = $graphClass->getMethod('resolveType');
467 24
                if ($method->isStatic() && $method->isPublic()) {
468 24
                    $unionConfiguration['resolveType'] = self::formatExpression(sprintf("@=call('%s::%s', [service('overblog_graphql.type_resolver'), value], true)", self::formatNamespaceForExpression($graphClass->getName()), 'resolveType'));
469
                } else {
470 24
                    throw new InvalidArgumentException(sprintf('The "resolveType()" method on class must be static and public. Or you must define a "resolveType" attribute on the @Union annotation.'));
471
                }
472
            } else {
473 1
                throw new InvalidArgumentException(sprintf('The annotation @Union has no "resolveType" attribute and the related class has no "resolveType()" public static method. You need to define of them.'));
474
            }
475
        }
476
477 24
        return ['type' => 'union', 'config' => $unionConfiguration];
478
    }
479
480
    /**
481
     * @phpstan-param ReflectionMethod|ReflectionProperty $reflector
482
     * @phpstan-param class-string<GQL\Field> $fieldAnnotationName
483
     *
484
     * @throws AnnotationException
485
     */
486 24
    private static function getTypeFieldConfigurationFromReflector(GraphClass $graphClass, Reflector $reflector, string $fieldAnnotationName, string $currentValue = 'value'): array
487
    {
488 24
        $annotations = $graphClass->getAnnotations($reflector);
489
490 24
        $fieldAnnotation = self::getFirstAnnotationMatching($annotations, $fieldAnnotationName);
491 24
        $accessAnnotation = self::getFirstAnnotationMatching($annotations, GQL\Access::class);
492 24
        $publicAnnotation = self::getFirstAnnotationMatching($annotations, GQL\IsPublic::class);
493
494 24
        if (null === $fieldAnnotation) {
495 24
            if (null !== $accessAnnotation || null !== $publicAnnotation) {
496 1
                throw new InvalidArgumentException(sprintf('The annotations "@Access" and/or "@Visible" defined on "%s" are only usable in addition of annotation "@Field"', $reflector->getName()));
497
            }
498
499 24
            return [];
500
        }
501
502 24
        if ($reflector instanceof ReflectionMethod && !$reflector->isPublic()) {
503 1
            throw new InvalidArgumentException(sprintf('The Annotation "@Field" can only be applied to public method. The method "%s" is not public.', $reflector->getName()));
504
        }
505
506 24
        $fieldName = $reflector->getName();
507 24
        $fieldConfiguration = [];
508
509 24
        if (isset($fieldAnnotation->type)) {
510 24
            $fieldConfiguration['type'] = $fieldAnnotation->type;
511
        }
512
513 24
        $fieldConfiguration = self::getDescriptionConfiguration($annotations, true) + $fieldConfiguration;
514
515 24
        $args = [];
516
517 24
        foreach ($fieldAnnotation->args as $arg) {
518 24
            $args[$arg->name] = ['type' => $arg->type];
519
520 24
            if (isset($arg->description)) {
521 24
                $args[$arg->name]['description'] = $arg->description;
522
            }
523
524 24
            if (isset($arg->default)) {
525 24
                $args[$arg->name]['defaultValue'] = $arg->default;
526
            }
527
        }
528
529 24
        if (empty($fieldAnnotation->args) && $reflector instanceof ReflectionMethod) {
530 24
            $args = self::guessArgs($reflector);
531
        }
532
533 24
        if (!empty($args)) {
534 24
            $fieldConfiguration['args'] = $args;
535
        }
536
537 24
        $fieldName = $fieldAnnotation->name ?? $fieldName;
538
539 24
        if (isset($fieldAnnotation->resolve)) {
540 24
            $fieldConfiguration['resolve'] = self::formatExpression($fieldAnnotation->resolve);
541
        } else {
542 24
            if ($reflector instanceof ReflectionMethod) {
543 24
                $fieldConfiguration['resolve'] = self::formatExpression(sprintf('call(%s.%s, %s)', $currentValue, $reflector->getName(), self::formatArgsForExpression($args)));
544
            } else {
545 24
                if ($fieldName !== $reflector->getName() || 'value' !== $currentValue) {
546
                    $fieldConfiguration['resolve'] = self::formatExpression(sprintf('%s.%s', $currentValue, $reflector->getName()));
547
                }
548
            }
549
        }
550
551 24
        if ($fieldAnnotation->argsBuilder) {
552 24
            if (is_string($fieldAnnotation->argsBuilder)) {
553
                $fieldConfiguration['argsBuilder'] = $fieldAnnotation->argsBuilder;
554 24
            } elseif (is_array($fieldAnnotation->argsBuilder)) {
555 24
                list($builder, $builderConfig) = $fieldAnnotation->argsBuilder;
556 24
                $fieldConfiguration['argsBuilder'] = ['builder' => $builder, 'config' => $builderConfig];
557
            } else {
558
                throw new InvalidArgumentException(sprintf('The attribute "argsBuilder" on GraphQL annotation "@%s" defined on "%s" must be a string or an array where first index is the builder name and the second is the config.', $fieldAnnotationName, $reflector->getName()));
559
            }
560
        }
561
562 24
        if ($fieldAnnotation->fieldBuilder) {
563 24
            if (is_string($fieldAnnotation->fieldBuilder)) {
564
                $fieldConfiguration['builder'] = $fieldAnnotation->fieldBuilder;
565 24
            } elseif (is_array($fieldAnnotation->fieldBuilder)) {
566 24
                list($builder, $builderConfig) = $fieldAnnotation->fieldBuilder;
567 24
                $fieldConfiguration['builder'] = $builder;
568 24
                $fieldConfiguration['builderConfig'] = $builderConfig ?: [];
569
            } else {
570 24
                throw new InvalidArgumentException(sprintf('The attribute "fieldBuilder" on GraphQL annotation "@%s" defined on "%s" must be a string or an array where first index is the builder name and the second is the config.', $fieldAnnotationName, $reflector->getName()));
571
            }
572
        } else {
573 24
            if (!isset($fieldAnnotation->type)) {
574 24
                if ($reflector instanceof ReflectionMethod) {
575
                    /** @var ReflectionMethod $reflector */
576 24
                    if ($reflector->hasReturnType()) {
577
                        try {
578
                            // @phpstan-ignore-next-line
579 24
                            $fieldConfiguration['type'] = self::resolveGraphQLTypeFromReflectionType($reflector->getReturnType(), self::VALID_OUTPUT_TYPES);
580
                        } catch (Exception $e) {
581 24
                            throw new InvalidArgumentException(sprintf('The attribute "type" on GraphQL annotation "@%s" is missing on method "%s" and cannot be auto-guessed from type hint "%s"', $fieldAnnotationName, $reflector->getName(), (string) $reflector->getReturnType()));
582
                        }
583
                    } else {
584 24
                        throw new InvalidArgumentException(sprintf('The attribute "type" on GraphQL annotation "@%s" is missing on method "%s" and cannot be auto-guessed as there is not return type hint.', $fieldAnnotationName, $reflector->getName()));
585
                    }
586
                } else {
587
                    try {
588 24
                        $fieldConfiguration['type'] = self::guessType($graphClass, $reflector, self::VALID_OUTPUT_TYPES);
589 2
                    } catch (Exception $e) {
590 2
                        throw new InvalidArgumentException(sprintf('The attribute "type" on "@%s" defined on "%s" is required and cannot be auto-guessed : %s.', $fieldAnnotationName, $reflector->getName(), $e->getMessage()));
591
                    }
592
                }
593
            }
594
        }
595
596 24
        if ($accessAnnotation) {
597 24
            $fieldConfiguration['access'] = self::formatExpression($accessAnnotation->value);
598
        }
599
600 24
        if ($publicAnnotation) {
601 24
            $fieldConfiguration['public'] = self::formatExpression($publicAnnotation->value);
602
        }
603
604 24
        if ($fieldAnnotation->complexity) {
605 24
            $fieldConfiguration['complexity'] = self::formatExpression($fieldAnnotation->complexity);
606
        }
607
608 24
        return [$fieldName => $fieldConfiguration];
609
    }
610
611
    /**
612
     * Create GraphQL input fields configuration based on annotations.
613
     *
614
     * @param ReflectionProperty[] $reflectors
615
     *
616
     * @throws AnnotationException
617
     */
618 24
    private static function getGraphQLInputFieldsFromAnnotations(GraphClass $graphClass, array $reflectors): array
619
    {
620 24
        $fields = [];
621
622 24
        foreach ($reflectors as $reflector) {
623 24
            $annotations = $graphClass->getAnnotations($reflector);
624
625
            /** @var GQL\Field|null $fieldAnnotation */
626 24
            $fieldAnnotation = self::getFirstAnnotationMatching($annotations, GQL\Field::class);
627
628
            // No field annotation found
629 24
            if (null === $fieldAnnotation) {
630 24
                continue;
631
            }
632
633
            // Ignore field with resolver when the type is an Input
634 24
            if (isset($fieldAnnotation->resolve)) {
635
                return [];
636
            }
637
638 24
            $fieldName = $reflector->getName();
639 24
            if (isset($fieldAnnotation->type)) {
640 24
                $fieldType = $fieldAnnotation->type;
641
            } else {
642
                try {
643 24
                    $fieldType = self::guessType($graphClass, $reflector, self::VALID_INPUT_TYPES);
644
                } catch (Exception $e) {
645
                    throw new InvalidArgumentException(sprintf('The attribute "type" on GraphQL annotation "@%s" is missing on property "%s" and cannot be auto-guessed as there is no type hint or Doctrine annotation.', GQL\Field::class, $reflector->getName()));
646
                }
647
            }
648 24
            $fieldConfiguration = [];
649 24
            if ($fieldType) {
650
                // Resolve a PHP class from a GraphQL type
651 24
                $resolvedType = self::$classesMap[$fieldType] ?? null;
652
                // We found a type but it is not allowed
653 24
                if (null !== $resolvedType && !in_array($resolvedType['type'], self::VALID_INPUT_TYPES)) {
654
                    throw new InvalidArgumentException(sprintf('The type "%s" on "%s" is a "%s" not valid on an Input @Field. Only Input, Scalar and Enum are allowed.', $fieldType, $reflector->getName(), $resolvedType['type']));
655
                }
656
657 24
                $fieldConfiguration['type'] = $fieldType;
658
            }
659
660 24
            $fieldConfiguration = array_merge(self::getDescriptionConfiguration($annotations, true), $fieldConfiguration);
661 24
            $fields[$fieldName] = $fieldConfiguration;
662
        }
663
664 24
        return $fields;
665
    }
666
667
    /**
668
     * Create GraphQL type fields configuration based on annotations.
669
     *
670
     * @phpstan-param class-string<GQL\Field> $fieldAnnotationName
671
     *
672
     * @param ReflectionProperty[]|ReflectionMethod[] $reflectors
673
     *
674
     * @throws AnnotationException
675
     */
676 25
    private static function getGraphQLTypeFieldsFromAnnotations(GraphClass $graphClass, array $reflectors, string $fieldAnnotationName = GQL\Field::class, string $currentValue = 'value'): array
677
    {
678 25
        $fields = [];
679
680 25
        foreach ($reflectors as $reflector) {
681 24
            $fields = array_merge($fields, self::getTypeFieldConfigurationFromReflector($graphClass, $reflector, $fieldAnnotationName, $currentValue));
682
        }
683
684 25
        return $fields;
685
    }
686
687
    /**
688
     * @phpstan-param class-string<GQL\Query|GQL\Mutation> $expectedAnnotation
689
     *
690
     * Return fields config from Provider methods.
691
     * Loop through configured provider and extract fields targeting the targetType.
692
     */
693 25
    private static function getGraphQLFieldsFromProviders(GraphClass $graphClass, string $expectedAnnotation, string $targetType, bool $isDefaultTarget = false): array
694
    {
695 25
        $fields = [];
696 25
        foreach (self::$providers as ['metadata' => $providerMetadata, 'annotation' => $providerAnnotation]) {
697 24
            $defaultAccessAnnotation = self::getFirstAnnotationMatching($providerMetadata->getAnnotations(), GQL\Access::class);
698 24
            $defaultIsPublicAnnotation = self::getFirstAnnotationMatching($providerMetadata->getAnnotations(), GQL\IsPublic::class);
699
700 24
            $defaultAccess = $defaultAccessAnnotation ? self::formatExpression($defaultAccessAnnotation->value) : false;
701 24
            $defaultIsPublic = $defaultIsPublicAnnotation ? self::formatExpression($defaultIsPublicAnnotation->value) : false;
702
703 24
            $methods = [];
704
            // First found the methods matching the targeted type
705 24
            foreach ($providerMetadata->getMethods() as $method) {
706 24
                $annotations = $providerMetadata->getAnnotations($method);
707
708 24
                $annotation = self::getFirstAnnotationMatching($annotations, [GQL\Mutation::class, GQL\Query::class]);
709 24
                if (null === $annotation) {
710
                    continue;
711
                }
712
713 24
                $annotationTargets = $annotation->targetType ?? null;
714
715 24
                if (null === $annotationTargets) {
716 24
                    if ($isDefaultTarget) {
717 24
                        $annotationTargets = [$targetType];
718 24
                        if (!$annotation instanceof $expectedAnnotation) {
719 24
                            continue;
720
                        }
721
                    } else {
722 24
                        continue;
723
                    }
724
                }
725
726 24
                if (!in_array($targetType, $annotationTargets)) {
727 24
                    continue;
728
                }
729
730 24
                if (!$annotation instanceof $expectedAnnotation) {
731 2
                    if (GQL\Mutation::class == $expectedAnnotation) {
732 1
                        $message = sprintf('The provider "%s" try to add a query field on type "%s" (through @Query on method "%s") but "%s" is a mutation.', $providerMetadata->getName(), $targetType, $method->getName(), $targetType);
733
                    } else {
734 1
                        $message = sprintf('The provider "%s" try to add a mutation on type "%s" (through @Mutation on method "%s") but "%s" is not a mutation.', $providerMetadata->getName(), $targetType, $method->getName(), $targetType);
735
                    }
736
737 2
                    throw new InvalidArgumentException($message);
738
                }
739 24
                $methods[$method->getName()] = $method;
740
            }
741
742 24
            $currentValue = sprintf("service('%s')", self::formatNamespaceForExpression($providerMetadata->getName()));
743 24
            $providerFields = self::getGraphQLTypeFieldsFromAnnotations($graphClass, $methods, $expectedAnnotation, $currentValue);
744 24
            foreach ($providerFields as $fieldName => $fieldConfig) {
745 24
                if (isset($providerAnnotation->prefix)) {
746 24
                    $fieldName = sprintf('%s%s', $providerAnnotation->prefix, $fieldName);
747
                }
748
749 24
                if ($defaultAccess && !isset($fieldConfig['access'])) {
750 24
                    $fieldConfig['access'] = $defaultAccess;
751
                }
752
753 24
                if ($defaultIsPublic && !isset($fieldConfig['public'])) {
754 24
                    $fieldConfig['public'] = $defaultIsPublic;
755
                }
756
757 24
                $fields[$fieldName] = $fieldConfig;
758
            }
759
        }
760
761 25
        return $fields;
762
    }
763
764
    /**
765
     * Get the config for description & deprecation reason.
766
     */
767 25
    private static function getDescriptionConfiguration(array $annotations, bool $withDeprecation = false): array
768
    {
769 25
        $config = [];
770 25
        $descriptionAnnotation = self::getFirstAnnotationMatching($annotations, GQL\Description::class);
771 25
        if (null !== $descriptionAnnotation) {
772 24
            $config['description'] = $descriptionAnnotation->value;
773
        }
774
775 25
        if ($withDeprecation) {
776 24
            $deprecatedAnnotation = self::getFirstAnnotationMatching($annotations, GQL\Deprecated::class);
777 24
            if (null !== $deprecatedAnnotation) {
778 24
                $config['deprecationReason'] = $deprecatedAnnotation->value;
779
            }
780
        }
781
782 25
        return $config;
783
    }
784
785
    /**
786
     * Format an array of args to a list of arguments in an expression.
787
     */
788 24
    private static function formatArgsForExpression(array $args): string
789
    {
790 24
        $mapping = [];
791 24
        foreach ($args as $name => $config) {
792 24
            $mapping[] = sprintf('%s: "%s"', $name, $config['type']);
793
        }
794
795 24
        return sprintf('arguments({%s}, args)', implode(', ', $mapping));
796
    }
797
798
    /**
799
     * Format a namespace to be used in an expression (double escape).
800
     */
801 24
    private static function formatNamespaceForExpression(string $namespace): string
802
    {
803 24
        return str_replace('\\', '\\\\', $namespace);
804
    }
805
806
    /**
807
     * Get the first annotation matching given class.
808
     *
809
     * @phpstan-template T of object
810
     * @phpstan-param class-string<T>|class-string<T>[] $annotationClass
811
     * @phpstan-return T|null
812
     *
813
     * @param string|array $annotationClass
814
     *
815
     * @return object|null
816
     */
817 25
    private static function getFirstAnnotationMatching(array $annotations, $annotationClass)
818
    {
819 25
        if (is_string($annotationClass)) {
820 25
            $annotationClass = [$annotationClass];
821
        }
822
823 25
        foreach ($annotations as $annotation) {
824 25
            foreach ($annotationClass as $class) {
825 25
                if ($annotation instanceof $class) {
826 24
                    return $annotation;
827
                }
828
            }
829
        }
830
831 25
        return null;
832
    }
833
834
    /**
835
     * Format an expression (ie. add "@=" if not set).
836
     */
837 24
    private static function formatExpression(string $expression): string
838
    {
839 24
        return '@=' === substr($expression, 0, 2) ? $expression : sprintf('@=%s', $expression);
840
    }
841
842
    /**
843
     * Suffix a name if it is not already.
844
     */
845 24
    private static function suffixName(string $name, string $suffix): string
846
    {
847 24
        return substr($name, -strlen($suffix)) === $suffix ? $name : sprintf('%s%s', $name, $suffix);
848
    }
849
850
    /**
851
     * Try to guess a field type base on his annotations.
852
     *
853
     * @throws RuntimeException
854
     */
855 24
    private static function guessType(GraphClass $graphClass, ReflectionProperty $reflector, array $filterGraphQLTypes = []): string
856
    {
857 24
        if ($reflector->hasType()) {
0 ignored issues
show
Bug introduced by
The method hasType() does not exist on ReflectionProperty. ( Ignorable by Annotation )

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

857
        if ($reflector->/** @scrutinizer ignore-call */ hasType()) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
858
            try {
859
                // @phpstan-ignore-next-line
860 24
                return self::resolveGraphQLTypeFromReflectionType($reflector->getType(), $filterGraphQLTypes);
0 ignored issues
show
Bug introduced by
The method getType() does not exist on ReflectionProperty. ( Ignorable by Annotation )

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

860
                return self::resolveGraphQLTypeFromReflectionType($reflector->/** @scrutinizer ignore-call */ getType(), $filterGraphQLTypes);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
861 24
            } catch (Exception $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
862
            }
863
        }
864
865 24
        $annotations = $graphClass->getAnnotations($reflector);
866 24
        $columnAnnotation = self::getFirstAnnotationMatching($annotations, Column::class);
867 24
        if (null !== $columnAnnotation) {
868 24
            $type = self::resolveTypeFromDoctrineType($columnAnnotation->type);
869 24
            $nullable = $columnAnnotation->nullable;
870 24
            if ($type) {
871 24
                return $nullable ? $type : sprintf('%s!', $type);
872
            } else {
873 1
                throw new RuntimeException(sprintf('Unable to auto-guess GraphQL type from Doctrine type "%s"', $columnAnnotation->type));
874
            }
875
        }
876
877
        $associationAnnotations = [
878 24
            OneToMany::class => true,
879
            OneToOne::class => false,
880
            ManyToMany::class => true,
881
            ManyToOne::class => false,
882
        ];
883
884 24
        $associationAnnotation = self::getFirstAnnotationMatching($annotations, array_keys($associationAnnotations));
885 24
        if (null !== $associationAnnotation) {
886 24
            $target = self::fullyQualifiedClassName($associationAnnotation->targetEntity, $graphClass->getNamespaceName());
887 24
            $type = self::resolveTypeFromClass($target, ['type']);
888
889 24
            if ($type) {
890 24
                $isMultiple = $associationAnnotations[get_class($associationAnnotation)];
891 24
                if ($isMultiple) {
892 24
                    return sprintf('[%s]!', $type);
893
                } else {
894 24
                    $isNullable = false;
895 24
                    $joinColumn = self::getFirstAnnotationMatching($annotations, JoinColumn::class);
896 24
                    if (null !== $joinColumn) {
897 24
                        $isNullable = $joinColumn->nullable;
898
                    }
899
900 24
                    return sprintf('%s%s', $type, $isNullable ? '' : '!');
901
                }
902
            } else {
903 1
                throw new RuntimeException(sprintf('Unable to auto-guess GraphQL type from Doctrine target class "%s" (check if the target class is a GraphQL type itself (with a @GQL\Type annotation).', $target));
904
            }
905
        }
906
907
        throw new InvalidArgumentException(sprintf('No Doctrine ORM annotation found.'));
908
    }
909
910
    /**
911
     * Resolve a FQN from classname and namespace.
912
     *
913
     * @internal
914
     */
915 24
    public static function fullyQualifiedClassName(string $className, string $namespace): string
916
    {
917 24
        if (false === strpos($className, '\\') && $namespace) {
918 24
            return $namespace.'\\'.$className;
919
        }
920
921 1
        return $className;
922
    }
923
924
    /**
925
     * Resolve a GraphQLType from a doctrine type.
926
     */
927 24
    private static function resolveTypeFromDoctrineType(string $doctrineType): ?string
928
    {
929 24
        if (isset(self::$doctrineMapping[$doctrineType])) {
930 24
            return self::$doctrineMapping[$doctrineType];
931
        }
932
933
        switch ($doctrineType) {
934 24
            case 'integer':
935 1
            case 'smallint':
936 1
            case 'bigint':
937 24
                return 'Int';
938 1
            case 'string':
939 1
            case 'text':
940
                return 'String';
941 1
            case 'bool':
942 1
            case 'boolean':
943
                return 'Boolean';
944 1
            case 'float':
945 1
            case 'decimal':
946
                return 'Float';
947
            default:
948 1
                return null;
949
        }
950
    }
951
952
    /**
953
     * Transform a method arguments from reflection to a list of GraphQL argument.
954
     */
955 24
    private static function guessArgs(ReflectionMethod $method): array
956
    {
957 24
        $arguments = [];
958 24
        foreach ($method->getParameters() as $index => $parameter) {
959 24
            if (!$parameter->hasType()) {
960 1
                throw new InvalidArgumentException(sprintf('Argument n°%s "$%s" on method "%s" cannot be auto-guessed as there is not type hint.', $index + 1, $parameter->getName(), $method->getName()));
961
            }
962
963
            try {
964
                // @phpstan-ignore-next-line
965 24
                $gqlType = self::resolveGraphQLTypeFromReflectionType($parameter->getType(), self::VALID_INPUT_TYPES, $parameter->isDefaultValueAvailable());
966
            } catch (Exception $e) {
967
                throw new InvalidArgumentException(sprintf('Argument n°%s "$%s" on method "%s" cannot be auto-guessed : %s".', $index + 1, $parameter->getName(), $method->getName(), $e->getMessage()));
968
            }
969
970 24
            $argumentConfig = [];
971 24
            if ($parameter->isDefaultValueAvailable()) {
972 24
                $argumentConfig['defaultValue'] = $parameter->getDefaultValue();
973
            }
974
975 24
            $argumentConfig['type'] = $gqlType;
976
977 24
            $arguments[$parameter->getName()] = $argumentConfig;
978
        }
979
980 24
        return $arguments;
981
    }
982
983 24
    private static function resolveGraphQLTypeFromReflectionType(ReflectionNamedType $type, array $filterGraphQLTypes = [], bool $isOptional = false): string
984
    {
985 24
        $sType = $type->getName();
986 24
        if ($type->isBuiltin()) {
987 24
            $gqlType = self::resolveTypeFromPhpType($sType);
988 24
            if (null === $gqlType) {
989 24
                throw new RuntimeException(sprintf('No corresponding GraphQL type found for builtin type "%s"', $sType));
990
            }
991
        } else {
992 24
            $gqlType = self::resolveTypeFromClass($sType, $filterGraphQLTypes);
993 24
            if (null === $gqlType) {
994
                throw new RuntimeException(sprintf('No corresponding GraphQL %s found for class "%s"', $filterGraphQLTypes ? implode(',', $filterGraphQLTypes) : 'object', $sType));
995
            }
996
        }
997
998 24
        return sprintf('%s%s', $gqlType, ($type->allowsNull() || $isOptional) ? '' : '!');
999
    }
1000
1001
    /**
1002
     * Resolve a GraphQL Type from a class name.
1003
     */
1004 24
    private static function resolveTypeFromClass(string $className, array $wantedTypes = []): ?string
1005
    {
1006 24
        foreach (self::$classesMap as $gqlType => $config) {
1007 24
            if ($config['class'] === $className) {
1008 24
                if (in_array($config['type'], $wantedTypes)) {
1009 24
                    return $gqlType;
1010
                }
1011
            }
1012
        }
1013
1014 1
        return null;
1015
    }
1016
1017
    /**
1018
     * Search the classes map for class by predicate.
1019
     *
1020
     * @return array
1021
     */
1022 25
    private static function searchClassesMapBy(callable $predicate, string $type)
1023
    {
1024 25
        $classNames = [];
1025 25
        foreach (self::$classesMap as $gqlType => $config) {
1026 25
            if ($config['type'] !== $type) {
1027 25
                continue;
1028
            }
1029
1030 24
            if ($predicate($gqlType, $config)) {
1031 24
                $classNames[$gqlType] = $config;
1032
            }
1033
        }
1034
1035 25
        return $classNames;
1036
    }
1037
1038
    /**
1039
     * Convert a PHP Builtin type to a GraphQL type.
1040
     */
1041 24
    private static function resolveTypeFromPhpType(string $phpType): ?string
1042
    {
1043
        switch ($phpType) {
1044 24
            case 'boolean':
1045 24
            case 'bool':
1046 24
                return 'Boolean';
1047 24
            case 'integer':
1048 24
            case 'int':
1049 24
                return 'Int';
1050 24
            case 'float':
1051 24
            case 'double':
1052 24
                return 'Float';
1053 24
            case 'string':
1054 24
                return 'String';
1055
            default:
1056 24
                return null;
1057
        }
1058
    }
1059
}
1060