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

Completed
Pull Request — master (#735)
by Vincent
08:05
created

AnnotationParser::guessArgs()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 26
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 5.0729

Importance

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

859
        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...
860
            try {
861
                // @phpstan-ignore-next-line
862 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

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