• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

overblog / GraphQLBundle / 21451946581

28 Jan 2026 07:10PM UTC coverage: 98.546% (-0.02%) from 98.563%
21451946581

Pull #1228

github

web-flow
Merge 7c2d0a455 into 1881475b6
Pull Request #1228: adding symfony8 to supported list

16 of 18 new or added lines in 2 files covered. (88.89%)

53 existing lines in 9 files now uncovered.

4541 of 4608 relevant lines covered (98.55%)

76.57 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.22
/src/Generator/TypeBuilder.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Overblog\GraphQLBundle\Generator;
6

7
use GraphQL\Language\AST\NodeKind;
8
use GraphQL\Language\Parser;
9
use GraphQL\Type\Definition\InputObjectType;
10
use GraphQL\Type\Definition\InterfaceType;
11
use GraphQL\Type\Definition\ObjectType;
12
use GraphQL\Type\Definition\ResolveInfo;
13
use GraphQL\Type\Definition\Type;
14
use GraphQL\Type\Definition\UnionType;
15
use Murtukov\PHPCodeGenerator\ArrowFunction;
16
use Murtukov\PHPCodeGenerator\Closure;
17
use Murtukov\PHPCodeGenerator\Config;
18
use Murtukov\PHPCodeGenerator\ConverterInterface;
19
use Murtukov\PHPCodeGenerator\GeneratorInterface;
20
use Murtukov\PHPCodeGenerator\Instance;
21
use Murtukov\PHPCodeGenerator\Literal;
22
use Murtukov\PHPCodeGenerator\PhpFile;
23
use Murtukov\PHPCodeGenerator\Utils;
24
use Overblog\GraphQLBundle\Definition\ConfigProcessor;
25
use Overblog\GraphQLBundle\Definition\GraphQLServices;
26
use Overblog\GraphQLBundle\Definition\Resolver\AliasedInterface;
27
use Overblog\GraphQLBundle\Definition\Type\CustomScalarType;
28
use Overblog\GraphQLBundle\Definition\Type\GeneratedTypeInterface;
29
use Overblog\GraphQLBundle\Definition\Type\PhpEnumType;
30
use Overblog\GraphQLBundle\Error\ResolveErrors;
31
use Overblog\GraphQLBundle\ExpressionLanguage\ExpressionLanguage as EL;
32
use Overblog\GraphQLBundle\Generator\Converter\ExpressionConverter;
33
use Overblog\GraphQLBundle\Generator\Exception\GeneratorException;
34
use Overblog\GraphQLBundle\Validator\InputValidator;
35
use ReflectionClass;
36
use Symfony\Component\Validator\Constraints\Choice;
37
use Symfony\Component\Validator\Constraints\Video;
38

39
use function array_map;
40
use function class_exists;
41
use function count;
42
use function explode;
43
use function in_array;
44
use function is_array;
45
use function is_callable;
46
use function is_string;
47
use function key;
48
use function ltrim;
49
use function reset;
50
use function rtrim;
51
use function strtolower;
52
use function substr;
53

54
/**
55
 * Service that exposes a single method `build` called for each GraphQL
56
 * type config to build a PhpFile object.
57
 *
58
 * {@link https://github.com/murtukov/php-code-generator}
59
 *
60
 * It's responsible for building all GraphQL types (object, input-object,
61
 * interface, union, enum and custom-scalar).
62
 *
63
 * Every method with prefix 'build' has a render example in it's PHPDoc.
64
 */
