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 (#639)
by
unknown
11:06
created

AnnotationParser::reset()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 4
dl 0
loc 6
ccs 5
cts 5
cp 1
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 0
crap 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Overblog\GraphQLBundle\Config\Parser;
6
7
use Doctrine\Common\Annotations\AnnotationReader;
8
use Doctrine\Common\Annotations\AnnotationRegistry;
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 Overblog\GraphQLBundle\Annotation as GQL;
16
use Overblog\GraphQLBundle\Relay\Connection\ConnectionInterface;
17
use Overblog\GraphQLBundle\Relay\Connection\EdgeInterface;
18
use Symfony\Component\Config\Resource\FileResource;
19
use Symfony\Component\DependencyInjection\ContainerBuilder;
20
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
21
22
class AnnotationParser implements PreParserInterface
23
{
24
    private static $annotationReader = null;
25
    private static $classesMap = [];
26
    private static $providers = [];
27
    private static $doctrineMapping = [];
28
    private static $classAnnotationsCache = [];
29
30
    private const GQL_SCALAR = 'scalar';
31
    private const GQL_ENUM = 'enum';
32
    private const GQL_TYPE = 'type';
33
    private const GQL_INPUT = 'input';
34
    private const GQL_UNION = 'union';
35
    private const GQL_INTERFACE = 'interface';
36
37
    /**
38
     * @see https://facebook.github.io/graphql/draft/#sec-Input-and-Output-Types
39
     */
40
    private const VALID_INPUT_TYPES = [self::GQL_SCALAR, self::GQL_ENUM, self::GQL_INPUT];
41
    private const VALID_OUTPUT_TYPES = [self::GQL_SCALAR, self::GQL_TYPE, self::GQL_INTERFACE, self::GQL_UNION, self::GQL_ENUM];
42
43
    /**
44
     * {@inheritdoc}
45
     *
46
     * @throws \ReflectionException
47
     * @throws InvalidArgumentException
48
     */
49 20
    public static function preParse(\SplFileInfo $file, ContainerBuilder $container, array $configs = []): void
50
    {
51 20
        $container->setParameter('overblog_graphql_types.classes_map', self::processFile($file, $container, $configs, true));
52 20
    }
53
54
    /**
55
     * {@inheritdoc}
56
     *
57
     * @throws \ReflectionException
58
     * @throws InvalidArgumentException
59
     */
60 20
    public static function parse(\SplFileInfo $file, ContainerBuilder $container, array $configs = []): array
61
    {
62 20
        return self::processFile($file, $container, $configs, false);
63
    }
64
65
    /**
66
     * @internal
67
     */
68 58
    public static function reset(): void
69
    {
70 58
        self::$classesMap = [];
71 58
        self::$providers = [];
72 58
        self::$classAnnotationsCache = [];
73 58
        self::$annotationReader = null;
74 58
    }
75
76
    /**
77
     * Process a file.
78
     *
79
     * @param \SplFileInfo     $file
80
     * @param ContainerBuilder $container
81
     * @param array            $configs
82
     * @param bool             $preProcess
83
     *
84
     * @return array
85
     *
86
     * @throws \ReflectionException
87
     * @throws InvalidArgumentException
88
     */
89 20
    private static function processFile(\SplFileInfo $file, ContainerBuilder $container, array $configs, bool $preProcess): array
90
    {
91 20
        self::$doctrineMapping = $configs['doctrine']['types_mapping'];
92 20
        $container->addResource(new FileResource($file->getRealPath()));
93
94
        try {
95 20
            $className = $file->getBasename('.php');
96 20
            if (\preg_match('#namespace (.+);#', \file_get_contents($file->getRealPath()), $matches)) {
97 20
                $className = \trim($matches[1]).'\\'.$className;
98
            }
99 20
            [$reflectionEntity, $classAnnotations, $properties, $methods] = self::extractClassAnnotations($className);
100 20
            $gqlTypes = [];
101
102 20
            foreach ($classAnnotations as $classAnnotation) {
103 20
                $gqlTypes = self::classAnnotationsToGQLConfiguration(
104 20
                    $reflectionEntity,
105 20
                    $classAnnotation,
106 20
                    $configs,
107 20
                    $classAnnotations,
108 20
                    $properties,
109 20
                    $methods,
110 20
                    $gqlTypes,
111 20
                    $preProcess
112
                );
113
            }
114
115 20
            return $preProcess ? self::$classesMap : $gqlTypes;
116 8
        } catch (\InvalidArgumentException $e) {
117 8
            throw new InvalidArgumentException(\sprintf('Failed to parse GraphQL annotations from file "%s".', $file), $e->getCode(), $e);
118
        }
119
    }
120
121
    /**
122
     * @param \ReflectionClass $reflectionEntity
123
     * @param array            $configs
124
     * @param object           $classAnnotation
125
     * @param array            $classAnnotations
126
     * @param array            $properties
127
     * @param array            $methods
128
     * @param array            $gqlTypes
129
     * @param bool             $preProcess
130
     *
131
     * @return array
132
     */
133 20
    private static function classAnnotationsToGQLConfiguration(
134
        \ReflectionClass $reflectionEntity,
135
        $classAnnotation,
136
        array $configs,
137
        array $classAnnotations,
138
        array $properties,
139
        array $methods,
140
        array $gqlTypes,
141
        bool $preProcess
142
    ): array {
143 20
        $gqlConfiguration = $gqlType = $gqlName = null;
144
145
        switch (true) {
146 20
            case $classAnnotation instanceof GQL\Type:
147 20
                $gqlType = self::GQL_TYPE;
148 20
                $gqlName = $classAnnotation->name ?: $reflectionEntity->getShortName();
149 20
                if (!$preProcess) {
150 20
                    $gqlConfiguration = self::typeAnnotationToGQLConfiguration(
151 20
                        $reflectionEntity, $classAnnotation, $gqlName, $classAnnotations, $properties, $methods, $configs
152
                    );
153
154 20
                    if ($classAnnotation instanceof GQL\Relay\Connection) {
155 19
                        if (!$reflectionEntity->implementsInterface(ConnectionInterface::class)) {
156
                            throw new InvalidArgumentException(\sprintf('The annotation @Connection on class "%s" can only be used on class implementing the ConnectionInterface.', $reflectionEntity->getName()));
157
                        }
158
159 19
                        if (!($classAnnotation->edge xor $classAnnotation->node)) {
160
                            throw new InvalidArgumentException(\sprintf('The annotation @Connection on class "%s" is invalid. You must define the "edge" OR the "node" attribute.', $reflectionEntity->getName()));
161
                        }
162
163 19
                        $edgeType = $classAnnotation->edge;
164 19
                        if (!$edgeType) {
165 19
                            $edgeType = \sprintf('%sEdge', $gqlName);
166 19
                            $gqlTypes[$edgeType] = [
167 19
                                'type' => 'object',
168
                                'config' => [
169
                                    'builders' => [
170 19
                                        ['builder' => 'relay-edge', 'builderConfig' => ['nodeType' => $classAnnotation->node]],
171
                                    ],
172
                                ],
173
                            ];
174
                        }
175 19
                        if (!isset($gqlConfiguration['config']['builders'])) {
176 19
                            $gqlConfiguration['config']['builders'] = [];
177
                        }
178 19
                        \array_unshift($gqlConfiguration['config']['builders'], ['builder' => 'relay-connection', 'builderConfig' => ['edgeType' => $edgeType]]);
179
                    }
180
                }
181 20
                break;
182
183 19
            case $classAnnotation instanceof GQL\Input:
184 19
                $gqlType = self::GQL_INPUT;
185 19
                $gqlName = $classAnnotation->name ?: self::suffixName($reflectionEntity->getShortName(), 'Input');
186 19
                if (!$preProcess) {
187 19
                    $gqlConfiguration = self::inputAnnotationToGQLConfiguration(
188 19
                        $classAnnotation, $classAnnotations, $properties, $reflectionEntity->getNamespaceName()
189
                    );
190
                }
191 19
                break;
192
193 19
            case $classAnnotation instanceof GQL\Scalar:
194 19
                $gqlType = self::GQL_SCALAR;
195 19
                if (!$preProcess) {
196 19
                    $gqlConfiguration = self::scalarAnnotationToGQLConfiguration(
197 19
                        $reflectionEntity->getName(), $classAnnotation, $classAnnotations
198
                    );
199
                }
200 19
                break;
201
202 19
            case $classAnnotation instanceof GQL\Enum:
203 19
                $gqlType = self::GQL_ENUM;
204 19
                if (!$preProcess) {
205 19
                    $gqlConfiguration = self::enumAnnotationToGQLConfiguration(
206 19
                        $classAnnotation, $classAnnotations, $reflectionEntity->getConstants()
207
                    );
208
                }
209 19
                break;
210
211 19
            case $classAnnotation instanceof GQL\Union:
212 19
                $gqlType = self::GQL_UNION;
213 19
                if (!$preProcess) {
214 19
                    $gqlConfiguration = self::unionAnnotationToGQLConfiguration(
215 19
                        $reflectionEntity->getName(), $classAnnotation, $classAnnotations, $methods
216
                    );
217
                }
218 19
                break;
219
220 19
            case $classAnnotation instanceof GQL\TypeInterface:
221 19
                $gqlType = self::GQL_INTERFACE;
222 19
                if (!$preProcess) {
223 19
                    $gqlConfiguration = self::typeInterfaceAnnotationToGQLConfiguration(
224 19
                        $classAnnotation, $classAnnotations, $properties, $methods, $reflectionEntity->getNamespaceName()
225
                    );
226
                }
227 19
                break;
228
229 19
            case $classAnnotation instanceof GQL\Provider:
230 19
                if ($preProcess) {
231 19
                    self::$providers[$reflectionEntity->getName()] = ['annotation' => $classAnnotation, 'methods' => $methods, 'annotations' => $classAnnotations];
232
                }
233 19
                break;
234
        }
235
236 20
        if (null !== $gqlType) {
237 20
            if (!$gqlName) {
238 19
                $gqlName = $classAnnotation->name ?: $reflectionEntity->getShortName();
0 ignored issues
show
Bug introduced by
The property name does not seem to exist on Overblog\GraphQLBundle\Annotation\Provider.
Loading history...
239
            }
240
241 20
            if ($preProcess) {
242 20
                if (isset(self::$classesMap[$gqlName])) {
243 1
                    throw new InvalidArgumentException(\sprintf('The GraphQL type "%s" has already been registered in class "%s"', $gqlName, self::$classesMap[$gqlName]['class']));
244
                }
245 20
                self::$classesMap[$gqlName] = ['type' => $gqlType, 'class' => $reflectionEntity->getName()];
246
            } else {
247 20
                $gqlTypes = [$gqlName => $gqlConfiguration] + $gqlTypes;
248
            }
249
        }
250
251 20
        return $gqlTypes;
252
    }
253
254 20
    private static function extractClassAnnotations(string $className): array
255
    {
256 20
        if (!isset(self::$classAnnotationsCache[$className])) {
257 20
            $annotationReader = self::getAnnotationReader();
258 20
            $reflectionEntity = new \ReflectionClass($className);
259 20
            $classAnnotations = $annotationReader->getClassAnnotations($reflectionEntity);
260
261 20
            $properties = [];
262 20
            foreach ($reflectionEntity->getProperties() as $property) {
263 19
                $properties[$property->getName()] = ['property' => $property, 'annotations' => $annotationReader->getPropertyAnnotations($property)];
264
            }
265
266 20
            $methods = [];
267 20
            foreach ($reflectionEntity->getMethods() as $method) {
268 19
                $methods[$method->getName()] = ['method' => $method, 'annotations' => $annotationReader->getMethodAnnotations($method)];
269
            }
270
271 20
            self::$classAnnotationsCache[$className] = [$reflectionEntity, $classAnnotations, $properties, $methods];
272
        }
273
274 20
        return self::$classAnnotationsCache[$className];
275
    }
276
277 20
    private static function typeAnnotationToGQLConfiguration(
278
        \ReflectionClass $reflectionEntity,
279
        GQL\Type $classAnnotation,
280
        string $gqlName,
281
        array $classAnnotations,
282
        array $properties,
283
        array $methods,
284
        array $configs
285
    ): array {
286 20
        $rootQueryType = $configs['definitions']['schema']['default']['query'] ?? null;
287 20
        $rootMutationType = $configs['definitions']['schema']['default']['mutation'] ?? null;
288 20
        $isRootQuery = ($rootQueryType && $gqlName === $rootQueryType);
289 20
        $isRootMutation = ($rootMutationType && $gqlName === $rootMutationType);
290 20
        $currentValue = ($isRootQuery || $isRootMutation) ? \sprintf("service('%s')", self::formatNamespaceForExpression($reflectionEntity->getName())) : 'value';
291
292 20
        $queryType = $isRootMutation ? 'Mutation' : 'Query';
293
294 20
        if (isset($configs['definitions']['schema'])) {
295 19
            $mutationTypes = [];
296
297 19
            foreach ($configs['definitions']['schema'] as $key => $value) {
298 19
                if (isset($configs['definitions']['schema'][$key]['mutation'])) {
299 19
                    $mutationTypes[] = $configs['definitions']['schema'][$key]['mutation'];
300
                }
301
            }
302
303 19
            $queryType = \in_array($gqlName, $mutationTypes) ? 'Mutation' : 'Query';
304
        }
305
306 20
        $gqlConfiguration = self::graphQLTypeConfigFromAnnotation($classAnnotation, $classAnnotations, $properties, $methods, $reflectionEntity->getNamespaceName(), $currentValue);
307 20
        $providerFields = self::getGraphQLFieldsFromProviders($reflectionEntity->getNamespaceName(), $queryType, $gqlName, ($isRootQuery || $isRootMutation));
308 20
        $gqlConfiguration['config']['fields'] = $providerFields + $gqlConfiguration['config']['fields'];
309
310 20
        if ($classAnnotation instanceof GQL\Relay\Edge) {
311 19
            if (!$reflectionEntity->implementsInterface(EdgeInterface::class)) {
312
                throw new InvalidArgumentException(\sprintf('The annotation @Edge on class "%s" can only be used on class implementing the EdgeInterface.', $reflectionEntity->getName()));
313
            }
314 19
            if (!isset($gqlConfiguration['config']['builders'])) {
315 19
                $gqlConfiguration['config']['builders'] = [];
316
            }
317 19
            \array_unshift($gqlConfiguration['config']['builders'], ['builder' => 'relay-edge', 'builderConfig' => ['nodeType' => $classAnnotation->node]]);
318
        }
319
320 20
        return $gqlConfiguration;
321
    }
322
323 20
    private static function getAnnotationReader()
324
    {
325 20
        if (null === self::$annotationReader) {
326 20
            if (!\class_exists('\\Doctrine\\Common\\Annotations\\AnnotationReader') ||
327 20
                !\class_exists('\\Doctrine\\Common\\Annotations\\AnnotationRegistry')) {
328
                throw new \RuntimeException('In order to use graphql annotation, you need to require doctrine annotations');
329
            }
330
331 20
            AnnotationRegistry::registerLoader('class_exists');
0 ignored issues
show
Deprecated Code introduced by
The function Doctrine\Common\Annotati...istry::registerLoader() has been deprecated: this method is deprecated and will be removed in doctrine/annotations 2.0 autoloading should be deferred to the globally registered autoloader by then. For now, use @example AnnotationRegistry::registerLoader('class_exists') ( Ignorable by Annotation )

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

331
            /** @scrutinizer ignore-deprecated */ AnnotationRegistry::registerLoader('class_exists');

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
332 20
            self::$annotationReader = new AnnotationReader();
333
        }
334
335 20
        return self::$annotationReader;
336
    }
337
338 20
    private static function graphQLTypeConfigFromAnnotation(GQL\Type $typeAnnotation, array $classAnnotations, array $properties, array $methods, string $namespace, string $currentValue): array
339
    {
340 20
        $typeConfiguration = [];
341
342 20
        $fields = self::getGraphQLFieldsFromAnnotations($namespace, $properties, false, false, $currentValue);
343 20
        $fields = self::getGraphQLFieldsFromAnnotations($namespace, $methods, false, true, $currentValue) + $fields;
344
345 20
        $typeConfiguration['fields'] = $fields;
346 20
        $typeConfiguration = self::getDescriptionConfiguration($classAnnotations) + $typeConfiguration;
347
348 20
        if ($typeAnnotation->interfaces) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $typeAnnotation->interfaces of type string[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
349 19
            $typeConfiguration['interfaces'] = $typeAnnotation->interfaces;
350
        }
351
352 20
        if ($typeAnnotation->resolveField) {
353 19
            $typeConfiguration['resolveField'] = self::formatExpression($typeAnnotation->resolveField);
354
        }
355
356 20
        if ($typeAnnotation->builders && !empty($typeAnnotation->builders)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $typeAnnotation->builders of type Overblog\GraphQLBundle\Annotation\FieldsBuilder[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
357
            $typeConfiguration['builders'] = \array_map(function ($fieldsBuilderAnnotation) {
358 19
                return ['builder' => $fieldsBuilderAnnotation->builder, 'builderConfig' => $fieldsBuilderAnnotation->builderConfig];
359 19
            }, $typeAnnotation->builders);
360
        }
361
362 20
        $publicAnnotation = self::getFirstAnnotationMatching($classAnnotations, GQL\IsPublic::class);
363 20
        if ($publicAnnotation) {
364 19
            $typeConfiguration['fieldsDefaultPublic'] = self::formatExpression($publicAnnotation->value);
365
        }
366
367 20
        $accessAnnotation = self::getFirstAnnotationMatching($classAnnotations, GQL\Access::class);
368 20
        if ($accessAnnotation) {
369 19
            $typeConfiguration['fieldsDefaultAccess'] = self::formatExpression($accessAnnotation->value);
370
        }
371
372 20
        return ['type' => $typeAnnotation->isRelay ? 'relay-mutation-payload' : 'object', 'config' => $typeConfiguration];
373
    }
374
375
    /**
376
     * Create a GraphQL Interface type configuration from annotations on properties.
377
     *
378
     * @param GQL\TypeInterface $interfaceAnnotation
379
     * @param array             $classAnnotations
380
     * @param array             $properties
381
     * @param array             $methods
382
     * @param string            $namespace
383
     *
384
     * @return array
385
     */
386 19
    private static function typeInterfaceAnnotationToGQLConfiguration(GQL\TypeInterface $interfaceAnnotation, array $classAnnotations, array $properties, array $methods, string $namespace)
387
    {
388 19
        $interfaceConfiguration = [];
389
390 19
        $fields = self::getGraphQLFieldsFromAnnotations($namespace, $properties);
391 19
        $fields = self::getGraphQLFieldsFromAnnotations($namespace, $methods, false, true) + $fields;
392
393 19
        $interfaceConfiguration['fields'] = $fields;
394 19
        $interfaceConfiguration = self::getDescriptionConfiguration($classAnnotations) + $interfaceConfiguration;
395
396 19
        $interfaceConfiguration['resolveType'] = self::formatExpression($interfaceAnnotation->resolveType);
397
398 19
        return ['type' => 'interface', 'config' => $interfaceConfiguration];
399
    }
400
401
    /**
402
     * Create a GraphQL Input type configuration from annotations on properties.
403
     *
404
     * @param GQL\Input $inputAnnotation
405
     * @param array     $classAnnotations
406
     * @param array     $properties
407
     * @param string    $namespace
408
     *
409
     * @return array
410
     */
411 19
    private static function inputAnnotationToGQLConfiguration(GQL\Input $inputAnnotation, array $classAnnotations, array $properties, string $namespace): array
412
    {
413 19
        $inputConfiguration = [];
414 19
        $fields = self::getGraphQLFieldsFromAnnotations($namespace, $properties, true);
415
416 19
        $inputConfiguration['fields'] = $fields;
417 19
        $inputConfiguration = self::getDescriptionConfiguration($classAnnotations) + $inputConfiguration;
418
419 19
        return ['type' => $inputAnnotation->isRelay ? 'relay-mutation-input' : 'input-object', 'config' => $inputConfiguration];
420
    }
421
422
    /**
423
     * Get a GraphQL scalar configuration from given scalar annotation.
424
     *
425
     * @param string     $className
426
     * @param GQL\Scalar $scalarAnnotation
427
     * @param array      $classAnnotations
428
     *
429
     * @return array
430
     */
431 19
    private static function scalarAnnotationToGQLConfiguration(string $className, GQL\Scalar $scalarAnnotation, array $classAnnotations): array
432
    {
433 19
        $scalarConfiguration = [];
434
435 19
        if ($scalarAnnotation->scalarType) {
436 19
            $scalarConfiguration['scalarType'] = self::formatExpression($scalarAnnotation->scalarType);
437
        } else {
438
            $scalarConfiguration = [
439 19
                'serialize' => [$className, 'serialize'],
440 19
                'parseValue' => [$className, 'parseValue'],
441 19
                'parseLiteral' => [$className, 'parseLiteral'],
442
            ];
443
        }
444
445 19
        $scalarConfiguration = self::getDescriptionConfiguration($classAnnotations) + $scalarConfiguration;
446
447 19
        return ['type' => 'custom-scalar', 'config' => $scalarConfiguration];
448
    }
449
450
    /**
451
     * Get a GraphQL Enum configuration from given enum annotation.
452
     *
453
     * @param GQL\Enum $enumAnnotation
454
     * @param array    $classAnnotations
455
     * @param array    $constants
456
     *
457
     * @return array
458
     */
459 19
    private static function enumAnnotationToGQLConfiguration(GQL\Enum $enumAnnotation, array $classAnnotations, array $constants): array
460
    {
461 19
        $enumValues = $enumAnnotation->values ? $enumAnnotation->values : [];
462
463 19
        $values = [];
464
465 19
        foreach ($constants as $name => $value) {
466
            $valueAnnotation = \current(\array_filter($enumValues, function ($enumValueAnnotation) use ($name) {
467 19
                return $enumValueAnnotation->name == $name;
468 19
            }));
469 19
            $valueConfig = [];
470 19
            $valueConfig['value'] = $value;
471
472 19
            if ($valueAnnotation && $valueAnnotation->description) {
473 19
                $valueConfig['description'] = $valueAnnotation->description;
474
            }
475
476 19
            if ($valueAnnotation && $valueAnnotation->deprecationReason) {
477 19
                $valueConfig['deprecationReason'] = $valueAnnotation->deprecationReason;
478
            }
479
480 19
            $values[$name] = $valueConfig;
481
        }
482
483 19
        $enumConfiguration = ['values' => $values];
484 19
        $enumConfiguration = self::getDescriptionConfiguration($classAnnotations) + $enumConfiguration;
485
486 19
        return ['type' => 'enum', 'config' => $enumConfiguration];
487
    }
488
489
    /**
490
     * Get a GraphQL Union configuration from given union annotation.
491
     *
492
     * @param string    $className
493
     * @param GQL\Union $unionAnnotation
494
     * @param array     $classAnnotations
495
     * @param array     $methods
496
     *
497
     * @return array
498
     */
499 19
    private static function unionAnnotationToGQLConfiguration(string $className, GQL\Union $unionAnnotation, array $classAnnotations, array $methods): array
500
    {
501 19
        $unionConfiguration = ['types' => $unionAnnotation->types];
502 19
        $unionConfiguration = self::getDescriptionConfiguration($classAnnotations) + $unionConfiguration;
503
504 19
        if ($unionAnnotation->resolveType) {
505 19
            $unionConfiguration['resolveType'] = self::formatExpression($unionAnnotation->resolveType);
506
        } else {
507 19
            if (isset($methods['resolveType'])) {
508 19
                $method = $methods['resolveType']['method'];
509 19
                if ($method->isStatic() && $method->isPublic()) {
510 19
                    $unionConfiguration['resolveType'] = self::formatExpression(\sprintf("@=call('%s::%s', [service('overblog_graphql.type_resolver'), value], true)", self::formatNamespaceForExpression($className), 'resolveType'));
511
                } else {
512 19
                    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.'));
513
                }
514
            } else {
515 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.'));
516
            }
517
        }
518
519 19
        return ['type' => 'union', 'config' => $unionConfiguration];
520
    }
521
522
    /**
523
     * Create GraphQL fields configuration based on annotations.
524
     *
525
     * @param string $namespace
526
     * @param array  $propertiesOrMethods
527
     * @param bool   $isInput
528
     * @param bool   $isMethod
529
     * @param string $currentValue
530
     *
531
     * @return array
532
     */
533 20
    private static function getGraphQLFieldsFromAnnotations(string $namespace, array $propertiesOrMethods, bool $isInput = false, bool $isMethod = false, string $currentValue = 'value', string $fieldAnnotationName = 'Field'): array
534
    {
535 20
        $fields = [];
536 20
        foreach ($propertiesOrMethods as $target => $config) {
537 19
            $annotations = $config['annotations'];
538 19
            $method = $isMethod ? $config['method'] : false;
539
540 19
            $fieldAnnotation = self::getFirstAnnotationMatching($annotations, \sprintf('Overblog\GraphQLBundle\Annotation\%s', $fieldAnnotationName));
541 19
            $accessAnnotation = self::getFirstAnnotationMatching($annotations, GQL\Access::class);
542 19
            $publicAnnotation = self::getFirstAnnotationMatching($annotations, GQL\IsPublic::class);
543
544 19
            if (!$fieldAnnotation) {
545 19
                if ($accessAnnotation || $publicAnnotation) {
546 1
                    throw new InvalidArgumentException(\sprintf('The annotations "@Access" and/or "@Visible" defined on "%s" are only usable in addition of annotation "@Field"', $target));
547
                }
548 19
                continue;
549
            }
550
551 19
            if ($isMethod && !$method->isPublic()) {
552 1
                throw new InvalidArgumentException(\sprintf('The Annotation "@Field" can only be applied to public method. The method "%s" is not public.', $target));
553
            }
554
555
            // Ignore field with resolver when the type is an Input
556 19
            if ($fieldAnnotation->resolve && $isInput) {
557
                continue;
558
            }
559
560 19
            $fieldName = $target;
561 19
            $fieldType = $fieldAnnotation->type;
562 19
            $fieldConfiguration = [];
563 19
            if ($fieldType) {
564 19
                $resolvedType = self::resolveClassFromType($fieldType);
565 19
                if (null !== $resolvedType && $isInput && !\in_array($resolvedType['type'], self::VALID_INPUT_TYPES)) {
566
                    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, $target, $resolvedType['type']));
567
                }
568
569 19
                $fieldConfiguration['type'] = $fieldType;
570
            }
571
572 19
            $fieldConfiguration = self::getDescriptionConfiguration($annotations, true) + $fieldConfiguration;
573
574 19
            if (!$isInput) {
575 19
                $args = self::getArgs($fieldAnnotation->args, $isMethod && !$fieldAnnotation->argsBuilder ? $method : null);
0 ignored issues
show
Bug introduced by
It seems like $isMethod && ! $fieldAnn...uilder ? $method : null can also be of type false; however, parameter $method of Overblog\GraphQLBundle\C...tationParser::getArgs() does only seem to accept ReflectionMethod|null, maybe add an additional type check? ( Ignorable by Annotation )

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

575
                $args = self::getArgs($fieldAnnotation->args, /** @scrutinizer ignore-type */ $isMethod && !$fieldAnnotation->argsBuilder ? $method : null);
Loading history...
576
577 19
                if (!empty($args)) {
578 19
                    $fieldConfiguration['args'] = $args;
579
                }
580
581 19
                $fieldName = $fieldAnnotation->name ?: $fieldName;
582
583 19
                if ($fieldAnnotation->resolve) {
584 19
                    $fieldConfiguration['resolve'] = self::formatExpression($fieldAnnotation->resolve);
585
                } else {
586 19
                    if ($isMethod) {
587 19
                        $fieldConfiguration['resolve'] = self::formatExpression(\sprintf('call(%s.%s, %s)', $currentValue, $target, self::formatArgsForExpression($args)));
588
                    } else {
589 19
                        if ($fieldName !== $target || 'value' !== $currentValue) {
590
                            $fieldConfiguration['resolve'] = self::formatExpression(\sprintf('%s.%s', $currentValue, $target));
591
                        }
592
                    }
593
                }
594
595 19
                if ($fieldAnnotation->argsBuilder) {
596 19
                    if (\is_string($fieldAnnotation->argsBuilder)) {
597
                        $fieldConfiguration['argsBuilder'] = $fieldAnnotation->argsBuilder;
598 19
                    } elseif (\is_array($fieldAnnotation->argsBuilder)) {
599 19
                        list($builder, $builderConfig) = $fieldAnnotation->argsBuilder;
600 19
                        $fieldConfiguration['argsBuilder'] = ['builder' => $builder, 'config' => $builderConfig];
601
                    } else {
602
                        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, $target));
603
                    }
604
                }
605
606 19
                if ($fieldAnnotation->fieldBuilder) {
607 19
                    if (\is_string($fieldAnnotation->fieldBuilder)) {
608
                        $fieldConfiguration['builder'] = $fieldAnnotation->fieldBuilder;
609 19
                    } elseif (\is_array($fieldAnnotation->fieldBuilder)) {
610 19
                        list($builder, $builderConfig) = $fieldAnnotation->fieldBuilder;
611 19
                        $fieldConfiguration['builder'] = $builder;
612 19
                        $fieldConfiguration['builderConfig'] = $builderConfig ?: [];
613
                    } else {
614 19
                        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, $target));
615
                    }
616
                } else {
617 19
                    if (!$fieldType) {
618 19
                        if ($isMethod) {
619 19
                            if ($method->hasReturnType()) {
620
                                try {
621 19
                                    $fieldConfiguration['type'] = self::resolveGraphQLTypeFromReflectionType($method->getReturnType(), self::VALID_OUTPUT_TYPES);
622
                                } catch (\Exception $e) {
623 19
                                    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, $target, (string) $method->getReturnType()));
624
                                }
625
                            } else {
626 19
                                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, $target));
627
                            }
628
                        } else {
629
                            try {
630 19
                                $fieldConfiguration['type'] = self::guessType($namespace, $annotations);
631 2
                            } catch (\Exception $e) {
632 2
                                throw new InvalidArgumentException(\sprintf('The attribute "type" on "@%s" defined on "%s" is required and cannot be auto-guessed : %s.', $fieldAnnotationName, $target, $e->getMessage()));
633
                            }
634
                        }
635
                    }
636
                }
637
638 19
                if ($accessAnnotation) {
639 19
                    $fieldConfiguration['access'] = self::formatExpression($accessAnnotation->value);
640
                }
641
642 19
                if ($publicAnnotation) {
643 19
                    $fieldConfiguration['public'] = self::formatExpression($publicAnnotation->value);
644
                }
645
646 19
                if ($fieldAnnotation->complexity) {
647 19
                    $fieldConfiguration['complexity'] = self::formatExpression($fieldAnnotation->complexity);
648
                }
649
            }
650
651 19
            $fields[$fieldName] = $fieldConfiguration;
652
        }
653
654 20
        return $fields;
655
    }
656
657
    /**
658
     * Return fields config from Provider methods.
659
     *
660
     * @param string $namespace
661
     * @param string $annotationName
662
     * @param string $targetType
663
     * @param bool   $isRoot
664
     *
665
     * @return array
666
     */
