Passed
Pull Request — master (#159)
by Christoffer
02:37
created

SchemaExtender::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 5
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 3
nc 1
nop 2
1
<?php
2
3
namespace Digia\GraphQL\SchemaExtension;
4
5
use Digia\GraphQL\Cache\CacheAwareTrait;
6
use Digia\GraphQL\Error\ExecutionException;
7
use Digia\GraphQL\Error\ExtensionException;
8
use Digia\GraphQL\Error\InvalidTypeException;
9
use Digia\GraphQL\Error\InvariantException;
10
use Digia\GraphQL\Language\Node\DirectiveDefinitionNode;
11
use Digia\GraphQL\Language\Node\DocumentNode;
12
use Digia\GraphQL\Language\Node\EnumTypeExtensionNode;
13
use Digia\GraphQL\Language\Node\InputObjectTypeExtensionNode;
14
use Digia\GraphQL\Language\Node\InterfaceTypeExtensionNode;
15
use Digia\GraphQL\Language\Node\NamedTypeNode;
16
use Digia\GraphQL\Language\Node\NodeInterface;
17
use Digia\GraphQL\Language\Node\ObjectTypeExtensionNode;
18
use Digia\GraphQL\Language\Node\ScalarTypeExtensionNode;
19
use Digia\GraphQL\Language\Node\TypeDefinitionNodeInterface;
20
use Digia\GraphQL\Language\Node\TypeExtensionNodeInterface;
21
use Digia\GraphQL\Language\Node\UnionTypeExtensionNode;
22
use Digia\GraphQL\SchemaBuilder\DefinitionBuilderCreatorInterface;
23
use Digia\GraphQL\SchemaBuilder\DefinitionBuilderInterface;
24
use Digia\GraphQL\Type\Definition\Argument;
25
use Digia\GraphQL\Type\Definition\Directive;
26
use Digia\GraphQL\Type\Definition\InterfaceType;
27
use Digia\GraphQL\Type\Definition\ListType;
28
use Digia\GraphQL\Type\Definition\NamedTypeInterface;
29
use Digia\GraphQL\Type\Definition\NonNullType;
30
use Digia\GraphQL\Type\Definition\ObjectType;
31
use Digia\GraphQL\Type\Definition\TypeInterface;
32
use Digia\GraphQL\Type\Definition\UnionType;
33
use Digia\GraphQL\Type\SchemaInterface;
34
use Psr\SimpleCache\CacheInterface;
35
use Psr\SimpleCache\InvalidArgumentException;
36
use function Digia\GraphQL\Type\GraphQLInterfaceType;
37
use function Digia\GraphQL\Type\GraphQLList;
38
use function Digia\GraphQL\Type\GraphQLNonNull;
39
use function Digia\GraphQL\Type\GraphQLObjectType;
40
use function Digia\GraphQL\Type\GraphQLSchema;
41
use function Digia\GraphQL\Type\GraphQLUnionType;
42
use function Digia\GraphQL\Type\isIntrospectionType;
43
use function Digia\GraphQL\Util\invariant;
44
use function Digia\GraphQL\Util\keyMap;
45
use function Digia\GraphQL\Util\toString;
46
47
class SchemaExtender implements SchemaExtenderInterface
48
{
49
    use CacheAwareTrait;
50
51
    private const CACHE_PREFIX = 'GraphQL_SchemaExtender_';
52
53
    /**
54
     * @var DefinitionBuilderCreatorInterface
55
     */
56
    protected $definitionBuilderCreator;
57
58
    /**
59
     * @var DefinitionBuilderInterface
60
     */
61
    protected $definitionBuilder;
62
63
    /**
64
     * @var TypeExtensionNodeInterface[][]
65
     */
66
    protected $typeExtensionsMap;
67
68
    /**
69
     * SchemaExtender constructor.
70
     * @param DefinitionBuilderCreatorInterface $definitionBuilderCreator
71
     */
72
    public function __construct(DefinitionBuilderCreatorInterface $definitionBuilderCreator, CacheInterface $cache)
73
    {
74
        $this->definitionBuilderCreator = $definitionBuilderCreator;
75
        $this->typeExtensionsMap        = [];
76
        $this->cache                    = $cache;
77
    }
78
79
    /**
80
     * @param SchemaInterface $schema
81
     * @param DocumentNode    $document
82
     * @param array           $options
83
     * @return SchemaInterface
84
     * @throws InvariantException
85
     * @throws ExtensionException
86
     * @throws InvalidArgumentException
87
     */
88
    public function extend(SchemaInterface $schema, DocumentNode $document, array $options = []): SchemaInterface
89
    {
90
        $typeDefinitionMap    = [];
91
        $directiveDefinitions = [];
92
93
        foreach ($document->getDefinitions() as $definition) {
94
            if ($definition instanceof TypeDefinitionNodeInterface) {
95
                // Sanity check that none of the defined types conflict with the schema's existing types.
96
                $typeName     = $definition->getNameValue();
97
                $existingType = $schema->getType($typeName);
98
99
                if (null !== $existingType) {
100
                    throw new ExtensionException(
101
                        \sprintf(
102
                            'Type "%s" already exists in the schema. It cannot also ' .
103
                            'be defined in this type definition.',
104
                            $typeName
105
                        ),
106
                        [$definition]
107
                    );
108
                }
109
110
                $typeDefinitionMap[$typeName] = $definition;
111
112
                continue;
113
            }
114
115
            if ($definition instanceof ObjectTypeExtensionNode || $definition instanceof InterfaceTypeExtensionNode) {
116
                // Sanity check that this type extension exists within the schema's existing types.
117
                $extendedTypeName = $definition->getNameValue();
118
                $existingType     = $schema->getType($extendedTypeName);
119
120
                if (null === $existingType) {
121
                    throw new ExtensionException(
122
                        \sprintf(
123
                            'Cannot extend type "%s" because it does not exist in the existing schema.',
124
                            $extendedTypeName
125
                        ),
126
                        [$definition]
127
                    );
128
                }
129
130
                $this->checkExtensionNode($existingType, $definition);
131
132
                $existingTypeExtensions                     = $this->typeExtensionsMap[$extendedTypeName] ?? [];
133
                $this->typeExtensionsMap[$extendedTypeName] = \array_merge($existingTypeExtensions, [$definition]);
134
135
                continue;
136
            }
137
138
            if ($definition instanceof DirectiveDefinitionNode) {
139
                $directiveName     = $definition->getNameValue();
140
                $existingDirective = $schema->getDirective($directiveName);
141
142
                if (null !== $existingDirective) {
143
                    throw new ExtensionException(
144
                        \sprintf(
145
                            'Directive "%s" already exists in the schema. It cannot be redefined.',
146
                            $directiveName
147
                        ),
148
                        [$definition]
149
                    );
150
                }
151
152
                $directiveDefinitions[] = $definition;
153
154
                continue;
155
            }
156
157
            if ($definition instanceof ScalarTypeExtensionNode ||
158
                $definition instanceof UnionTypeExtensionNode ||
159
                $definition instanceof EnumTypeExtensionNode ||
160
                $definition instanceof InputObjectTypeExtensionNode) {
161
                throw new ExtensionException(
162
                    \sprintf('The %s kind is not yet supported by extendSchema().', $definition->getKind())
163
                );
164
            }
165
        }
166
167
        // If this document contains no new types, extensions, or directives then
168
        // return the same unmodified GraphQLSchema instance.
169
        if (empty($typeDefinitionMap) && empty($this->typeExtensionsMap) && empty($directiveDefinitions)) {
170
            return $schema;
171
        }
172
173
        $resolveTypeFunction = function (NamedTypeNode $node) use ($schema): ?TypeInterface {
174
            $typeName     = $node->getNameValue();
175
            $existingType = $schema->getType($typeName);
176
177
            if (null !== $existingType) {
178
                /** @noinspection PhpIncompatibleReturnTypeInspection */
179
                /** @noinspection PhpParamsInspection */
180
                return $this->getExtendedType($existingType);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->getExtendedType($existingType) returns the type Digia\GraphQL\Type\Definition\NamedTypeInterface which is incompatible with the type-hinted return null|Digia\GraphQL\Type\Definition\TypeInterface.
Loading history...
Bug introduced by
$existingType of type Digia\GraphQL\Type\Definition\TypeInterface is incompatible with the type Digia\GraphQL\Type\Definition\NamedTypeInterface expected by parameter $type of Digia\GraphQL\SchemaExte...nder::getExtendedType(). ( Ignorable by Annotation )

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

180
                return $this->getExtendedType(/** @scrutinizer ignore-type */ $existingType);
Loading history...
181
            }
182
183
            throw new ExecutionException(
184
                \sprintf(
185
                    'Unknown type: "%s". Ensure that this type exists ' .
186
                    'either in the original schema, or is added in a type definition.',
187
                    $typeName
188
                ),
189
                [$node]
190
            );
191
        };
192
193
        $this->definitionBuilder = $this->definitionBuilderCreator->create(
194
            $typeDefinitionMap,
195
            $resolveTypeFunction
196
        );
197
198
        $this->cache->clear();
199
200
        $existingQueryType        = $schema->getQueryType();
201
        $existingMutationType     = $schema->getMutationType();
202
        $existingSubscriptionType = $schema->getSubscriptionType();
203
204
        /** @noinspection PhpParamsInspection */
205
        return GraphQLSchema([
206
            'query'        => null !== $existingQueryType
207
                ? $this->getExtendedType($existingQueryType)
0 ignored issues
show
Bug introduced by
$existingQueryType of type Digia\GraphQL\Type\Definition\TypeInterface is incompatible with the type Digia\GraphQL\Type\Definition\NamedTypeInterface expected by parameter $type of Digia\GraphQL\SchemaExte...nder::getExtendedType(). ( Ignorable by Annotation )

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

207
                ? $this->getExtendedType(/** @scrutinizer ignore-type */ $existingQueryType)
Loading history...
208
                : null,
209
            'mutation'     => null !== $existingMutationType ?
210
                $this->getExtendedType($existingMutationType)
211
                : null,
212
            'subscription' => null !== $existingSubscriptionType
213
                ? $this->getExtendedType($existingSubscriptionType)
214
                : null,
215
            'types'        => \array_merge(
216
                \array_map(function ($type) {
217
                    return $this->getExtendedType($type);
218
                }, \array_values($schema->getTypeMap())),
219
                $this->definitionBuilder->buildTypes(\array_values($typeDefinitionMap))
220
            ),
221
            'directives'   => $this->getMergedDirectives($schema, $directiveDefinitions),
222
            'astNode'      => $schema->getAstNode(),
223
        ]);
224
    }
225
226
    /**
227
     * @param SchemaInterface $schema
228
     * @param array           $directiveDefinitions
229
     * @return Directive[]
230
     * @throws InvariantException
231
     */
232
    protected function getMergedDirectives(SchemaInterface $schema, array $directiveDefinitions): array
233
    {
234
        $existingDirectives = $schema->getDirectives();
235
236
        invariant(!empty($existingDirectives), 'schema must have default directives');
237
238
        return \array_merge(
239
            $existingDirectives,
240
            \array_map(function (DirectiveDefinitionNode $node) {
241
                return $this->definitionBuilder->buildDirective($node);
242
            }, $directiveDefinitions)
243
        );
244
    }
245
246
    /**
247
     * @param TypeInterface $type
248
     * @param NodeInterface $node
249
     * @throws ExtensionException
250
     */
251
    protected function checkExtensionNode(TypeInterface $type, NodeInterface $node): void
252
    {
253
        if ($node instanceof ObjectTypeExtensionNode && !($type instanceof ObjectType)) {
254
            throw new ExtensionException(
255
                \sprintf('Cannot extend non-object type "%s".', toString($type)),
256
                [$node]
257
            );
258
        }
259
260
        if ($node instanceof InterfaceTypeExtensionNode && !($type instanceof InterfaceType)) {
261
            throw new ExtensionException(
262
                \sprintf('Cannot extend non-interface type "%s".', toString($type)),
263
                [$node]
264
            );
265
        }
266
    }
267
268
    /**
269
     * @param NamedTypeInterface $type
270
     * @return NamedTypeInterface
271
     * @throws InvalidArgumentException
272
     * @throws InvariantException
273
     */
274
    protected function getExtendedType(NamedTypeInterface $type): NamedTypeInterface
275
    {
276
        $typeName = $type->getName();
277
278
        if (!$this->isInCache($typeName)) {
279
            $this->setInCache($typeName, $this->extendType($type));
280
        }
281
282
        return $this->getFromCache($typeName);
283
    }
284
285
    /**
286
     * @param NamedTypeInterface $type
287
     * @return NamedTypeInterface
288
     * @throws InvariantException
289
     */
290
    protected function extendType(NamedTypeInterface $type): NamedTypeInterface
291
    {
292
        /** @noinspection PhpParamsInspection */
293
        if (isIntrospectionType($type)) {
294
            // Introspection types are not extended.
295
            return $type;
296
        }
297
298
        if ($type instanceof ObjectType) {
299
            return $this->extendObjectType($type);
300
        }
301
302
        if ($type instanceof InterfaceType) {
303
            return $this->extendInterfaceType($type);
304
        }
305
306
        if ($type instanceof UnionType) {
307
            return $this->extendUnionType($type);
308
        }
309
310
        // This type is not yet extendable.
311
        return $type;
312
    }
313
314
    /**
315
     * @param ObjectType $type
316
     * @return ObjectType
317
     */
318
    protected function extendObjectType(ObjectType $type): ObjectType
319
    {
320
        $typeName          = $type->getName();
321
        $extensionASTNodes = $type->getExtensionAstNodes();
322
323
        if (isset($this->typeExtensionsMap[$typeName])) {
324
            $extensionASTNodes = !empty($extensionASTNodes)
325
                ? \array_merge($this->typeExtensionsMap[$typeName], $extensionASTNodes)
326
                : $this->typeExtensionsMap[$typeName];
327
        }
328
329
        return GraphQLObjectType([
330
            'name'              => $typeName,
331
            'description'       => $type->getDescription(),
332
            'interfaces'        => function () use ($type) {
333
                return $this->extendImplementedInterfaces($type);
334
            },
335
            'fields'            => function () use ($type) {
336
                return $this->extendFieldMap($type);
337
            },
338
            'astNode'           => $type->getAstNode(),
339
            'extensionASTNodes' => $extensionASTNodes,
340
            'isTypeOf'          => $type->getIsTypeOf(),
341
        ]);
342
    }
343
344
    /**
345
     * @param InterfaceType $type
346
     * @return InterfaceType
347
     */
348
    protected function extendInterfaceType(InterfaceType $type): InterfaceType
349
    {
350
        $typeName          = $type->getName();
351
        $extensionASTNodes = $this->typeExtensionsMap[$typeName] ?? [];
352
353
        if (isset($this->typeExtensionsMap[$typeName])) {
354
            $extensionASTNodes = !empty($extensionASTNodes)
355
                ? \array_merge($this->typeExtensionsMap[$typeName], $extensionASTNodes)
356
                : $this->typeExtensionsMap[$typeName];
357
        }
358
359
        return GraphQLInterfaceType([
360
            'name'              => $typeName,
361
            'description'       => $type->getDescription(),
362
            'fields'            => function () use ($type) {
363
                return $this->extendFieldMap($type);
364
            },
365
            'astNode'           => $type->getAstNode(),
366
            'extensionASTNodes' => $extensionASTNodes,
367
            'resolveType'       => $type->getResolveType(),
368
        ]);
369
    }
370
371
    /**
372
     * @param UnionType $type
373
     * @return UnionType
374
     * @throws InvariantException
375
     */
376
    protected function extendUnionType(UnionType $type): UnionType
377
    {
378
        return GraphQLUnionType([
379
            'name'        => $type->getName(),
380
            'description' => $type->getDescription(),
381
            'types'       => \array_map(function ($unionType) {
382
                return $this->getExtendedType($unionType);
383
            }, $type->getTypes()),
384
            'astNode'     => $type->getAstNode(),
385
            'resolveType' => $type->getResolveType(),
386
        ]);
387
    }
388
389
    /**
390
     * @param ObjectType $type
391
     * @return array
392
     * @throws InvariantException
393
     */
394
    protected function extendImplementedInterfaces(ObjectType $type): array
395
    {
396
        $interfaces = \array_map(function (InterfaceType $interface) {
397
            return $this->getExtendedType($interface);
398
        }, $type->getInterfaces());
399
400
        // If there are any extensions to the interfaces, apply those here.
401
        $extensions = $this->typeExtensionsMap[$type->getName()] ?? null;
402
403
        if (null !== $extensions) {
404
            foreach ($extensions as $extension) {
405
                foreach ($extension->getInterfaces() as $namedType) {
0 ignored issues
show
Bug introduced by
The method getInterfaces() does not exist on Digia\GraphQL\Language\N...eExtensionNodeInterface. It seems like you code against a sub-type of Digia\GraphQL\Language\N...eExtensionNodeInterface such as Digia\GraphQL\Language\N...ObjectTypeExtensionNode. ( Ignorable by Annotation )

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

405
                foreach ($extension->/** @scrutinizer ignore-call */ getInterfaces() as $namedType) {
Loading history...
406
                    // Note: While this could make early assertions to get the correctly
407
                    // typed values, that would throw immediately while type system
408
                    // validation with validateSchema() will produce more actionable results.
409
                    $interfaces[] = $this->definitionBuilder->buildType($namedType);
410
                }
411
            }
412
        }
413
414
        return $interfaces;
415
    }
416
417
    /**
418
     * @param TypeInterface|ObjectType|InterfaceType $type
419
     * @return array
420
     * @throws InvalidTypeException
421
     * @throws InvariantException
422
     * @throws ExtensionException
423
     * @throws InvalidArgumentException
424
     */
425
    protected function extendFieldMap(TypeInterface $type): array
426
    {
427
        $typeName    = $type->getName();
0 ignored issues
show
Bug introduced by
The method getName() does not exist on Digia\GraphQL\Type\Definition\TypeInterface. It seems like you code against a sub-type of said class. However, the method does not exist in Digia\GraphQL\Type\Defin...n\AbstractTypeInterface or Digia\GraphQL\Type\Defin...n\WrappingTypeInterface. Are you sure you never get one of those? ( Ignorable by Annotation )

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

427
        /** @scrutinizer ignore-call */ 
428
        $typeName    = $type->getName();
Loading history...
428
        $newFieldMap = [];
429
        $oldFieldMap = $type->getFields();
0 ignored issues
show
Bug introduced by
The method getFields() does not exist on Digia\GraphQL\Type\Definition\TypeInterface. It seems like you code against a sub-type of Digia\GraphQL\Type\Definition\TypeInterface such as Digia\GraphQL\Type\Definition\InputObjectType or Digia\GraphQL\Type\Definition\ObjectType or Digia\GraphQL\Type\Definition\InterfaceType. ( Ignorable by Annotation )

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

429
        /** @scrutinizer ignore-call */ 
430
        $oldFieldMap = $type->getFields();
Loading history...
430
431
        foreach (\array_keys($oldFieldMap) as $fieldName) {
432
            $field = $oldFieldMap[$fieldName];
433
434
            $newFieldMap[$fieldName] = [
435
                'description'       => $field->getDescription(),
436
                'deprecationReason' => $field->getDeprecationReason(),
437
                'type'              => $this->extendFieldType($field->getType()),
438
                'args'              => keyMap($field->getArguments(), function (Argument $argument) {
439
                    return $argument->getName();
440
                }),
441
                'astNode'           => $field->getAstNode(),
442
                'resolve'           => $field->getResolve(),
443
            ];
444
        }
445
446
        // If there are any extensions to the fields, apply those here.
447
        /** @var ObjectTypeExtensionNode|InterfaceTypeExtensionNode[] $extensions */
448
        $extensions = $this->typeExtensionsMap[$typeName] ?? null;
449
450
        if (null !== $extensions) {
0 ignored issues
show
introduced by
The condition null !== $extensions is always true.
Loading history...
451
            foreach ($extensions as $extension) {
452
                foreach ($extension->getFields() as $field) {
453
                    $fieldName = $field->getNameValue();
454
455
                    if (isset($oldFieldMap[$fieldName])) {
456
                        throw new ExtensionException(
457
                            \sprintf(
458
                                'Field "%s.%s" already exists in the schema. ' .
459
                                'It cannot also be defined in this type extension.',
460
                                $typeName, $fieldName
461
                            ),
462
                            [$field]
463
                        );
464
                    }
465
466
                    $newFieldMap[$fieldName] = $this->definitionBuilder->buildField($field);
467
                }
468
            }
469
        }
470
471
        return $newFieldMap;
472
    }
473
474
    /**
475
     * @param TypeInterface $typeDefinition
476
     * @return TypeInterface
477
     * @throws InvalidArgumentException
478
     * @throws InvalidTypeException
479
     * @throws InvariantException
480
     */
481
    protected function extendFieldType(TypeInterface $typeDefinition): TypeInterface
482
    {
483
        if ($typeDefinition instanceof ListType) {
484
            return GraphQLList($this->extendFieldType($typeDefinition->getOfType()));
485
        }
486
487
        if ($typeDefinition instanceof NonNullType) {
488
            return GraphQLNonNull($this->extendFieldType($typeDefinition->getOfType()));
489
        }
490
491
        /** @noinspection PhpIncompatibleReturnTypeInspection */
492
        /** @noinspection PhpParamsInspection */
493
        return $this->getExtendedType($typeDefinition);
0 ignored issues
show
Bug introduced by
$typeDefinition of type Digia\GraphQL\Type\Definition\TypeInterface is incompatible with the type Digia\GraphQL\Type\Definition\NamedTypeInterface expected by parameter $type of Digia\GraphQL\SchemaExte...nder::getExtendedType(). ( Ignorable by Annotation )

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

493
        return $this->getExtendedType(/** @scrutinizer ignore-type */ $typeDefinition);
Loading history...
Bug Best Practice introduced by
The expression return $this->getExtendedType($typeDefinition) returns the type Digia\GraphQL\Type\Definition\NamedTypeInterface which is incompatible with the type-hinted return Digia\GraphQL\Type\Definition\TypeInterface.
Loading history...
494
    }
495
496
    /**
497
     * @inheritdoc
498
     */
499
    protected function getCachePrefix(): string
500
    {
501
        return self::CACHE_PREFIX;
502
    }
503
}
504