65
final class TypeBuilder
66
{
67
    private const CONSTRAINTS_NAMESPACE = 'Symfony\Component\Validator\Constraints';
68
    private const DOCBLOCK_TEXT = 'THIS FILE WAS GENERATED AND SHOULD NOT BE EDITED MANUALLY.';
69
    private const BUILT_IN_TYPES = [Type::STRING, Type::INT, Type::FLOAT, Type::BOOLEAN, Type::ID];
70

71
    private const EXTENDS = [
72
        'object' => ObjectType::class,
73
        'input-object' => InputObjectType::class,
74
        'interface' => InterfaceType::class,
75
        'union' => UnionType::class,
76
        'enum' => PhpEnumType::class,
77
        'custom-scalar' => CustomScalarType::class,
78
    ];
79

80
    private ExpressionConverter $expressionConverter;
81
    private PhpFile $file;
82
    private string $namespace;
83
    private array $config;
84
    private string $type;
85
    private string $currentField;
86
    private string $gqlServices = '$'.TypeGenerator::GRAPHQL_SERVICES;
87
    private bool $isSymfony74Plus;
88

89
    public function __construct(ExpressionConverter $expressionConverter, string $namespace)
90
    {
91
        $this->expressionConverter = $expressionConverter;
326✔
92
        $this->namespace = $namespace;
326✔
93

94
        // Register additional converter in the php code generator
95
        Config::registerConverter($expressionConverter, ConverterInterface::TYPE_STRING);
326✔
96
        $this->isSymfony74Plus = class_exists(Video::class);
326✔
97
    }
98

99
    /**
100
     * @param array{
101
     *     name:          string,
102
     *     class_name:    string,
103
     *     fields:        array,
104
     *     description?:  string,
105
     *     interfaces?:   array,
106
     *     resolveType?:  string,
107
     *     validation?:   array,
108
     *     types?:        array,
109
     *     values?:       array,
110
     *     serialize?:    callable,
111
     *     parseValue?:   callable,
112
     *     parseLiteral?: callable,
113
     * } $config
114
     *
115
     * @throws GeneratorException
116
     */
117
    public function build(array $config, string $type): PhpFile
118
    {
119
        // This values should be accessible from every method
120
        $this->config = $config;
88✔
121
        $this->type = $type;
88✔
122

123
        $this->file = PhpFile::new()->setNamespace($this->namespace);
88✔
124

125
        $class = $this->file->createClass($config['class_name'])
88✔
126
            ->setFinal()
88✔
127
            ->setExtends(static::EXTENDS[$type])
88✔
128
            ->addImplements(GeneratedTypeInterface::class, AliasedInterface::class)
88✔
129
            ->addConst('NAME', $config['name'])
88✔
130
            ->setDocBlock(static::DOCBLOCK_TEXT);
88✔
131

132
        $class->emptyLine();
88✔
133

134
        $class->createConstructor()
88✔
135
            ->addArgument('configProcessor', ConfigProcessor::class)
88✔
136
            ->addArgument(TypeGenerator::GRAPHQL_SERVICES, GraphQLServices::class)
88✔
137
            ->append('$config = ', $this->buildConfig($config))
88✔
138
            ->emptyLine()
88✔
139
            ->append('parent::__construct($configProcessor->process($config))');
88✔
140

141
        $class->createMethod('getAliases', 'public')
82✔
142
            ->setStatic()
82✔
143
            ->setReturnType('array')
82✔
144
            ->setDocBlock('{@inheritdoc}')
82✔
145
            ->append('return [self::NAME]');
82✔
146

147
        return $this->file;
82✔
148
    }
149

150
    /**
151
     * Converts a native GraphQL type string into the `webonyx/graphql-php`
152
     * type literal. References to user-defined types are converted into
153
     * TypeResolver method call and wrapped into a closure.
154
     *
155
     * Render examples:
156
     *
157
     *  -   "String"   -> Type::string()
158
     *  -   "String!"  -> Type::nonNull(Type::string())
159
     *  -   "[String!] -> Type::listOf(Type::nonNull(Type::string()))
160
     *  -   "[Post]"   -> Type::listOf($services->getType('Post'))
161
     *
162
     * @return GeneratorInterface|string
163
     */
164
    private function buildType(string $typeDefinition)
165
    {
166
        $typeNode = Parser::parseType($typeDefinition);
88✔
167

168
        $isReference = false;
88✔
169
        $type = $this->wrapTypeRecursive($typeNode, $isReference);
88✔
170

171
        if ($isReference) {
88✔
172
            // References to other types should be wrapped in a closure
173
            // for performance reasons
174
            return ArrowFunction::new($type);
66✔
175
        }
176

177
        return $type;
88✔
178
    }
179

180
    /**
181
     * Used by {@see buildType}.
182
     *
183
     * @param mixed $typeNode
184
     *
185
     * @return Literal|string
186
     */
187
    private function wrapTypeRecursive($typeNode, bool &$isReference)
188
    {
189
        switch ($typeNode->kind) {
88✔
190
            case NodeKind::NON_NULL_TYPE:
2✔
191
                $innerType = $this->wrapTypeRecursive($typeNode->type, $isReference);
82✔
192
                $type = Literal::new("Type::nonNull($innerType)");
82✔
193
                $this->file->addUse(Type::class);
82✔
194
                break;
82✔
195
            case NodeKind::LIST_TYPE:
2✔
196
                $innerType = $this->wrapTypeRecursive($typeNode->type, $isReference);
49✔
197
                $type = Literal::new("Type::listOf($innerType)");
49✔
198
                $this->file->addUse(Type::class);
49✔
199
                break;
49✔
200
            default: // NodeKind::NAMED_TYPE
201
                if (in_array($typeNode->name->value, static::BUILT_IN_TYPES)) {
88✔
202
                    $name = strtolower($typeNode->name->value);
88✔
203
                    $type = Literal::new("Type::$name()");
88✔
204
                    $this->file->addUse(Type::class);
88✔
205
                } else {
206
                    $name = $typeNode->name->value;
66✔
207
                    $type = "$this->gqlServices->getType('$name')";
66✔
208
                    $isReference = true;
66✔
209
                }
210
                break;
88✔
211
        }
212

213
        return $type;
88✔
214
    }
215

216
    /**
217
     * Builds a config array compatible with webonyx/graphql-php type system. The content
218
     * of the array depends on the GraphQL type that is currently being generated.
219
     *
220
     * Render example (object):
221
     *
222
     *      [
223
     *          'name' => self::NAME,
224
     *          'description' => 'Root query type',
225
     *          'fields' => fn() => [
226
     *              'posts' => {@see buildField},
227
     *              'users' => {@see buildField},
228
     *               ...
229
     *           ],
230
     *           'interfaces' => fn() => [
231
     *               $services->getType('PostInterface'),
232
     *               ...
233
     *           ],
234
     *           'resolveField' => {@see buildResolveField},
235
     *      ]
236
     *
237
     * Render example (input-object):
238
     *
239
     *      [
240
     *          'name' => self::NAME,
241
     *          'description' => 'Some description.',
242
     *          'validation' => {@see buildValidationRules}
243
     *          'fields' => fn() => [
244
     *              {@see buildField},
245
     *               ...
246
     *           ],
247
     *      ]
248
     *
249
     * Render example (interface)
250
     *
251
     *      [
252
     *          'name' => self::NAME,
253
     *          'description' => 'Some description.',
254
     *          'fields' => fn() => [
255
     *              {@see buildField},
256
     *               ...
257
     *           ],
258
     *          'resolveType' => {@see buildResolveType},
259
     *      ]
260
     *
261
     * Render example (union):
262
     *
263
     *      [
264
     *          'name' => self::NAME,
265
     *          'description' => 'Some description.',
266
     *          'types' => fn() => [
267
     *              $services->getType('Photo'),
268
     *              ...
269
     *          ],
270
     *          'resolveType' => {@see buildResolveType},
271
     *      ]
272
     *
273
     * Render example (custom-scalar):
274
     *
275
     *      [
276
     *          'name' => self::NAME,
277
     *          'description' => 'Some description'
278
     *          'serialize' => {@see buildScalarCallback},
279
     *          'parseValue' => {@see buildScalarCallback},
280
     *          'parseLiteral' => {@see buildScalarCallback},
281
     *      ]
282
     *
283
     * Render example (enum):
284
     *
285
     *      [
286
     *          'name' => self::NAME,
287
     *          'values' => [
288
     *              'PUBLISHED' => ['value' => 1],
289
     *              'DRAFT' => ['value' => 2],
290
     *              'STANDBY' => [
291
     *                  'value' => 3,
292
     *                  'description' => 'Waiting for validation',
293
     *              ],
294
     *              ...
295
     *          ],
296
     *      ]
297
     *
298
     * @throws GeneratorException
299
     */
300
    private function buildConfig(array $config): Collection
301
    {
302
        // Convert to an object for a better readability
303
        $c = (object) $config;
88✔
304

305
        $configLoader = Collection::assoc();
88✔
306
        $configLoader->addItem('name', new Literal('self::NAME'));
88✔
307

308
        if (isset($c->description)) {
88✔
309
            $configLoader->addItem('description', $c->description);
82✔
310
        }
311

312
        // only by input-object types (for class level validation)
313
        if (isset($c->validation)) {
88✔
314
            $configLoader->addItem('validation', $this->buildValidationRules($c->validation));
2✔
315
        }
316

317
        // only by object, input-object and interface types
318
        if (!empty($c->fields)) {
88✔
319
            $configLoader->addItem('fields', ArrowFunction::new(
88✔
320
                Collection::map($c->fields, [$this, 'buildField'])
88✔
321
            ));
88✔
322
        }
323

324
        if (!empty($c->interfaces)) {
82✔
325
            $items = array_map(fn ($type) => "$this->gqlServices->getType('$type')", $c->interfaces);
15✔
326
            $configLoader->addItem('interfaces', ArrowFunction::new(Collection::numeric($items, true)));
15✔
327
        }
328

329
        if (!empty($c->types)) {
82✔
330
            $items = array_map(fn ($type) => "$this->gqlServices->getType('$type')", $c->types);
3✔
331
            $configLoader->addItem('types', ArrowFunction::new(Collection::numeric($items, true)));
3✔
332
        }
333

334
        if (isset($c->resolveType)) {
82✔
335
            $configLoader->addItem('resolveType', $this->buildResolveType($c->resolveType));
10✔
336
        }
337

338
        if (isset($c->isTypeOf)) {
82✔
339
            $configLoader->addItem('isTypeOf', $this->buildIsTypeOf($c->isTypeOf));
3✔
340
        }
341

342
        if (isset($c->resolveField)) {
82✔
343
            $configLoader->addItem('resolveField', $this->buildResolve($c->resolveField));
6✔
344
        }
345

346
        // only by enum types
347
        if (isset($c->values)) {
82✔
348
            $configLoader->addItem('values', Collection::assoc($c->values));
15✔
349
        }
350
        if (isset($c->enumClass)) {
82✔
351
            $configLoader->addItem('enumClass', $c->enumClass);
3✔
352
        }
353

354
        // only by custom-scalar types
355
        if ('custom-scalar' === $this->type) {
82✔
356
            if (isset($c->scalarType)) {
9✔
357
                $configLoader->addItem('scalarType', $c->scalarType);
3✔
358
            }
359

360
            if (isset($c->serialize)) {
9✔
361
                $configLoader->addItem('serialize', $this->buildScalarCallback($c->serialize, 'serialize'));
6✔
362
            }
363

364
            if (isset($c->parseValue)) {
9✔
365
                $configLoader->addItem('parseValue', $this->buildScalarCallback($c->parseValue, 'parseValue'));
6✔
366
            }
367

368
            if (isset($c->parseLiteral)) {
9✔
369
                $configLoader->addItem('parseLiteral', $this->buildScalarCallback($c->parseLiteral, 'parseLiteral'));
6✔
370
            }
371
        }
372

373
        return $configLoader;
82✔
374
    }
375

376
    /**
377
     * Builds an arrow function that calls a static method.
378
     *
379
     * Render example:
380
     *
381
     *      fn() => MyClassName::myMethodName(...\func_get_args())
382
     *
383
     * @param callable $callback - a callable string or a callable array
384
     *
385
     * @return ArrowFunction
386
     *
387
     * @throws GeneratorException
388
     */
389
    private function buildScalarCallback($callback, string $fieldName)
390
    {
391
        if (!is_callable($callback)) {
6✔
392
            throw new GeneratorException("Value of '$fieldName' is not callable.");
×
393
        }
394

395
        $closure = new ArrowFunction();
6✔
396

397
        if (!is_string($callback)) {
6✔
398
            [$class, $method] = $callback;
6✔
399
        } else {
400
            [$class, $method] = explode('::', $callback);
3✔
401
        }
402

403
        $className = Utils::resolveQualifier($class);
6✔
404

405
        if ($className === $this->config['class_name']) {
6✔
406
            // Create an alias if name of serializer is same as type name
407
            $className = 'Base'.$className;
3✔
408
            $this->file->addUse($class, $className);
3✔
409
        } else {
410
            $this->file->addUse($class);
3✔
411
        }
412

413
        $closure->setExpression(Literal::new("$className::$method(...\\func_get_args())"));
6✔
414

415
        return $closure;
6✔
416
    }
417

418
    /**
419
     * Builds a resolver closure that contains the compiled result of user-defined
420
     * expression and optionally the validation logic.
421
     *
422
     * Render example (no expression language):
423
     *
424
     *      function ($value, $args, $context, $info) use ($services) {
425
     *          return "Hello, World!";
426
     *      }
427
     *
428
     * Render example (with expression language):
429
     *
430
     *      function ($value, $args, $context, $info) use ($services) {
431
     *          return $services->mutation("my_resolver", $args);
432
     *      }
433
     *
434
     * Render example (with validation):
435
     *
436
     *      function ($value, $args, $context, $info) use ($services) {
437
     *          $validator = $services->createInputValidator(...func_get_args());
438
     *          return $services->mutation("create_post", $validator]);
439
     *      }
440
     *
441
     * Render example (with validation, but errors are injected into the user-defined resolver):
442
     * {@link https://github.com/overblog/GraphQLBundle/blob/master/docs/validation/index.md#injecting-errors}
443
     *
444
     *      function ($value, $args, $context, $info) use ($services) {
445
     *          $errors = new ResolveErrors();
446
     *          $validator = $services->createInputValidator(...func_get_args());
447
     *
448
     *          $errors->setValidationErrors($validator->validate(null, false))
449
     *
450
     *          return $services->mutation("create_post", $errors);
451
     *      }
452
     *
453
     * @param mixed $resolve
454
     *
455
     * @throws GeneratorException
456
     */
457
    private function buildResolve($resolve, ?array $groups = null): GeneratorInterface
458
    {
459
        if (is_callable($resolve) && is_array($resolve)) {
78✔
460
            return Collection::numeric($resolve);
6✔
461
        }
462

463
        // TODO: before creating an input validator, check if any validation rules are defined
464
        if (EL::isStringWithTrigger($resolve)) {
76✔
465
            $closure = Closure::new()
74✔
466
                ->addArguments('value', 'args', 'context', 'info')
74✔
467
                ->bindVar(TypeGenerator::GRAPHQL_SERVICES);
74✔
468

469
            $injectValidator = EL::expressionContainsVar('validator', $resolve);
74✔
470

471
            if ($this->configContainsValidation()) {
74✔
472
                $injectErrors = EL::expressionContainsVar('errors', $resolve);
11✔
473

474
                if ($injectErrors) {
11✔
475
                    $closure->append('$errors = ', Instance::new(ResolveErrors::class));
2✔
476
                }
477

478
                $closure->append('$validator = ', "$this->gqlServices->createInputValidator(...func_get_args())");
11✔
479

480
                // If auto-validation on or errors are injected
481
                if (!$injectValidator || $injectErrors) {
11✔
482
                    if (!empty($groups)) {
5✔
483
                        $validationGroups = Collection::numeric($groups);
2✔
484
                    } else {
485
                        $validationGroups = 'null';
5✔
486
                    }
487

488
                    $closure->emptyLine();
5✔
489

490
                    if ($injectErrors) {
5✔
491
                        $closure->append('$errors->setValidationErrors($validator->validate(', $validationGroups, ', false))');
2✔
492
                    } else {
493
                        $closure->append('$validator->validate(', $validationGroups, ')');
5✔
494
                    }
495

496
                    $closure->emptyLine();
8✔
497
                }
498
            } elseif ($injectValidator) {
66✔
499
                throw new GeneratorException('Unable to inject an instance of the InputValidator. No validation constraints provided. Please remove the "validator" argument from the list of dependencies of your resolver or provide validation configs.');
2✔
500
            }
501

502
            $closure->append('return ', $this->expressionConverter->convert($resolve));
72✔
503

504
            return $closure;
72✔
505
        }
506

507
        return ArrowFunction::new($resolve);
42✔
508
    }
509

510
    /**
511
     * Checks if given config contains any validation rules.
512
     */
513
    private function configContainsValidation(): bool
514
    {
515
        $fieldConfig = $this->config['fields'][$this->currentField];
74✔
516

517
        if (!empty($fieldConfig['validation'])) {
74✔
518
            return true;
2✔
519
        }
520

521
        foreach ($fieldConfig['args'] ?? [] as $argConfig) {
74✔
522
            if (!empty($argConfig['validation'])) {
66✔
523
                return true;
11✔
524
            }
525
        }
526

527
        return false;
66✔
528
    }
529

530
    /**
531
     * Render example:
532
     *
533
     *      [
534
     *          'link' => {@see normalizeLink}
535
     *          'cascade' => [
536
     *              'groups' => ['my_group'],
537
     *          ],
538
     *          'constraints' => {@see buildConstraints}
539
     *      ]
540
     *
541
     * If only constraints provided, uses {@see buildConstraints} directly.
542
     *
543
     * @param array{
544
     *     constraints: array,
545
     *     link: string,
546
     *     cascade: array
547
     * } $config
548
     *
549
     * @throws GeneratorException
550
     */
551
    private function buildValidationRules(array $config): GeneratorInterface
552
    {
553
        // Convert to object for better readability
554
        $c = (object) $config;
9✔
555

556
        $array = Collection::assoc();
9✔
557

558
        if (!empty($c->link)) {
9✔
559
            if (!str_contains($c->link, '::')) {
2✔
560
                // e.g. App\Entity\Droid
561
                $array->addItem('link', $c->link);
2✔
562
            } else {
563
                // e.g. App\Entity\Droid::$id
564
                $array->addItem('link', Collection::numeric($this->normalizeLink($c->link)));
2✔
565
            }
566
        }
567

568
        if (isset($c->cascade)) {
9✔
569
            // If there are only constarainst, use short syntax
570
            if (empty($c->cascade['groups'])) {
5✔
571
                $this->file->addUse(InputValidator::class);
5✔
572

573
                return Literal::new('InputValidator::CASCADE');
5✔
574
            }
575
            $array->addItem('cascade', $c->cascade['groups']);
2✔
576
        }
577

578
        if (!empty($c->constraints)) {
9✔
579
            // If there are only constarainst, use short syntax
580
            if (0 === $array->count()) {
9✔
581
                return $this->buildConstraints($c->constraints);
9✔
582
            }
583
            $array->addItem('constraints', $this->buildConstraints($c->constraints));
2✔
584
        }
585

586
        return $array;
2✔
587
    }
588

589
    /**
590
     * Builds a closure or a numeric multiline array with Symfony Constraint
591
     * instances. The array is used by {@see InputValidator} during requests.
592
     *
593
     * Render example (array):
594
     *
595
     *      [
596
     *          new NotNull(),
597
     *          new Length([
598
     *              'min' => 5,
599
     *              'max' => 10
600
     *          ]),
601
     *          ...
602
     *      ]
603
     *
604
     * Render example (in a closure):
605
     *
606
     *      fn() => [
607
     *          new NotNull(),
608
     *          new Length([
609
     *              'min' => 5,
610
     *              'max' => 10
611
     *          ]),
612
     *          ...
613
     *      ]
614
     *
615
     * @return ArrowFunction|Collection
616
     *
617
     * @throws GeneratorException
618
     */
619
    private function buildConstraints(array $constraints = [], bool $inClosure = true)
620
    {
621
        $result = Collection::numeric()->setMultiline();
9✔
622

623
        foreach ($constraints as $wrapper) {
9✔
624
            $name = key($wrapper);
9✔
625
            $args = reset($wrapper);
9✔
626

627
            if (str_contains($name, '\\')) {
9✔
628
                // Custom constraint
629
                $fqcn = ltrim($name, '\\');
4✔
630
                $instance = Instance::new("@\\$fqcn");
4✔
631
            } else {
632
                // Symfony constraint
633
                $fqcn = static::CONSTRAINTS_NAMESPACE."\\$name";
9✔
634
                $this->file->addUse(static::CONSTRAINTS_NAMESPACE.' as SymfonyConstraints');
9✔
635
                $instance = Instance::new("@SymfonyConstraints\\$name");
9✔
636
            }
637

638
            if (!class_exists($fqcn)) {
9✔
639
                throw new GeneratorException("Constraint class '$fqcn' doesn't exist.");
2✔
640
            }
641

642
            if (is_array($args)) {
7✔
643
                if ($this->isSymfony74Plus && isset($args[0]) && Choice::class === $fqcn) {
4✔
644
                    // Handle Choice constraint in Symfony 7.4+
645
                    $args = ['choices' => $args];
1✔
646
                }
647

648
                /*
649
                 * In Symfony 7.4+, we should not pass an array, but split up parameters in different arguments.
650
                 */
651
                if ($this->isSymfony74Plus && false === isset($args[0]) && [] !== $args) {
4✔
652
                    $reflectionClass = new ReflectionClass($fqcn);
2✔
653
                    $constructor = $reflectionClass->getConstructor();
2✔
654
                    if (null === $constructor) {
2✔
NEW
655
                        throw new GeneratorException("Constraint '$fqcn' doesn't have a constructor.");
×
656
                    }
657
                    $parameters = $constructor->getParameters();
2✔
658
                    foreach ($parameters as $parameter) {
2✔
659
                        $name = $parameter->getName();
2✔
660
                        if (isset($args[$name])) {
2✔
661
                            $instance->addArgument($args[$name]);
2✔
662
                        } elseif ($parameter->isDefaultValueAvailable()) {
2✔
663
                            $instance->addArgument($parameter->getDefaultValue());
2✔
664
                        } else {
NEW
665
                            throw new GeneratorException("Constraint '$fqcn' requires argument '$name'.");
×
666
                        }
667
                    }
668
                } elseif (isset($args[0]) && is_array($args[0])) {
3✔
669
                    // Nested instance
670
                    $instance->addArgument($this->buildConstraints($args, false));
2✔
671
                } elseif (isset($args['constraints'][0]) && is_array($args['constraints'][0])) {
3✔
672
                    // Nested instance with "constraints" key (full syntax)
UNCOV
673
                    $options = [
1✔
UNCOV
674
                        'constraints' => $this->buildConstraints($args['constraints'], false),
1✔
UNCOV
675
                    ];
1✔
676

677
                    // Check for additional options
UNCOV
678
                    foreach ($args as $key => $option) {
1✔
UNCOV
679
                        if ('constraints' === $key) {
1✔
UNCOV
680
                            continue;
1✔
681
                        }
UNCOV
682
                        $options[$key] = $option;
1✔
683
                    }
684

UNCOV
685
                    $instance->addArgument($options);
1✔
686
                } else {
687
                    // Numeric or Assoc array?
688
                    $instance->addArgument(isset($args[0]) ? $args : Collection::assoc($args));
3✔
689
                }
690
            } elseif (null !== $args) {
7✔
691
                $instance->addArgument($args);
2✔
692
            }
693

694
            $result->push($instance);
7✔
695
        }
696

697
        if ($inClosure) {
7✔
698
            return ArrowFunction::new($result);
7✔
699
        }
700

701
        return $result; // @phpstan-ignore-line
2✔
702
    }
703

704
    /**
705
     * Render example:
706
     *
707
     *      [
708
     *          'type' => {@see buildType},
709
     *          'description' => 'Some description.',
710
     *          'deprecationReason' => 'This field will be removed soon.',
711
     *          'args' => fn() => [
712
     *              {@see buildArg},
713
     *              {@see buildArg},
714
     *               ...
715
     *           ],
716
     *          'resolve' => {@see buildResolve},
717
     *          'complexity' => {@see buildComplexity},
718
     *      ]
719
     *
720
     * @param array{
721
     *     type:              string,
722
     *     resolve?:          string,
723
     *     description?:      string,
724
     *     args?:             array,
725
     *     complexity?:       string,
726
     *     deprecatedReason?: string,
727
     *     validation?:       array,
728
     * } $fieldConfig
729
     *
730
     * @internal
731
     *
732
     * @return GeneratorInterface|Collection|string
733
     *
734
     * @throws GeneratorException
735
     */
736
    public function buildField(array $fieldConfig, string $fieldname)
737
    {
738
        $this->currentField = $fieldname;
88✔
739

740
        // Convert to object for better readability
741
        $c = (object) $fieldConfig;
88✔
742

743
        // TODO(any): modify `InputValidator` and `TypeDecoratorListener` to support it before re-enabling this
744
        // see https://github.com/overblog/GraphQLBundle/issues/973
745
        // If there is only 'type', use shorthand
746
        /*if (1 === count($fieldConfig) && isset($c->type)) {
747
            return $this->buildType($c->type);
748
        }*/
749

750
        $field = Collection::assoc()
88✔
751
            ->addItem('type', $this->buildType($c->type));
88✔
752

753
        // only for object types
754
        if (isset($c->resolve)) {
88✔
755
            if (isset($c->validation)) {
78✔
756
                $field->addItem('validation', $this->buildValidationRules($c->validation));
2✔
757
            }
758
            $field->addItem('resolve', $this->buildResolve($c->resolve, $fieldConfig['validationGroups'] ?? null));
78✔
759
        }
760

761
        if (isset($c->deprecationReason)) {
86✔
762
            $field->addItem('deprecationReason', $c->deprecationReason);
3✔
763
        }
764

765
        if (isset($c->description)) {
86✔
766
            $field->addItem('description', $c->description);
82✔
767
        }
768

769
        if (!empty($c->args)) {
86✔
770
            $field->addItem('args', Collection::map($c->args, [$this, 'buildArg'], false));
67✔
771
        }
772

773
        if (isset($c->complexity)) {
82✔
774
            $field->addItem('complexity', $this->buildComplexity($c->complexity));
9✔
775
        }
776

777
        if (isset($c->public)) {
82✔
778
            $field->addItem('public', $this->buildPublic($c->public));
5✔
779
        }
780

781
        if (isset($c->access)) {
82✔
782
            $field->addItem('access', $this->buildAccess($c->access));
6✔
783
        }
784

785
        if (!empty($c->access) && is_string($c->access) && EL::expressionContainsVar('object', $c->access)) {
82✔
786
            $field->addItem('useStrictAccess', false);
3✔
787
        }
788

789
        if ('input-object' === $this->type) {
82✔
790
            if (property_exists($c, 'defaultValue')) {
27✔
791
                $field->addItem('defaultValue', $c->defaultValue);
6✔
792
            }
793

794
            if (isset($c->validation)) {
27✔
795
                $field->addItem('validation', $this->buildValidationRules($c->validation));
5✔
796
            }
797
        }
798

799
        return $field;
82✔
800
    }
801

802
    /**
803
     * Render example:
804
     * <code>
805
     *  [
806
     *      'name' => 'username',
807
     *      'type' => {@see buildType},
808
     *      'description' => 'Some fancy description.',
809
     *      'defaultValue' => 'admin',
810
     *  ]
811
     * </code>
812
     *
813
     * @param array{
814
     *     type: string,
815
     *     description?: string,
816
     *     defaultValue?: string
817
     * } $argConfig
818
     *
819
     * @internal
820
     *
821
     * @throws GeneratorException
822
     */
823
    public function buildArg(array $argConfig, string $argName): Collection
824
    {
825
        // Convert to object for better readability
826
        $c = (object) $argConfig;
67✔
827

828
        $arg = Collection::assoc()
67✔
829
            ->addItem('name', $argName)
67✔
830
            ->addItem('type', $this->buildType($c->type));
67✔
831

832
        if (isset($c->description)) {
67✔
833
            $arg->addIfNotEmpty('description', $c->description);
5✔
834
        }
835

836
        if (property_exists($c, 'defaultValue')) {
67✔
837
            $arg->addItem('defaultValue', $c->defaultValue);
5✔
838
        }
839

840
        if (!empty($c->validation)) {
67✔
841
            if (in_array($c->type, self::BUILT_IN_TYPES) && isset($c->validation['cascade'])) {
11✔
842
                throw new GeneratorException('Cascade validation cannot be applied to built-in types.');
2✔
843
            }
844

845
            $arg->addIfNotEmpty('validation', $this->buildValidationRules($c->validation));
9✔
846
        }
847

848
        return $arg;
63✔
849
    }
850

851
    /**
852
     * Builds a closure or an arrow function, depending on whether the `args` param is provided.
853
     *
854
     * Render example (closure):
855
     *
856
     *      function ($value, $arguments) use ($services) {
857
     *          $args = $services->get('argumentFactory')->create($arguments);
858
     *          return ($args['age'] + 5);
859
     *      }
860
     *
861
     * Render example (arrow function):
862
     *
863
     *      fn($childrenComplexity) => ($childrenComplexity + 20);
864
     *
865
     * @param mixed $complexity
866
     *
867
     * @return Closure|mixed
868
     */
869
    private function buildComplexity($complexity)
870
    {
871
        if (EL::isStringWithTrigger($complexity)) {
9✔
872
            $expression = $this->expressionConverter->convert($complexity);
7✔
873

874
            if (EL::expressionContainsVar('args', $complexity)) {
7✔
875
                return Closure::new()
7✔
876
                    ->addArgument('childrenComplexity')
7✔
877
                    ->addArgument('arguments', '', [])
7✔
878
                    ->bindVar(TypeGenerator::GRAPHQL_SERVICES)
7✔
879
                    ->append('$args = ', "$this->gqlServices->get('argumentFactory')->create(\$arguments)")
7✔
880
                    ->append('return ', $expression)
7✔
881
                ;
7✔
882
            }
883

884
            $arrow = ArrowFunction::new(is_string($expression) ? new Literal($expression) : $expression);
7✔
885

886
            if (EL::expressionContainsVar('childrenComplexity', $complexity)) {
7✔
887
                $arrow->addArgument('childrenComplexity');
7✔
888
            }
889

890
            return $arrow;
7✔
891
        }
892

893
        return new ArrowFunction(0);
2✔
894
    }
895

896
    /**
897
     * Builds an arrow function from a string with an expression prefix,
898
     * otherwise just returns the provided value back untouched.
899
     *
900
     * Render example (if expression):
901
     *
902
     *      fn($fieldName, $typeName = self::NAME) => ($fieldName == "name")
903
     *
904
     * @param mixed $public
905
     *
906
     * @return ArrowFunction|mixed
907
     */
908
    private function buildPublic($public)
909
    {
910
        if (EL::isStringWithTrigger($public)) {
5✔
911
            $expression = $this->expressionConverter->convert($public);
3✔
912
            $arrow = ArrowFunction::new(Literal::new($expression));
3✔
913

914
            if (EL::expressionContainsVar('fieldName', $public)) {
3✔
915
                $arrow->addArgument('fieldName');
3✔
916
            }
917

918
            if (EL::expressionContainsVar('typeName', $public)) {
3✔
919
                $arrow->addArgument('fieldName');
3✔
920
                $arrow->addArgument('typeName', '', new Literal('self::NAME'));
3✔
921
            }
922

923
            return $arrow;
3✔
924
        }
925

926
        return $public;
2✔
927
    }
928

929
    /**
930
     * Builds an arrow function from a string with an expression prefix,
931
     * otherwise just returns the provided value back untouched.
932
     *
933
     * Render example (if expression):
934
     *
935
     *      fn($value, $args, $context, $info, $object) => $services->get('private_service')->hasAccess()
936
     *
937
     * @param mixed $access
938
     *
939
     * @return ArrowFunction|mixed
940
     */
941
    private function buildAccess($access)
942
    {
943
        if (EL::isStringWithTrigger($access)) {
6✔
944
            $expression = $this->expressionConverter->convert($access);
6✔
945

946
            return ArrowFunction::new()
6✔
947
                ->addArguments('value', 'args', 'context', 'info', 'object')
6✔
948
                ->setExpression(Literal::new($expression));
6✔
949
        }
950

951
        return $access;
3✔
952
    }
953

954
    /**
955
     * Builds an arrow function from a string with an expression prefix,
956
     * otherwise just returns the provided value back untouched.
957
     *
958
     * Render example:
959
     *
960
     *      fn($value, $context, $info) => $services->getType($value)
961
     *
962
     * @param mixed $resolveType
963
     *
964
     * @return mixed|ArrowFunction
965
     */
966
    private function buildResolveType($resolveType)
967
    {
968
        if (EL::isStringWithTrigger($resolveType)) {
10✔
969
            $expression = $this->expressionConverter->convert($resolveType);
8✔
970

971
            return ArrowFunction::new()
8✔
972
                ->addArguments('value', 'context', 'info')
8✔
973
                ->setExpression(Literal::new($expression));
8✔
974
        }
975

976
        return $resolveType;
3✔
977
    }
978

979
    /**
980
     * Builds an arrow function from a string with an expression prefix,
981
     * otherwise just returns the provided value back untouched.
982
     *
983
     * Render example:
984
     *
985
     *      fn($className) => (($className = "App\\ClassName") && $value instanceof $className)
986
     *
987
     * @param mixed $isTypeOf
988
     */
989
    private function buildIsTypeOf($isTypeOf): ArrowFunction
990
    {
991
        if (EL::isStringWithTrigger($isTypeOf)) {
3✔
992
            $expression = $this->expressionConverter->convert($isTypeOf);
×
993

994
            return ArrowFunction::new(Literal::new($expression), 'bool')
×
995
                ->setStatic()
×
996
                ->addArguments('value', 'context')
×
997
                ->addArgument('info', ResolveInfo::class);
×
998
        }
999

1000
        return ArrowFunction::new($isTypeOf);
3✔
1001
    }
1002

1003
    /**
1004
     * Creates and array from a formatted string.
1005
     *
1006
     * Examples:
1007
     *
1008
     *      "App\Entity\User::$firstName"  -> ['App\Entity\User', 'firstName', 'property']
1009
     *      "App\Entity\User::firstName()" -> ['App\Entity\User', 'firstName', 'getter']
1010
     *      "App\Entity\User::firstName"   -> ['App\Entity\User', 'firstName', 'member']
1011
     */
1012
    private function normalizeLink(string $link): array
1013
    {
1014
        [$fqcn, $classMember] = explode('::', $link);
2✔
1015

1016
        if ('$' === $classMember[0]) {
2✔
1017
            return [$fqcn, ltrim($classMember, '$'), 'property'];
2✔
1018
        } elseif (')' === substr($classMember, -1)) {
2✔
1019
            return [$fqcn, rtrim($classMember, '()'), 'getter'];
2✔
1020
        }
1021

1022
        return [$fqcn, $classMember, 'member'];
2✔
1023
    }
1024
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc