diff --git a/src/AnnotationReader.php b/src/AnnotationReader.php index c075d6b63..43521ffd7 100644 --- a/src/AnnotationReader.php +++ b/src/AnnotationReader.php @@ -24,12 +24,15 @@ use TheCodingMachine\GraphQLite\Annotations\SourceFieldInterface; use TheCodingMachine\GraphQLite\Annotations\Type; use TheCodingMachine\GraphQLite\Annotations\TypeInterface; +use TheCodingMachine\GraphQLite\Directives\InputObjectTypeDirective; +use TheCodingMachine\GraphQLite\Directives\ObjectTypeDirective; use function array_diff_key; use function array_filter; use function array_key_exists; use function array_map; use function array_merge; +use function array_values; use function assert; use function count; use function get_class; @@ -326,6 +329,34 @@ static function (array $parameterAnnotations): ParameterAnnotations { ); } + /** + * The {@see ObjectTypeDirective}s on a class. + * + * @param ReflectionClass $refClass + * + * @return list + * + * @template T of object + */ + public function getObjectTypeDirectives(ReflectionClass $refClass): array + { + return array_values($this->getClassAnnotations($refClass, ObjectTypeDirective::class, false)); + } + + /** + * The {@see InputObjectTypeDirective}s on a class. + * + * @param ReflectionClass $refClass + * + * @return list + * + * @template T of object + */ + public function getInputObjectTypeDirectives(ReflectionClass $refClass): array + { + return array_values($this->getClassAnnotations($refClass, InputObjectTypeDirective::class, false)); + } + public function getMiddlewareAnnotations(ReflectionMethod|ReflectionProperty $reflection): MiddlewareAnnotations { if ($reflection instanceof ReflectionMethod) { diff --git a/src/Directives/BehavioralFieldDirective.php b/src/Directives/BehavioralFieldDirective.php new file mode 100644 index 000000000..154b1c547 --- /dev/null +++ b/src/Directives/BehavioralFieldDirective.php @@ -0,0 +1,18 @@ +reason ?? $descriptor->getDeprecationReason() ?? WebonyxDirective::DEFAULT_DEPRECATION_REASON; + + return $next->handle($descriptor->withDeprecationReason($reason)); + } +} diff --git a/src/Directives/BuiltIn/OneOf.php b/src/Directives/BuiltIn/OneOf.php new file mode 100644 index 000000000..0407bb31c --- /dev/null +++ b/src/Directives/BuiltIn/OneOf.php @@ -0,0 +1,42 @@ +handle($descriptor); + $type->isOneOf = true; + return $type; + } +} diff --git a/src/Directives/DirectiveAstBuilder.php b/src/Directives/DirectiveAstBuilder.php new file mode 100644 index 000000000..84d828ffb --- /dev/null +++ b/src/Directives/DirectiveAstBuilder.php @@ -0,0 +1,118 @@ +directives` on the definition node (FieldDefinitionNode, + * InputValueDefinitionNode, etc.). GraphQLite doesn't otherwise set `astNode`, so this builds just + * enough of the node to carry the directives, with arguments encoded via {@see AST::astFromValue}. + * + * @internal + */ +final class DirectiveAstBuilder +{ + public function __construct(private readonly DirectiveRegistry $registry) + { + } + + /** + * @param list $directives Directive instances applied to this element. + * + * @return list + */ + public function buildDirectiveNodes(array $directives): array + { + $nodes = []; + foreach ($directives as $directive) { + $definition = $this->registry->definitionFor($directive::class); + if ($definition === null) { + continue; + } + $nodes[] = $this->buildDirectiveNode($directive, $definition); + } + return $nodes; + } + + private function buildDirectiveNode(DirectiveInterface $directive, DirectiveDefinition $definition): DirectiveNode + { + $arguments = $this->registry->argumentsFor($directive::class); + $reflection = new ReflectionClass($directive); + + $argumentNodes = []; + foreach ($arguments as $argument) { + if (! $reflection->hasProperty($argument->name)) { + continue; + } + $property = $reflection->getProperty($argument->name); + $value = $property->getValue($directive); + + $argumentNodes[] = new ArgumentNode([ + 'name' => new NameNode(['value' => $argument->name]), + 'value' => AST::astFromValue($value, $argument->type), + ]); + } + + return new DirectiveNode([ + 'name' => new NameNode(['value' => $definition->name]), + 'arguments' => new NodeList($argumentNodes), + ]); + } + + /** @param list $directiveNodes */ + public static function buildFieldDefinitionNode(string $name, array $directiveNodes): FieldDefinitionNode + { + return new FieldDefinitionNode([ + 'name' => new NameNode(['value' => $name]), + 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'Unknown'])]), + 'arguments' => new NodeList([]), + 'directives' => new NodeList($directiveNodes), + ]); + } + + /** @param list $directiveNodes */ + public static function buildInputValueDefinitionNode(string $name, array $directiveNodes): InputValueDefinitionNode + { + return new InputValueDefinitionNode([ + 'name' => new NameNode(['value' => $name]), + 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'Unknown'])]), + 'directives' => new NodeList($directiveNodes), + ]); + } + + /** @param list $directiveNodes */ + public static function buildObjectTypeDefinitionNode(string $name, array $directiveNodes): ObjectTypeDefinitionNode + { + return new ObjectTypeDefinitionNode([ + 'name' => new NameNode(['value' => $name]), + 'interfaces' => new NodeList([]), + 'directives' => new NodeList($directiveNodes), + 'fields' => new NodeList([]), + ]); + } + + /** @param list $directiveNodes */ + public static function buildInputObjectTypeDefinitionNode(string $name, array $directiveNodes): InputObjectTypeDefinitionNode + { + return new InputObjectTypeDefinitionNode([ + 'name' => new NameNode(['value' => $name]), + 'directives' => new NodeList($directiveNodes), + 'fields' => new NodeList([]), + ]); + } +} diff --git a/src/Directives/DirectiveDefinition.php b/src/Directives/DirectiveDefinition.php new file mode 100644 index 000000000..51ee64e17 --- /dev/null +++ b/src/Directives/DirectiveDefinition.php @@ -0,0 +1,29 @@ + $locations */ + public function __construct( + public readonly string $name, + public readonly array $locations, + public readonly string|null $description = null, + public readonly bool $builtIn = false, + ) { + } +} diff --git a/src/Directives/DirectiveInterface.php b/src/Directives/DirectiveInterface.php new file mode 100644 index 000000000..39a2e87a6 --- /dev/null +++ b/src/Directives/DirectiveInterface.php @@ -0,0 +1,14 @@ + true, + default => false, + }; + } + + public function isTypeSystem(): bool + { + return ! $this->isExecutable(); + } +} diff --git a/src/Directives/DirectiveReflection.php b/src/Directives/DirectiveReflection.php new file mode 100644 index 000000000..894d2fd2a --- /dev/null +++ b/src/Directives/DirectiveReflection.php @@ -0,0 +1,31 @@ + $reflection */ + public static function attributeFlags(ReflectionClass $reflection): int + { + $attributes = $reflection->getAttributes(Attribute::class); + if ($attributes === []) { + return Attribute::TARGET_ALL; + } + + $args = $attributes[0]->getArguments(); + + return $args[0] ?? $args['flags'] ?? Attribute::TARGET_ALL; + } +} diff --git a/src/Directives/DirectiveRegistry.php b/src/Directives/DirectiveRegistry.php new file mode 100644 index 000000000..c4718a47e --- /dev/null +++ b/src/Directives/DirectiveRegistry.php @@ -0,0 +1,133 @@ +, ResolvedDirective> */ + private array $resolvedByClass = []; + + /** Attribute classes that bind PHP behavior to webonyx's built-in directives. */ + private const BUILT_IN_ATTRIBUTES = [ + OneOf::class, + Deprecated::class, + ]; + + public function __construct( + private readonly AnnotationReader $annotationReader, + ) { + } + + /** Resolve the built-in directives once. Idempotent. */ + public function discover(): void + { + foreach (self::BUILT_IN_ATTRIBUTES as $directiveClass) { + /** @var class-string $directiveClass */ + if (isset($this->resolvedByClass[$directiveClass])) { + continue; + } + + $this->resolvedByClass[$directiveClass] = DirectiveResolver::resolve($directiveClass, $directiveClass::definition()); + } + } + + public function hasAny(): bool + { + foreach ($this->resolvedByClass as $resolved) { + if ($resolved->webonyxDirective !== null) { + return true; + } + } + + return false; + } + + /** @return list */ + public function webonyxDirectives(): array + { + $directives = []; + foreach ($this->resolvedByClass as $resolved) { + if ($resolved->webonyxDirective === null) { + continue; + } + $directives[] = $resolved->webonyxDirective; + } + + return $directives; + } + + /** + * The argument shape for a directive class. + * + * @param class-string $directiveClass + * + * @return list + */ + public function argumentsFor(string $directiveClass): array + { + $resolved = $this->resolvedByClass[$directiveClass] ?? null; + + return $resolved === null ? [] : $resolved->arguments; + } + + /** + * The metadata for a directive class, or null if it isn't registered. + * + * @param class-string $directiveClass + */ + public function definitionFor(string $directiveClass): DirectiveDefinition|null + { + return ($this->resolvedByClass[$directiveClass] ?? null)?->definition; + } + + /** + * The object-type directives applied to a class, after checking each is allowed at the OBJECT + * location — e.g. rejecting `#[OneOf]` (an INPUT_OBJECT directive) on a `#[Type]`. + * + * @param ReflectionClass $refClass + * + * @return list + * + * @throws InvalidDirectiveException + */ + public function objectTypeDirectives(ReflectionClass $refClass): array + { + DirectiveValidator::assertDirectivesUsableAt($refClass, DirectiveLocation::OBJECT); + + return $this->annotationReader->getObjectTypeDirectives($refClass); + } + + /** + * The input-object directives applied to a class, after checking each is allowed at the + * INPUT_OBJECT location. + * + * @param ReflectionClass $refClass + * + * @return list + * + * @throws InvalidDirectiveException + */ + public function inputObjectTypeDirectives(ReflectionClass $refClass): array + { + DirectiveValidator::assertDirectivesUsableAt($refClass, DirectiveLocation::INPUT_OBJECT); + + return $this->annotationReader->getInputObjectTypeDirectives($refClass); + } +} diff --git a/src/Directives/DirectiveResolver.php b/src/Directives/DirectiveResolver.php new file mode 100644 index 000000000..1e42c9f85 --- /dev/null +++ b/src/Directives/DirectiveResolver.php @@ -0,0 +1,145 @@ + $directiveClass + * + * @throws InvalidDirectiveException when a constructor argument can't map to a GraphQL input type. + */ + public static function resolve(string $directiveClass, DirectiveDefinition $definition): ResolvedDirective + { + $reflection = new ReflectionClass($directiveClass); + $arguments = self::resolveArguments($directiveClass, $reflection); + + // Built-in directives are declared by webonyx, so there's no directive to register for them. + $webonyxDirective = $definition->builtIn + ? null + : self::buildWebonyxDirective($definition, $arguments, self::isRepeatable($reflection)); + + return new ResolvedDirective($definition, $arguments, $webonyxDirective); + } + + /** + * @param class-string $directiveClass + * @param ReflectionClass $reflection + * + * @return list + */ + private static function resolveArguments(string $directiveClass, ReflectionClass $reflection): array + { + $constructor = $reflection->getConstructor(); + if ($constructor === null) { + return []; + } + + $arguments = []; + foreach ($constructor->getParameters() as $parameter) { + $arguments[] = self::resolveArgument($directiveClass, $parameter); + } + + return $arguments; + } + + private static function resolveArgument(string $directiveClass, ReflectionParameter $parameter): ResolvedDirectiveArgument + { + $type = $parameter->getType(); + if (! $type instanceof ReflectionNamedType) { + throw InvalidDirectiveException::unsupportedArgumentType( + $directiveClass, + $parameter->getName(), + 'union/intersection types are not supported', + ); + } + + if ($type->isBuiltin() === false) { + throw InvalidDirectiveException::unsupportedArgumentType( + $directiveClass, + $parameter->getName(), + 'only scalar types (string, int, float, bool) are supported in this release', + ); + } + + $graphQlType = match ($type->getName()) { + 'string' => Type::string(), + 'int' => Type::int(), + 'float' => Type::float(), + 'bool' => Type::boolean(), + default => throw InvalidDirectiveException::unsupportedArgumentType( + $directiveClass, + $parameter->getName(), + 'PHP type "' . $type->getName() . '" cannot be mapped to a GraphQL scalar', + ), + }; + + $nullable = $type->allowsNull(); + $finalType = $nullable ? $graphQlType : new NonNull($graphQlType); + + $hasDefault = $parameter->isDefaultValueAvailable(); + $default = $hasDefault ? $parameter->getDefaultValue() : null; + + return new ResolvedDirectiveArgument( + name: $parameter->getName(), + type: $finalType, + hasDefaultValue: $hasDefault, + defaultValue: $default, + ); + } + + /** @param ReflectionClass $reflection */ + private static function isRepeatable(ReflectionClass $reflection): bool + { + return (DirectiveReflection::attributeFlags($reflection) & Attribute::IS_REPEATABLE) === Attribute::IS_REPEATABLE; + } + + /** @param list $arguments */ + private static function buildWebonyxDirective(DirectiveDefinition $definition, array $arguments, bool $repeatable): WebonyxDirective + { + $argsConfig = []; + foreach ($arguments as $argument) { + $config = ['type' => $argument->type]; + if ($argument->hasDefaultValue) { + $config['defaultValue'] = $argument->defaultValue; + } + if ($argument->description !== null) { + $config['description'] = $argument->description; + } + $argsConfig[$argument->name] = $config; + } + + $config = [ + 'name' => $definition->name, + 'locations' => array_map(static fn (DirectiveLocation $loc) => $loc->value, $definition->locations), + 'isRepeatable' => $repeatable, + 'args' => $argsConfig, + ]; + if ($definition->description !== null) { + $config['description'] = $definition->description; + } + + return new WebonyxDirective($config); + } +} diff --git a/src/Directives/DirectiveValidator.php b/src/Directives/DirectiveValidator.php new file mode 100644 index 000000000..bf3d700ef --- /dev/null +++ b/src/Directives/DirectiveValidator.php @@ -0,0 +1,44 @@ + $refClass + * + * @throws InvalidDirectiveException when a directive on $refClass isn't allowed at $location. + */ + public static function assertDirectivesUsableAt(ReflectionClass $refClass, DirectiveLocation $location): void + { + foreach ($refClass->getAttributes() as $attribute) { + $directiveClass = $attribute->getName(); + if (! is_a($directiveClass, TypeSystemDirective::class, true)) { + continue; + } + + $locations = $directiveClass::definition()->locations; + if (in_array($location, $locations, true)) { + continue; + } + + throw InvalidDirectiveException::notUsableAtLocation($directiveClass, $location, $locations); + } + } +} diff --git a/src/Directives/Exceptions/InvalidDirectiveException.php b/src/Directives/Exceptions/InvalidDirectiveException.php new file mode 100644 index 000000000..e032900b3 --- /dev/null +++ b/src/Directives/Exceptions/InvalidDirectiveException.php @@ -0,0 +1,119 @@ + $declaredLocations + */ + public static function notUsableAtLocation(string $directiveClass, DirectiveLocation $attempted, array $declaredLocations): self + { + $declared = implode(', ', array_map(static fn (DirectiveLocation $location) => $location->value, $declaredLocations)); + + return new self(sprintf( + 'Directive "%s" cannot be used on %s; it is declared for %s.', + $directiveClass, + $attempted->value, + $declared === '' ? 'no locations' : $declared, + )); + } + + public static function phpTargetMissingForLocation(string $directiveClass, DirectiveLocation $location, string $requiredTarget): self + { + return new self(sprintf( + 'Directive "%s" declares location %s but its PHP #[Attribute(...)] target does not include %s. ' . + 'Add the missing target to make the directive usable.', + $directiveClass, + $location->value, + $requiredTarget, + )); + } + + /** @param list $locations */ + public static function interfaceWithoutMatchingLocation(string $directiveClass, string $interface, array $locations): self + { + $locationsList = implode(', ', array_map(static fn (DirectiveLocation $l) => $l->value, $locations)); + + return new self(sprintf( + 'Directive "%s" implements %s but its declared locations [%s] do not include the corresponding GraphQL location. ' . + 'Either remove the interface or add the location to DirectiveDefinition::$locations.', + $directiveClass, + $interface, + $locationsList, + )); + } + + public static function locationWithoutMatchingInterface(string $directiveClass, DirectiveLocation $location, string $expectedInterface): self + { + return new self(sprintf( + 'Directive "%s" declares location %s but does not implement %s. ' . + 'Either remove the location or implement the corresponding interface.', + $directiveClass, + $location->value, + $expectedInterface, + )); + } + + public static function notAttribute(string $directiveClass): self + { + return new self(sprintf( + 'Directive "%s" must declare a #[Attribute(...)] declaration so it can be applied to PHP code.', + $directiveClass, + )); + } + + public static function unsupportedArgumentType(string $directiveClass, string $parameterName, string $reason): self + { + return new self(sprintf( + 'Directive "%s" constructor parameter "$%s" cannot be mapped to a GraphQL input type: %s. ' . + 'Supported types are scalars (string, int, float, bool), backed enums, and arrays/lists of those.', + $directiveClass, + $parameterName, + $reason, + )); + } + + public static function duplicateName(string $name, string $existingClass, string $newClass): self + { + return new self(sprintf( + 'Two directives declare the GraphQL name "@%s": %s and %s. Directive names must be unique.', + $name, + $existingClass, + $newClass, + )); + } + + public static function reservedName(string $name, string $directiveClass): self + { + return new self(sprintf( + 'Directive "%s" declares the name "@%s" which is reserved for a webonyx built-in directive (@skip, @include, @deprecated). Pick a different name.', + $directiveClass, + $name, + )); + } + + public static function noDefinitionMethod(string $directiveClass): self + { + return new self(sprintf( + 'Directive "%s" implements DirectiveInterface but does not declare a static `definition(): DirectiveDefinition` method.', + $directiveClass, + )); + } +} diff --git a/src/Directives/FieldDirective.php b/src/Directives/FieldDirective.php new file mode 100644 index 000000000..14126d5f9 --- /dev/null +++ b/src/Directives/FieldDirective.php @@ -0,0 +1,20 @@ + $arguments */ + public function __construct( + public readonly DirectiveDefinition $definition, + public readonly array $arguments, + public readonly WebonyxDirective|null $webonyxDirective, + ) { + } +} diff --git a/src/Directives/ResolvedDirectiveArgument.php b/src/Directives/ResolvedDirectiveArgument.php new file mode 100644 index 000000000..9113e72fa --- /dev/null +++ b/src/Directives/ResolvedDirectiveArgument.php @@ -0,0 +1,29 @@ + $reflectionClass + * @param list $directives + */ + public function __construct( + private readonly ReflectionClass $reflectionClass, + private readonly MutableInputObjectType $type, + private readonly array $directives = [], + ) { + } + + /** @return ReflectionClass */ + public function getReflectionClass(): ReflectionClass + { + return $this->reflectionClass; + } + + public function getType(): MutableInputObjectType + { + return $this->type; + } + + /** @return list */ + public function getDirectives(): array + { + return $this->directives; + } + + /** @param list $directives */ + public function withDirectives(array $directives): self + { + return $this->with(directives: $directives); + } +} diff --git a/src/InputTypeGenerator.php b/src/InputTypeGenerator.php index d2bc24797..672423fc0 100644 --- a/src/InputTypeGenerator.php +++ b/src/InputTypeGenerator.php @@ -6,11 +6,16 @@ use GraphQL\Type\Definition\InputObjectType; use Psr\Container\ContainerInterface; +use ReflectionClass; use ReflectionFunctionAbstract; use ReflectionMethod; +use TheCodingMachine\GraphQLite\Directives\DirectiveRegistry; +use TheCodingMachine\GraphQLite\Middlewares\InputObjectTypeHandlerInterface; +use TheCodingMachine\GraphQLite\Middlewares\InputObjectTypeMiddlewareInterface; use TheCodingMachine\GraphQLite\Reflection\DocBlock\DocBlockFactory; use TheCodingMachine\GraphQLite\Types\InputType; use TheCodingMachine\GraphQLite\Types\InputTypeValidatorInterface; +use TheCodingMachine\GraphQLite\Types\MutableInputObjectType; use TheCodingMachine\GraphQLite\Types\ResolvableMutableInputInterface; use TheCodingMachine\GraphQLite\Types\ResolvableMutableInputObjectType; use TheCodingMachine\GraphQLite\Utils\DescriptionResolver; @@ -36,6 +41,8 @@ public function __construct( private AnnotationReader|null $annotationReader = null, private DocBlockFactory|null $docBlockFactory = null, private DescriptionResolver $descriptionResolver = new DescriptionResolver(true), + private InputObjectTypeMiddlewareInterface|null $inputObjectTypeMiddleware = null, + private DirectiveRegistry|null $directiveRegistry = null, ) { } @@ -52,7 +59,7 @@ public function mapFactoryMethod(string $factory, string $methodName, ContainerI [$inputName, $className] = $this->inputTypeUtils->getInputTypeNameAndClassName($method); if (! isset($this->factoryCache[$inputName])) { - $this->factoryCache[$inputName] = new ResolvableMutableInputObjectType( + $type = new ResolvableMutableInputObjectType( $inputName, $this->fieldsBuilder, $object, @@ -60,6 +67,12 @@ public function mapFactoryMethod(string $factory, string $methodName, ContainerI $this->resolveFactoryDescription($method), $this->canBeInstantiatedWithoutParameter($method, false), ); + + /** @var class-string $factoryClass */ + $factoryClass = $factory; + $decorated = $this->runInputObjectMiddleware(new ReflectionClass($factoryClass), $type); + assert($decorated instanceof ResolvableMutableInputObjectType); + $this->factoryCache[$inputName] = $decorated; } return $this->factoryCache[$inputName]; @@ -96,7 +109,7 @@ private function resolveFactoryDescription(ReflectionMethod $method): string|nul public function mapInput(string $className, string $inputName, string|null $description, bool $isUpdate): InputType { if (! isset($this->inputCache[$inputName])) { - $this->inputCache[$inputName] = new InputType( + $type = new InputType( $className, $inputName, $description, @@ -104,11 +117,40 @@ public function mapInput(string $className, string $inputName, string|null $desc $this->fieldsBuilder, $this->inputTypeValidator, ); + + $decorated = $this->runInputObjectMiddleware(new ReflectionClass($className), $type); + assert($decorated instanceof InputType); + $this->inputCache[$inputName] = $decorated; } return $this->inputCache[$inputName]; } + /** @param ReflectionClass $refClass */ + private function runInputObjectMiddleware(ReflectionClass $refClass, MutableInputObjectType $type): MutableInputObjectType + { + if ($this->inputObjectTypeMiddleware === null || $this->directiveRegistry === null) { + return $type; + } + + $directives = $this->directiveRegistry->inputObjectTypeDirectives($refClass); + if ($directives === []) { + return $type; + } + + $descriptor = new InputObjectTypeDescriptor($refClass, $type, $directives); + + return $this->inputObjectTypeMiddleware->process( + $descriptor, + new class implements InputObjectTypeHandlerInterface { + public function handle(InputObjectTypeDescriptor $descriptor): MutableInputObjectType + { + return $descriptor->getType(); + } + }, + ); + } + public static function canBeInstantiatedWithoutParameter(ReflectionFunctionAbstract $refMethod, bool $skipFirstArgument): bool { $nbParams = $refMethod->getNumberOfRequiredParameters(); diff --git a/src/Middlewares/DirectiveFieldMiddleware.php b/src/Middlewares/DirectiveFieldMiddleware.php new file mode 100644 index 000000000..cb6f86f60 --- /dev/null +++ b/src/Middlewares/DirectiveFieldMiddleware.php @@ -0,0 +1,74 @@ + $directives */ + $directives = $queryFieldDescriptor + ->getMiddlewareAnnotations() + ->getAnnotationsByType(FieldDirective::class); + + if ($directives === []) { + return $fieldHandler->handle($queryFieldDescriptor); + } + + $behavioralDirectives = array_values(array_filter( + $directives, + static fn (FieldDirective $directive): bool => $directive instanceof BehavioralFieldDirective, + )); + + $handler = $fieldHandler; + foreach (array_reverse($behavioralDirectives) as $directive) { + $handler = new class ($directive, $handler) implements FieldHandlerInterface { + public function __construct( + private readonly BehavioralFieldDirective $directive, + private readonly FieldHandlerInterface $next, + ) { + } + + public function handle(QueryFieldDescriptor $fieldDescriptor): FieldDefinition|null + { + return $this->directive->applyToField($fieldDescriptor, $this->next); + } + }; + } + + $result = $handler->handle($queryFieldDescriptor); + if ($result === null) { + return null; + } + + $result->astNode = DirectiveAstBuilder::buildFieldDefinitionNode( + $result->name, + $this->astBuilder->buildDirectiveNodes($directives), + ); + + return $result; + } +} diff --git a/src/Middlewares/DirectiveInputFieldMiddleware.php b/src/Middlewares/DirectiveInputFieldMiddleware.php new file mode 100644 index 000000000..4ff9217ea --- /dev/null +++ b/src/Middlewares/DirectiveInputFieldMiddleware.php @@ -0,0 +1,79 @@ + $directives */ + $directives = $inputFieldDescriptor + ->getMiddlewareAnnotations() + ->getAnnotationsByType(InputFieldDirective::class); + + if ($directives === []) { + return $inputFieldHandler->handle($inputFieldDescriptor); + } + + $behavioralDirectives = array_values(array_filter( + $directives, + static fn (InputFieldDirective $directive): bool => $directive instanceof BehavioralInputFieldDirective, + )); + + $handler = $inputFieldHandler; + foreach (array_reverse($behavioralDirectives) as $directive) { + $handler = new class ($directive, $handler) implements InputFieldHandlerInterface { + public function __construct( + private readonly BehavioralInputFieldDirective $directive, + private readonly InputFieldHandlerInterface $next, + ) { + } + + public function handle(InputFieldDescriptor $inputFieldDescriptor): InputField|null + { + return $this->directive->applyToInputField($inputFieldDescriptor, $this->next); + } + }; + } + + $result = $handler->handle($inputFieldDescriptor); + if ($result === null) { + return null; + } + + $astNode = DirectiveAstBuilder::buildInputValueDefinitionNode( + $result->name, + $this->astBuilder->buildDirectiveNodes($directives), + ); + + // InputObjectType reconstructs each field from $field->config (see InputType.php:51), + // so mutating only the astNode property would not survive. Update the config too. + $result->astNode = $astNode; + $result->config['astNode'] = $astNode; + + return $result; + } +} diff --git a/src/Middlewares/DirectiveInputObjectTypeMiddleware.php b/src/Middlewares/DirectiveInputObjectTypeMiddleware.php new file mode 100644 index 000000000..c72afb90c --- /dev/null +++ b/src/Middlewares/DirectiveInputObjectTypeMiddleware.php @@ -0,0 +1,67 @@ +getDirectives(); + + if ($directives === []) { + return $next->handle($descriptor); + } + + $behavioralDirectives = array_values(array_filter( + $directives, + static fn ($directive): bool => $directive instanceof BehavioralInputObjectTypeDirective, + )); + + $handler = $next; + foreach (array_reverse($behavioralDirectives) as $directive) { + $handler = new class ($directive, $handler) implements InputObjectTypeHandlerInterface { + public function __construct( + private readonly BehavioralInputObjectTypeDirective $directive, + private readonly InputObjectTypeHandlerInterface $next, + ) { + } + + public function handle(InputObjectTypeDescriptor $descriptor): MutableInputObjectType + { + return $this->directive->applyToInputObjectType($descriptor, $this->next); + } + }; + } + + $type = $handler->handle($descriptor); + + $type->astNode = DirectiveAstBuilder::buildInputObjectTypeDefinitionNode( + $type->name, + $this->astBuilder->buildDirectiveNodes($directives), + ); + + return $type; + } +} diff --git a/src/Middlewares/DirectiveObjectTypeMiddleware.php b/src/Middlewares/DirectiveObjectTypeMiddleware.php new file mode 100644 index 000000000..06bcd8c11 --- /dev/null +++ b/src/Middlewares/DirectiveObjectTypeMiddleware.php @@ -0,0 +1,67 @@ +getDirectives(); + + if ($directives === []) { + return $next->handle($descriptor); + } + + $behavioralDirectives = array_values(array_filter( + $directives, + static fn ($directive): bool => $directive instanceof BehavioralObjectTypeDirective, + )); + + $handler = $next; + foreach (array_reverse($behavioralDirectives) as $directive) { + $handler = new class ($directive, $handler) implements ObjectTypeHandlerInterface { + public function __construct( + private readonly BehavioralObjectTypeDirective $directive, + private readonly ObjectTypeHandlerInterface $next, + ) { + } + + public function handle(ObjectTypeDescriptor $descriptor): MutableObjectType + { + return $this->directive->applyToObjectType($descriptor, $this->next); + } + }; + } + + $type = $handler->handle($descriptor); + + $type->astNode = DirectiveAstBuilder::buildObjectTypeDefinitionNode( + $type->name, + $this->astBuilder->buildDirectiveNodes($directives), + ); + + return $type; + } +} diff --git a/src/Middlewares/InputObjectTypeHandlerInterface.php b/src/Middlewares/InputObjectTypeHandlerInterface.php new file mode 100644 index 000000000..fa8181400 --- /dev/null +++ b/src/Middlewares/InputObjectTypeHandlerInterface.php @@ -0,0 +1,14 @@ +pipeline = new SplQueue(); + } + + public function process(InputObjectTypeDescriptor $descriptor, InputObjectTypeHandlerInterface $next): MutableInputObjectType + { + return (new InputObjectTypeNext($this->pipeline, $next))->handle($descriptor); + } + + public function pipe(InputObjectTypeMiddlewareInterface $middleware): void + { + $this->pipeline->enqueue($middleware); + } +} diff --git a/src/Middlewares/InputObjectTypeNext.php b/src/Middlewares/InputObjectTypeNext.php new file mode 100644 index 000000000..92dab70d9 --- /dev/null +++ b/src/Middlewares/InputObjectTypeNext.php @@ -0,0 +1,35 @@ +queue = clone $queue; + } + + public function handle(InputObjectTypeDescriptor $descriptor): MutableInputObjectType + { + if ($this->queue->isEmpty()) { + return $this->fallbackHandler->handle($descriptor); + } + + $middleware = $this->queue->dequeue(); + assert($middleware instanceof InputObjectTypeMiddlewareInterface); + return $middleware->process($descriptor, $this); + } +} diff --git a/src/Middlewares/ObjectTypeHandlerInterface.php b/src/Middlewares/ObjectTypeHandlerInterface.php new file mode 100644 index 000000000..97dd2a21d --- /dev/null +++ b/src/Middlewares/ObjectTypeHandlerInterface.php @@ -0,0 +1,14 @@ +pipeline = new SplQueue(); + } + + public function process(ObjectTypeDescriptor $descriptor, ObjectTypeHandlerInterface $next): MutableObjectType + { + return (new ObjectTypeNext($this->pipeline, $next))->handle($descriptor); + } + + public function pipe(ObjectTypeMiddlewareInterface $middleware): void + { + $this->pipeline->enqueue($middleware); + } +} diff --git a/src/Middlewares/ObjectTypeNext.php b/src/Middlewares/ObjectTypeNext.php new file mode 100644 index 000000000..923f28bc8 --- /dev/null +++ b/src/Middlewares/ObjectTypeNext.php @@ -0,0 +1,35 @@ +queue = clone $queue; + } + + public function handle(ObjectTypeDescriptor $descriptor): MutableObjectType + { + if ($this->queue->isEmpty()) { + return $this->fallbackHandler->handle($descriptor); + } + + $middleware = $this->queue->dequeue(); + assert($middleware instanceof ObjectTypeMiddlewareInterface); + return $middleware->process($descriptor, $this); + } +} diff --git a/src/ObjectTypeDescriptor.php b/src/ObjectTypeDescriptor.php new file mode 100644 index 000000000..79bdf38ae --- /dev/null +++ b/src/ObjectTypeDescriptor.php @@ -0,0 +1,53 @@ + $reflectionClass + * @param list $directives + */ + public function __construct( + private readonly ReflectionClass $reflectionClass, + private readonly MutableObjectType $type, + private readonly array $directives = [], + ) { + } + + /** @return ReflectionClass */ + public function getReflectionClass(): ReflectionClass + { + return $this->reflectionClass; + } + + public function getType(): MutableObjectType + { + return $this->type; + } + + /** @return list */ + public function getDirectives(): array + { + return $this->directives; + } + + /** @param list $directives */ + public function withDirectives(array $directives): self + { + return $this->with(directives: $directives); + } +} diff --git a/src/Schema.php b/src/Schema.php index f90c7fbbf..852b696cf 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -4,6 +4,7 @@ namespace TheCodingMachine\GraphQLite; +use GraphQL\Type\Definition\Directive; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type; use GraphQL\Type\SchemaConfig; @@ -18,13 +19,19 @@ */ class Schema extends \GraphQL\Type\Schema { + /** + * @param array $directives Custom directive definitions to add alongside webonyx's + * standard ones. + */ public function __construct( - QueryProviderInterface $queryProvider, + QueryProviderInterface $queryProvider, RecursiveTypeMapperInterface $recursiveTypeMapper, - TypeResolver $typeResolver, - RootTypeMapperInterface $rootTypeMapper, - SchemaConfig|null $config = null, - ) { + TypeResolver $typeResolver, + RootTypeMapperInterface $rootTypeMapper, + SchemaConfig|null $config = null, + array $directives = [], + ) + { if ($config === null) { $config = SchemaConfig::create(); } @@ -113,6 +120,16 @@ public function __construct( return $rootTypeMapper->mapNameToType($name); }); + // Register custom directives so they show up in introspection and SDL. webonyx drops its + // standard directives once this list is set, so include them too: whatever the config + // already had, or webonyx's defaults. + if ($directives !== []) { + $config->setDirectives([ + ...($config->getDirectives() ?? Directive::builtInDirectives()), + ...$directives + ]); + } + $typeResolver->registerSchema($this); parent::__construct($config); diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index 20b5e27c1..afd261b02 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -18,6 +18,8 @@ use TheCodingMachine\GraphQLite\Cache\ClassBoundCache; use TheCodingMachine\GraphQLite\Cache\FilesSnapshot; use TheCodingMachine\GraphQLite\Cache\SnapshotClassBoundCache; +use TheCodingMachine\GraphQLite\Directives\DirectiveAstBuilder; +use TheCodingMachine\GraphQLite\Directives\DirectiveRegistry; use TheCodingMachine\GraphQLite\Discovery\Cache\HardClassFinderComputedCache; use TheCodingMachine\GraphQLite\Discovery\Cache\SnapshotClassFinderComputedCache; use TheCodingMachine\GraphQLite\Discovery\ClassFinder; @@ -50,10 +52,16 @@ use TheCodingMachine\GraphQLite\Middlewares\AuthorizationFieldMiddleware; use TheCodingMachine\GraphQLite\Middlewares\AuthorizationInputFieldMiddleware; use TheCodingMachine\GraphQLite\Middlewares\CostFieldMiddleware; +use TheCodingMachine\GraphQLite\Middlewares\DirectiveFieldMiddleware; +use TheCodingMachine\GraphQLite\Middlewares\DirectiveInputFieldMiddleware; +use TheCodingMachine\GraphQLite\Middlewares\DirectiveInputObjectTypeMiddleware; +use TheCodingMachine\GraphQLite\Middlewares\DirectiveObjectTypeMiddleware; use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewareInterface; use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewarePipe; use TheCodingMachine\GraphQLite\Middlewares\InputFieldMiddlewareInterface; use TheCodingMachine\GraphQLite\Middlewares\InputFieldMiddlewarePipe; +use TheCodingMachine\GraphQLite\Middlewares\InputObjectTypeMiddlewarePipe; +use TheCodingMachine\GraphQLite\Middlewares\ObjectTypeMiddlewarePipe; use TheCodingMachine\GraphQLite\Middlewares\SecurityFieldMiddleware; use TheCodingMachine\GraphQLite\Middlewares\SecurityInputFieldMiddleware; use TheCodingMachine\GraphQLite\Reflection\DocBlock\CachedDocBlockFactory; @@ -400,6 +408,11 @@ public function createSchema(): Schema $expressionLanguage = $this->expressionLanguage ?: new ExpressionLanguage($symfonyCache); $expressionLanguage->registerProvider(new SecurityExpressionLanguageProvider()); + $directiveRegistry = new DirectiveRegistry($annotationReader); + $directiveRegistry->discover(); + + $directiveAstBuilder = new DirectiveAstBuilder($directiveRegistry); + $fieldMiddlewarePipe = new FieldMiddlewarePipe(); foreach ($this->fieldMiddlewares as $fieldMiddleware) { $fieldMiddlewarePipe->pipe($fieldMiddleware); @@ -408,6 +421,7 @@ public function createSchema(): Schema $fieldMiddlewarePipe->pipe(new SecurityFieldMiddleware($expressionLanguage, $authenticationService, $authorizationService)); $fieldMiddlewarePipe->pipe(new AuthorizationFieldMiddleware($authenticationService, $authorizationService)); $fieldMiddlewarePipe->pipe(new CostFieldMiddleware()); + $fieldMiddlewarePipe->pipe(new DirectiveFieldMiddleware($directiveAstBuilder)); $inputFieldMiddlewarePipe = new InputFieldMiddlewarePipe(); foreach ($this->inputFieldMiddlewares as $inputFieldMiddleware) { @@ -416,6 +430,13 @@ public function createSchema(): Schema // TODO: add a logger to the SchemaFactory and make use of it everywhere (and most particularly in SecurityInputFieldMiddleware) $inputFieldMiddlewarePipe->pipe(new SecurityInputFieldMiddleware($expressionLanguage, $authenticationService, $authorizationService)); $inputFieldMiddlewarePipe->pipe(new AuthorizationInputFieldMiddleware($authenticationService, $authorizationService)); + $inputFieldMiddlewarePipe->pipe(new DirectiveInputFieldMiddleware($directiveAstBuilder)); + + $objectTypeMiddlewarePipe = new ObjectTypeMiddlewarePipe(); + $objectTypeMiddlewarePipe->pipe(new DirectiveObjectTypeMiddleware($directiveAstBuilder)); + + $inputObjectTypeMiddlewarePipe = new InputObjectTypeMiddlewarePipe(); + $inputObjectTypeMiddlewarePipe->pipe(new DirectiveInputObjectTypeMiddleware($directiveAstBuilder)); $compositeTypeMapper = new CompositeTypeMapper(); $recursiveTypeMapper = new RecursiveTypeMapper($compositeTypeMapper, $namingStrategy, $namespacedCache, $typeRegistry, $annotationReader); @@ -481,9 +502,9 @@ classBoundCache: $classBoundCache, $parameterMiddlewarePipe->pipe(new ContainerParameterHandler($this->container)); $parameterMiddlewarePipe->pipe(new InjectUserParameterHandler($authenticationService)); - $typeGenerator = new TypeGenerator($annotationReader, $namingStrategy, $typeRegistry, $this->container, $recursiveTypeMapper, $fieldsBuilder, $docBlockFactory, $descriptionResolver); + $typeGenerator = new TypeGenerator($annotationReader, $namingStrategy, $typeRegistry, $this->container, $recursiveTypeMapper, $fieldsBuilder, $docBlockFactory, $descriptionResolver, $objectTypeMiddlewarePipe, $directiveRegistry); $inputTypeUtils = new InputTypeUtils($annotationReader, $namingStrategy); - $inputTypeGenerator = new InputTypeGenerator($inputTypeUtils, $fieldsBuilder, $this->inputTypeValidator, $annotationReader, $docBlockFactory, $descriptionResolver); + $inputTypeGenerator = new InputTypeGenerator($inputTypeUtils, $fieldsBuilder, $this->inputTypeValidator, $annotationReader, $docBlockFactory, $descriptionResolver, $inputObjectTypeMiddlewarePipe, $directiveRegistry); if ($this->namespaces) { $compositeTypeMapper->addTypeMapper(new ClassFinderTypeMapper( @@ -558,7 +579,14 @@ classBoundCache: $classBoundCache, $aggregateQueryProvider = new AggregateQueryProvider($queryProviders); - return new Schema($aggregateQueryProvider, $recursiveTypeMapper, $typeResolver, $topRootTypeMapper, $this->schemaConfig); + return new Schema( + $aggregateQueryProvider, + $recursiveTypeMapper, + $typeResolver, + $topRootTypeMapper, + $this->schemaConfig, + $directiveRegistry->webonyxDirectives(), + ); } private function createClassFinder(): ClassFinder diff --git a/src/TypeGenerator.php b/src/TypeGenerator.php index 1220fa679..a0054be2a 100644 --- a/src/TypeGenerator.php +++ b/src/TypeGenerator.php @@ -10,7 +10,10 @@ use TheCodingMachine\GraphQLite\Annotations\Exceptions\DuplicateDescriptionOnTypeException; use TheCodingMachine\GraphQLite\Annotations\ExtendType; use TheCodingMachine\GraphQLite\Annotations\Type as TypeAnnotation; +use TheCodingMachine\GraphQLite\Directives\DirectiveRegistry; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapperInterface; +use TheCodingMachine\GraphQLite\Middlewares\ObjectTypeHandlerInterface; +use TheCodingMachine\GraphQLite\Middlewares\ObjectTypeMiddlewareInterface; use TheCodingMachine\GraphQLite\Reflection\DocBlock\DocBlockFactory; use TheCodingMachine\GraphQLite\Types\MutableInterface; use TheCodingMachine\GraphQLite\Types\MutableInterfaceType; @@ -49,6 +52,8 @@ public function __construct( private FieldsBuilder $fieldsBuilder, private DocBlockFactory|null $docBlockFactory = null, private DescriptionResolver $descriptionResolver = new DescriptionResolver(true), + private ObjectTypeMiddlewareInterface|null $objectTypeMiddleware = null, + private DirectiveRegistry|null $directiveRegistry = null, ) { } @@ -125,6 +130,22 @@ public function mapAnnotatedObject(string $annotatedObjectClassName): MutableInt $type->description = $resolvedDescription; } + if ($type instanceof MutableObjectType && $this->objectTypeMiddleware !== null && $this->directiveRegistry !== null) { + $directives = $this->directiveRegistry->objectTypeDirectives($refTypeClass); + if ($directives !== []) { + $descriptor = new ObjectTypeDescriptor($refTypeClass, $type, $directives); + $type = $this->objectTypeMiddleware->process( + $descriptor, + new class implements ObjectTypeHandlerInterface { + public function handle(ObjectTypeDescriptor $descriptor): MutableObjectType + { + return $descriptor->getType(); + } + }, + ); + } + } + return $type; } diff --git a/tests/Directives/DirectiveRegistryTest.php b/tests/Directives/DirectiveRegistryTest.php new file mode 100644 index 000000000..e39bb8711 --- /dev/null +++ b/tests/Directives/DirectiveRegistryTest.php @@ -0,0 +1,67 @@ +discover(); + + return $registry; + } + + public function testRegistersTheBuiltInDirectives(): void + { + $registry = $this->registry(); + + $this->assertNotNull($registry->definitionFor(OneOf::class)); + $this->assertNotNull($registry->definitionFor(Deprecated::class)); + } + + public function testBuiltInsAreNotAddedToTheSchemaDirectiveList(): void + { + // webonyx already declares @oneOf and @deprecated, so the registry contributes nothing to + // SchemaConfig::$directives. + $registry = $this->registry(); + + $this->assertSame([], $registry->webonyxDirectives()); + $this->assertFalse($registry->hasAny()); + } + + public function testResolvesBuiltInArguments(): void + { + $arguments = $this->registry()->argumentsFor(Deprecated::class); + + $this->assertCount(1, $arguments); + $this->assertSame('reason', $arguments[0]->name); + } + + public function testDiscoverIsIdempotent(): void + { + $registry = new DirectiveRegistry(new AnnotationReader()); + $registry->discover(); + $registry->discover(); + + $this->assertNotNull($registry->definitionFor(OneOf::class)); + } + + public function testObjectTypeDirectivesRejectsMisplacedDirective(): void + { + $this->expectException(InvalidDirectiveException::class); + $this->expectExceptionMessageMatches('/cannot be used on OBJECT/'); + + $this->registry()->objectTypeDirectives(new ReflectionClass(MisusedOneOfOnType::class)); + } +} diff --git a/tests/Directives/DirectiveResolverTest.php b/tests/Directives/DirectiveResolverTest.php new file mode 100644 index 000000000..ca380ddc3 --- /dev/null +++ b/tests/Directives/DirectiveResolverTest.php @@ -0,0 +1,55 @@ +assertSame([], $resolved->arguments); + } + + public function testResolvesScalarArgumentWithCorrectTypeAndNullability(): void + { + $resolved = DirectiveResolver::resolve(AuditFieldDirective::class, AuditFieldDirective::definition()); + + $this->assertCount(1, $resolved->arguments); + $this->assertSame('reason', $resolved->arguments[0]->name); + $this->assertInstanceOf(NonNull::class, $resolved->arguments[0]->type); + $this->assertInstanceOf(StringType::class, $resolved->arguments[0]->type->getWrappedType()); + $this->assertFalse($resolved->arguments[0]->hasDefaultValue); + } + + public function testInfersRepeatableFromAttributeFlags(): void + { + $audit = DirectiveResolver::resolve(AuditFieldDirective::class, AuditFieldDirective::definition())->webonyxDirective; + $uppercase = DirectiveResolver::resolve(UppercaseFieldDirective::class, UppercaseFieldDirective::definition())->webonyxDirective; + + $this->assertInstanceOf(WebonyxDirective::class, $audit); + $this->assertTrue($audit->isRepeatable); + + $this->assertInstanceOf(WebonyxDirective::class, $uppercase); + $this->assertFalse($uppercase->isRepeatable); + } + + public function testRejectsUnsupportedArgumentType(): void + { + $this->expectException(InvalidDirectiveException::class); + $this->expectExceptionMessageMatches('/scalar types/'); + + DirectiveResolver::resolve(UnsupportedArgumentTypeDirective::class, UnsupportedArgumentTypeDirective::definition()); + } +} diff --git a/tests/Directives/DirectiveUsageTest.php b/tests/Directives/DirectiveUsageTest.php new file mode 100644 index 000000000..a5faa69c1 --- /dev/null +++ b/tests/Directives/DirectiveUsageTest.php @@ -0,0 +1,21 @@ +expectException(InvalidDirectiveException::class); + $this->expectExceptionMessageMatches('/cannot be used on OBJECT/'); + + DirectiveValidator::assertDirectivesUsableAt(new ReflectionClass(MisusedOneOfOnType::class), DirectiveLocation::OBJECT); + } +} diff --git a/tests/Fixtures/DirectiveLayerIntegration/DirectiveLayerController.php b/tests/Fixtures/DirectiveLayerIntegration/DirectiveLayerController.php new file mode 100644 index 000000000..892437b77 --- /dev/null +++ b/tests/Fixtures/DirectiveLayerIntegration/DirectiveLayerController.php @@ -0,0 +1,34 @@ +sku ?? (string) $lookup->id; + } + + // A bare #[Deprecated] should keep the docblock @deprecated reason rather than overriding it. + /** @deprecated Use lookup instead. */ + #[Query] + #[Deprecated] + public function legacyDocblock(): string + { + return 'legacy'; + } +} diff --git a/tests/Fixtures/DirectiveLayerIntegration/Lookup.php b/tests/Fixtures/DirectiveLayerIntegration/Lookup.php new file mode 100644 index 000000000..3d71d80f5 --- /dev/null +++ b/tests/Fixtures/DirectiveLayerIntegration/Lookup.php @@ -0,0 +1,23 @@ +getResolver(); + $descriptor = $descriptor->withResolver(static function (...$args) use ($resolver): mixed { + $value = $resolver(...$args); + return is_string($value) ? strtoupper($value) : $value; + }); + + return $next->handle($descriptor); + } +} diff --git a/tests/Integration/DirectiveLayerEndToEndTest.php b/tests/Integration/DirectiveLayerEndToEndTest.php new file mode 100644 index 000000000..7b5631c9a --- /dev/null +++ b/tests/Integration/DirectiveLayerEndToEndTest.php @@ -0,0 +1,51 @@ +prodMode(); + $factory->addNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\DirectiveLayerIntegration'); + + return $factory->createSchema(); + } + + public function testOneOfPrintsInSdl(): void + { + $sdl = SchemaPrinter::doPrint($this->buildSchema()); + + $this->assertStringContainsString('input LookupInput @oneOf', $sdl); + } + + public function testDeprecatedPrintsInSdl(): void + { + $sdl = SchemaPrinter::doPrint($this->buildSchema()); + + $this->assertStringContainsString('legacy: String! @deprecated(reason: "Use current instead.")', $sdl); + } + + public function testBareDeprecatedKeepsTheDocblockReason(): void + { + $sdl = SchemaPrinter::doPrint($this->buildSchema()); + + $this->assertStringContainsString('legacyDocblock: String! @deprecated(reason: "Use lookup instead.")', $sdl); + } +}