667 20
    private static function getGraphQLFieldsFromProviders(string $namespace, string $annotationName, string $targetType, bool $isRoot = false)
668
    {
669 20
        $fields = [];
670 20
        foreach (self::$providers as $className => $configuration) {
671 19
            $providerMethods = $configuration['methods'];
672 19
            $providerAnnotation = $configuration['annotation'];
673 19
            $providerAnnotations = $configuration['annotations'];
674
675 19
            $defaultAccessAnnotation = self::getFirstAnnotationMatching($providerAnnotations, GQL\Access::class);
676 19
            $defaultIsPublicAnnotation = self::getFirstAnnotationMatching($providerAnnotations, GQL\IsPublic::class);
677
678 19
            $defaultAccess = $defaultAccessAnnotation ? self::formatExpression($defaultAccessAnnotation->value) : false;
679 19
            $defaultIsPublic = $defaultIsPublicAnnotation ? self::formatExpression($defaultIsPublicAnnotation->value) : false;
680
681 19
            $filteredMethods = [];
682 19
            foreach ($providerMethods as $methodName => $config) {
683 19
                $annotations = $config['annotations'];
684
685 19
                $annotation = self::getFirstAnnotationMatching($annotations, \sprintf('Overblog\\GraphQLBundle\\Annotation\\%s', $annotationName));
686 19
                if (!$annotation) {
687 19
                    continue;
688
                }
689
690 19
                $annotationTarget = \in_array($annotationName, ['Query', 'Mutation']) ? $annotation->targetType : null;
691 19
                if (!$annotationTarget && $isRoot) {
692 19
                    $annotationTarget = $targetType;
693
                }
694
695 19
                if ($annotationTarget !== $targetType) {
696 19
                    continue;
697
                }
698
699 19
                $filteredMethods[$methodName] = $config;
700
            }
701
702 19
            $currentValue = \sprintf("service('%s')", self::formatNamespaceForExpression($className));
703 19
            $providerFields = self::getGraphQLFieldsFromAnnotations($namespace, $filteredMethods, false, true, $currentValue, $annotationName);
704 19
            foreach ($providerFields as $fieldName => $fieldConfig) {
705 19
                if ($providerAnnotation->prefix) {
706 19
                    $fieldName = \sprintf('%s%s', $providerAnnotation->prefix, $fieldName);
707
                }
708
709 19
                if ($defaultAccess && !isset($fieldConfig['access'])) {
710 19
                    $fieldConfig['access'] = $defaultAccess;
711
                }
712
713 19
                if ($defaultIsPublic && !isset($fieldConfig['public'])) {
714 19
                    $fieldConfig['public'] = $defaultIsPublic;
715
                }
716
717 19
                $fields[$fieldName] = $fieldConfig;
718
            }
719
        }
720
721 20
        return $fields;
722
    }
723
724
    /**
725
     * Get the config for description & deprecation reason.
726
     *
727
     * @param array $annotations
728
     * @param bool  $withDeprecation
729
     *
730
     * @return array
731
     */
732 20
    private static function getDescriptionConfiguration(array $annotations, bool $withDeprecation = false): array
733
    {
734 20
        $config = [];
735 20
        $descriptionAnnotation = self::getFirstAnnotationMatching($annotations, GQL\Description::class);
736 20
        if ($descriptionAnnotation) {
737 19
            $config['description'] = $descriptionAnnotation->value;
738
        }
739
740 20
        if ($withDeprecation) {
741 19
            $deprecatedAnnotation = self::getFirstAnnotationMatching($annotations, GQL\Deprecated::class);
742 19
            if ($deprecatedAnnotation) {
743 19
                $config['deprecationReason'] = $deprecatedAnnotation->value;
744
            }
745
        }
746
747 20
        return $config;
748
    }
749
750
    /**
751
     * Get args config from an array of @Arg annotation or by auto-guessing if a method is provided.
752
     *
753
     * @param array             $args
754
     * @param \ReflectionMethod $method
755
     *
756
     * @return array
757
     */
758 19
    private static function getArgs(array $args = null, \ReflectionMethod $method = null): array
759
    {
760 19
        $config = [];
761 19
        if ($args && !empty($args)) {
762 19
            foreach ($args as $arg) {
763 19
                $config[$arg->name] = ['type' => $arg->type]
764 19
                    + ($arg->description ? ['description' => $arg->description] : [])
765 19
                    + ($arg->default ? ['defaultValue' => $arg->default] : []);
766
            }
767 19
        } elseif ($method) {
768 19
            $config = self::guessArgs($method);
769
        }
770
771 19
        return $config;
772
    }
773
774
    /**
775
     * Format an array of args to a list of arguments in an expression.
776
     *
777
     * @param array $args
778
     *
779
     * @return string
780
     */
781 19
    private static function formatArgsForExpression(array $args): string
782
    {
783 19
        $mapping = [];
784 19
        foreach ($args as $name => $config) {
785 19
            $mapping[] = \sprintf('%s: "%s"', $name, $config['type']);
786
        }
787
788 19
        return \sprintf('arguments({%s}, args)', \implode(', ', $mapping));
789
    }
790
791
    /**
792
     * Format a namespace to be used in an expression (double escape).
793
     *
794
     * @param string $namespace
795
     *
796
     * @return string
797
     */
798 19
    private static function formatNamespaceForExpression(string $namespace): string
799
    {
800 19
        return \str_replace('\\', '\\\\', $namespace);
801
    }
802
803
    /**
804
     * Get the first annotation matching given class.
805
     *
806
     * @param array        $annotations
807
     * @param string|array $annotationClass
808
     *
809
     * @return mixed
810
     */
811 20
    private static function getFirstAnnotationMatching(array $annotations, $annotationClass)
812
    {
813 20
        if (\is_string($annotationClass)) {
814 20
            $annotationClass = [$annotationClass];
815
        }
816
817 20
        foreach ($annotations as $annotation) {
818 20
            foreach ($annotationClass as $class) {
819 20
                if ($annotation instanceof $class) {
820 19
                    return $annotation;
821
                }
822
            }
823
        }
824
825 20
        return false;
826
    }
827
828
    /**
829
     * Format an expression (ie. add "@=" if not set).
830
     *
831
     * @param string $expression
832
     *
833
     * @return string
834
     */
835 19
    private static function formatExpression(string $expression)
836
    {
837 19
        return '@=' === \substr($expression, 0, 2) ? $expression : \sprintf('@=%s', $expression);
838
    }
839
840
    /**
841
     * Suffix a name if it is not already.
842
     *
843
     * @param string $name
844
     * @param string $suffix
845
     *
846
     * @return string
847
     */
848 19
    private static function suffixName(string $name, string $suffix)
849
    {
850 19
        return \substr($name, -\strlen($suffix)) === $suffix ? $name : \sprintf('%s%s', $name, $suffix);
851
    }
852
853
    /**
854
     * Try to guess a field type base on is annotations.
855
     *
856
     * @param string $namespace
857
     * @param array  $annotations
858
     *
859
     * @return string
860
     *
861
     * @throws \RuntimeException
862
     */
863 19
    private static function guessType(string $namespace, array $annotations): string
864
    {
865 19
        $columnAnnotation = self::getFirstAnnotationMatching($annotations, Column::class);
866 19
        if ($columnAnnotation) {
867 19
            $type = self::resolveTypeFromDoctrineType($columnAnnotation->type);
868 19
            $nullable = $columnAnnotation->nullable;
869 19
            if ($type) {
870 19
                return $nullable ? $type : \sprintf('%s!', $type);
871
            } else {
872 1
                throw new \RuntimeException(\sprintf('Unable to auto-guess GraphQL type from Doctrine type "%s"', $columnAnnotation->type));
873
            }
874
        }
875
876
        $associationAnnotations = [
877 19
            OneToMany::class => true,
878
            OneToOne::class => false,
879
            ManyToMany::class => true,
880
            ManyToOne::class => false,
881
        ];
882
883 19
        $associationAnnotation = self::getFirstAnnotationMatching($annotations, \array_keys($associationAnnotations));
884 19
        if ($associationAnnotation) {
885 19
            $target = self::fullyQualifiedClassName($associationAnnotation->targetEntity, $namespace);
886 19
            $type = self::resolveTypeFromClass($target, ['type']);
887
888 19
            if ($type) {
889 19
                $isMultiple = $associationAnnotations[\get_class($associationAnnotation)];
890 19
                if ($isMultiple) {
891 19
                    return \sprintf('[%s]!', $type);
892
                } else {
893 19
                    $isNullable = false;
894 19
                    $joinColumn = self::getFirstAnnotationMatching($annotations, JoinColumn::class);
895 19
                    if ($joinColumn) {
896 19
                        $isNullable = $joinColumn->nullable;
897
                    }
898
899 19
                    return \sprintf('%s%s', $type, $isNullable ? '' : '!');
900
                }
901
            } else {
902 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));
903
            }
904
        }
905
906
        throw new InvalidArgumentException(\sprintf('No Doctrine ORM annotation found.'));
907
    }
908
909
    /**
910
     * Resolve a FQN from classname and namespace.
911
     *
912
     * @param string $className
913
     * @param string $namespace
914
     *
915
     * @return string
916
     *
917
     * @internal
918
     */
919 19
    public static function fullyQualifiedClassName(string $className, string $namespace): string
920
    {
921 19
        if (false === \strpos($className, '\\') && $namespace) {
922 19
            return $namespace.'\\'.$className;
923
        }
924
925 1
        return $className;
926
    }
927
928
    /**
929
     * Resolve a GraphQLType from a doctrine type.
930
     *
931
     * @param string $doctrineType
932
     *
933
     * @return string|null
934
     */
935 19
    private static function resolveTypeFromDoctrineType(string $doctrineType): ?string
936
    {
937 19
        if (isset(self::$doctrineMapping[$doctrineType])) {
938 19
            return self::$doctrineMapping[$doctrineType];
939
        }
940
941 19
        switch ($doctrineType) {
942 19
            case 'integer':
943 19
            case 'smallint':
944 19
            case 'bigint':
945 19
                return 'Int';
946 19
            case 'string':
947 1
            case 'text':
948 19
                return 'String';
949 1
            case 'bool':
950 1
            case 'boolean':
951
                return 'Boolean';
952 1
            case 'float':
953 1
            case 'decimal':
954
                return 'Float';
955
            default:
956 1
                return null;
957
        }
958
    }
959
960
    /**
961
     * Transform a method arguments from reflection to a list of GraphQL argument.
962
     */
963 19
    private static function guessArgs(\ReflectionMethod $method): array
964
    {
965 19
        $arguments = [];
966 19
        foreach ($method->getParameters() as $index => $parameter) {
967 19
            if (!$parameter->hasType()) {
968 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()));
969
            }
970
971
            try {
972 19
                $gqlType = self::resolveGraphQLTypeFromReflectionType($parameter->getType(), self::VALID_INPUT_TYPES, $parameter->isDefaultValueAvailable());
973
            } catch (\Exception $e) {
974
                throw new InvalidArgumentException(\sprintf('Argument n°%s "$%s" on method "%s" cannot be auto-guessed : %s".', $index + 1, $parameter->getName(), $method->getName(), $e->getMessage()));
975
            }
976
977 19
            $argumentConfig = [];
978 19
            if ($parameter->isDefaultValueAvailable()) {
979 19
                $argumentConfig['defaultValue'] = $parameter->getDefaultValue();
980
            }
981
982 19
            $argumentConfig['type'] = $gqlType;
983
984 19
            $arguments[$parameter->getName()] = $argumentConfig;
985
        }
986
987 19
        return $arguments;
988
    }
989
990 19
    private static function resolveGraphQLTypeFromReflectionType(\ReflectionType $type, array $filterGraphQLTypes = null, bool $isOptional = false): string
991
    {
992 19
        $sType = $type->getName();
993 19
        if ($type->isBuiltin()) {
994 19
            $gqlType = self::resolveTypeFromPhpType($sType);
995 19
            if (null === $gqlType) {
996 19
                throw new \RuntimeException(\sprintf('No corresponding GraphQL type found for builtin type "%s"', $sType));
997
            }
998
        } else {
999 19
            $gqlType = self::resolveTypeFromClass($sType, $filterGraphQLTypes);
1000 19
            if (null === $gqlType) {
1001
                throw new \RuntimeException(\sprintf('No corresponding GraphQL %s found for class "%s"', $filterGraphQLTypes ? \implode(',', $filterGraphQLTypes) : 'object', $sType));
1002
            }
1003
        }
1004
1005 19
        return \sprintf('%s%s', $gqlType, ($type->allowsNull() || $isOptional) ? '' : '!');
1006
    }
1007
1008
    /**
1009
     * Resolve a GraphQL Type from a class name.
1010
     *
1011
     * @param string $className
1012
     * @param array  $wantedTypes
1013
     *
1014
     * @return string|null
1015
     */
1016 19
    private static function resolveTypeFromClass(string $className, array $wantedTypes = null): ?string
1017
    {
1018 19
        foreach (self::$classesMap as $gqlType => $config) {
1019 19
            if ($config['class'] === $className) {
1020 19
                if (!$wantedTypes || \in_array($config['type'], $wantedTypes)) {
1021 19
                    return $gqlType;
1022
                }
1023
            }
1024
        }
1025
1026 1
        return null;
1027
    }
1028
1029
    /**
1030
     * Resolve a PHP class from a GraphQL type.
1031
     *
1032
     * @param string $type
1033
     *
1034
     * @return string|array|null
1035
     */
1036 19
    private static function resolveClassFromType(string $type)
1037
    {
1038 19
        return self::$classesMap[$type] ?? null;
1039
    }
1040
1041
    /**
1042
     * Convert a PHP Builtin type to a GraphQL type.
1043
     *
1044
     * @param string $phpType
1045
     *
1046
     * @return string|null
1047
     */
1048 19
    private static function resolveTypeFromPhpType(string $phpType): ?string
1049
    {
1050 19
        switch ($phpType) {
1051 19
            case 'boolean':
1052 19
            case 'bool':
1053 19
                return 'Boolean';
1054 19
            case 'integer':
1055 19
            case 'int':
1056 19
                return 'Int';
1057 19
            case 'float':
1058 19
            case 'double':
1059 19
                return 'Float';
1060 19
            case 'string':
1061 19
                return 'String';
1062
            default:
1063
                return null;
1064
        }
1065
    }
1066
}
1067