From 4ddaed8d2f5d3d292598dccfabfcbc5628814bf9 Mon Sep 17 00:00:00 2001 From: Michael Georgiadis <38563912+michael-georgiadis@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:12:55 +0200 Subject: [PATCH 01/12] Add directive contracts and DTOs --- src/Directives/BehavioralFieldDirective.php | 19 +++++++ .../BehavioralInputFieldDirective.php | 18 ++++++ .../BehavioralInputObjectTypeDirective.php | 18 ++++++ .../BehavioralObjectTypeDirective.php | 19 +++++++ src/Directives/DirectiveDefinition.php | 34 +++++++++++ src/Directives/DirectiveInterface.php | 15 +++++ src/Directives/DirectiveLocation.php | 57 +++++++++++++++++++ src/Directives/FieldDirective.php | 22 +++++++ src/Directives/InputFieldDirective.php | 16 ++++++ src/Directives/InputObjectTypeDirective.php | 14 +++++ src/Directives/ObjectTypeDirective.php | 14 +++++ src/Directives/TypeSystemDirective.php | 15 +++++ .../InputObjectTypeHandlerInterface.php | 14 +++++ .../InputObjectTypeMiddlewareInterface.php | 20 +++++++ .../ObjectTypeHandlerInterface.php | 14 +++++ .../ObjectTypeMiddlewareInterface.php | 19 +++++++ 16 files changed, 328 insertions(+) create mode 100644 src/Directives/BehavioralFieldDirective.php create mode 100644 src/Directives/BehavioralInputFieldDirective.php create mode 100644 src/Directives/BehavioralInputObjectTypeDirective.php create mode 100644 src/Directives/BehavioralObjectTypeDirective.php create mode 100644 src/Directives/DirectiveDefinition.php create mode 100644 src/Directives/DirectiveInterface.php create mode 100644 src/Directives/DirectiveLocation.php create mode 100644 src/Directives/FieldDirective.php create mode 100644 src/Directives/InputFieldDirective.php create mode 100644 src/Directives/InputObjectTypeDirective.php create mode 100644 src/Directives/ObjectTypeDirective.php create mode 100644 src/Directives/TypeSystemDirective.php create mode 100644 src/Middlewares/InputObjectTypeHandlerInterface.php create mode 100644 src/Middlewares/InputObjectTypeMiddlewareInterface.php create mode 100644 src/Middlewares/ObjectTypeHandlerInterface.php create mode 100644 src/Middlewares/ObjectTypeMiddlewareInterface.php diff --git a/src/Directives/BehavioralFieldDirective.php b/src/Directives/BehavioralFieldDirective.php new file mode 100644 index 000000000..051a861dd --- /dev/null +++ b/src/Directives/BehavioralFieldDirective.php @@ -0,0 +1,19 @@ + $locations */ + public function __construct( + public readonly string $name, + public readonly array $locations, + public readonly bool $repeatable = false, + 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..1aa24c313 --- /dev/null +++ b/src/Directives/DirectiveInterface.php @@ -0,0 +1,15 @@ + true, + default => false, + }; + } + + public function isTypeSystem(): bool + { + return ! $this->isExecutable(); + } +} diff --git a/src/Directives/FieldDirective.php b/src/Directives/FieldDirective.php new file mode 100644 index 000000000..feded484f --- /dev/null +++ b/src/Directives/FieldDirective.php @@ -0,0 +1,22 @@ + Date: Mon, 1 Jun 2026 18:19:26 +0200 Subject: [PATCH 02/12] Add reflection-scanning and validator code --- src/AnnotationReader.php | 31 +++ src/Directives/DirectiveRegistry.php | 191 +++++++++++++++++ src/Directives/DirectiveValidator.php | 194 ++++++++++++++++++ .../Discovery/DirectiveClassFinder.php | 75 +++++++ .../Discovery/GlobDirectivesCache.php | 24 +++ src/Directives/ResolvedDirectiveArgument.php | 30 +++ 6 files changed, 545 insertions(+) create mode 100644 src/Directives/DirectiveRegistry.php create mode 100644 src/Directives/DirectiveValidator.php create mode 100644 src/Directives/Discovery/DirectiveClassFinder.php create mode 100644 src/Directives/Discovery/GlobDirectivesCache.php create mode 100644 src/Directives/ResolvedDirectiveArgument.php diff --git a/src/AnnotationReader.php b/src/AnnotationReader.php index c075d6b63..1cf124811 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 { ); } + /** + * Returns every {@see ObjectTypeDirective} attached to 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)); + } + + /** + * Returns every {@see InputObjectTypeDirective} attached to 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/DirectiveRegistry.php b/src/Directives/DirectiveRegistry.php new file mode 100644 index 000000000..167ee3a8f --- /dev/null +++ b/src/Directives/DirectiveRegistry.php @@ -0,0 +1,191 @@ +, DirectiveDefinition> */ + private array $definitionsByClass = []; + + /** @var array, list> */ + private array $argumentsByClass = []; + + /** @var array, WebonyxDirective> */ + private array $webonyxByClass = []; + + /** @var array> */ + private array $classByName = []; + + /** + * Names of webonyx-built-in directives. Custom (non-built-in) directives cannot reuse them. + */ + private const RESERVED_NAMES = ['skip', 'include', 'deprecated', 'oneOf']; + + /** + * Attribute classes that bind PHP behavior to webonyx's pre-existing built-in directives. + * Registered after user discovery so a user-provided override (a class with the same name and + * `builtIn: true`) wins. + */ + private const BUILT_IN_ATTRIBUTES = [ + OneOf::class, + ]; + + public function __construct( + private readonly DirectiveClassFinder $classFinder, + ) { + } + + /** Run discovery + validation once. Idempotent. */ + public function discover(): void + { + // User classes first so a user-supplied override of a built-in (a class with `builtIn: + // true` and the same name) lands before our bundled copy gets a chance to register. + foreach ($this->classFinder->findDirectives() as $directiveClass) { + $this->register($directiveClass); + } + foreach (self::BUILT_IN_ATTRIBUTES as $directiveClass) { + $this->register($directiveClass); + } + } + + /** + * @param class-string $directiveClass + * + * @throws InvalidDirectiveException + */ + private function register(string $directiveClass): void + { + // Same FQCN registered twice is a no-op — discovery, the built-in list, and user code all + // share the registry, so collisions on the same class are expected. + if (isset($this->definitionsByClass[$directiveClass])) { + return; + } + + if (! method_exists($directiveClass, 'definition')) { + throw InvalidDirectiveException::noDefinitionMethod($directiveClass); + } + + $definition = $directiveClass::definition(); + assert($definition instanceof DirectiveDefinition); + + $arguments = DirectiveValidator::validate($directiveClass, $definition); + + if (! $definition->builtIn && in_array($definition->name, self::RESERVED_NAMES, true)) { + throw InvalidDirectiveException::reservedName($definition->name, $directiveClass); + } + + if (array_key_exists($definition->name, $this->classByName)) { + // A name clash with a built-in is allowed when this side is also a built-in: it means + // the user has supplied their own implementation and we defer. Two non-built-ins with + // the same name remain an error. + if ($definition->builtIn) { + return; + } + throw InvalidDirectiveException::duplicateName( + $definition->name, + $this->classByName[$definition->name], + $directiveClass, + ); + } + + $this->definitionsByClass[$directiveClass] = $definition; + $this->argumentsByClass[$directiveClass] = $arguments; + $this->classByName[$definition->name] = $directiveClass; + + // Built-in directives are declared by webonyx itself — don't contribute a duplicate + // definition to SchemaConfig::$directives. + if ($definition->builtIn) { + return; + } + + $this->webonyxByClass[$directiveClass] = self::buildWebonyxDirective($definition, $arguments); + } + + /** @param list $arguments */ + private static function buildWebonyxDirective(DirectiveDefinition $definition, array $arguments): 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' => $definition->repeatable, + 'args' => $argsConfig, + ]; + if ($definition->description !== null) { + $config['description'] = $definition->description; + } + + return new WebonyxDirective($config); + } + + public function hasAny(): bool + { + return $this->webonyxByClass !== []; + } + + /** @return list */ + public function webonyxDirectives(): array + { + return array_values($this->webonyxByClass); + } + + /** + * Look up the argument shape for a directive class. Accepts any {@see DirectiveInterface} class + * so it composes with future executable-directive lookups. + * + * @param class-string $directiveClass + * + * @return list + */ + public function argumentsFor(string $directiveClass): array + { + return $this->argumentsByClass[$directiveClass] ?? []; + } + + /** + * Look up the declarative metadata for a directive class. Accepts any + * {@see DirectiveInterface} class for the same reason as {@see argumentsFor}. + * + * @param class-string $directiveClass + */ + public function definitionFor(string $directiveClass): DirectiveDefinition|null + { + return $this->definitionsByClass[$directiveClass] ?? null; + } +} diff --git a/src/Directives/DirectiveValidator.php b/src/Directives/DirectiveValidator.php new file mode 100644 index 000000000..5fa395246 --- /dev/null +++ b/src/Directives/DirectiveValidator.php @@ -0,0 +1,194 @@ + $directiveClass + * + * @return list + * + * @throws InvalidDirectiveException + */ + public static function validate(string $directiveClass, DirectiveDefinition $definition): array + { + $reflection = new ReflectionClass($directiveClass); + + $attributeAttributes = $reflection->getAttributes(Attribute::class); + if ($attributeAttributes === []) { + throw InvalidDirectiveException::notAttribute($directiveClass); + } + + $attributeArgs = $attributeAttributes[0]->getArguments(); + $phpFlags = $attributeArgs[0] ?? $attributeArgs['flags'] ?? Attribute::TARGET_ALL; + + self::checkPhpTargets($directiveClass, $definition, $phpFlags); + self::checkRepeatableParity($directiveClass, $definition, $phpFlags); + self::checkInterfaceAndLocationAgreement($directiveClass, $definition, $reflection); + + return self::resolveArguments($directiveClass, $reflection); + } + + private static function checkPhpTargets(string $directiveClass, DirectiveDefinition $definition, int $phpFlags): void + { + foreach ($definition->locations as $location) { + foreach (self::requiredPhpTargetsFor($location) as $requiredTarget => $label) { + if (($phpFlags & $requiredTarget) === $requiredTarget) { + continue; + } + + throw InvalidDirectiveException::phpTargetMissingForLocation($directiveClass, $location, $label); + } + } + } + + private static function checkRepeatableParity(string $directiveClass, DirectiveDefinition $definition, int $phpFlags): void + { + $phpRepeatable = ($phpFlags & Attribute::IS_REPEATABLE) === Attribute::IS_REPEATABLE; + if ($phpRepeatable === $definition->repeatable) { + return; + } + + throw InvalidDirectiveException::repeatableMismatch($directiveClass, $phpRepeatable, $definition->repeatable); + } + + /** @param ReflectionClass $reflection */ + private static function checkInterfaceAndLocationAgreement(string $directiveClass, DirectiveDefinition $definition, ReflectionClass $reflection): void + { + $locations = $definition->locations; + $interfacePairs = [ + FieldDirective::class => DirectiveLocation::FIELD_DEFINITION, + InputFieldDirective::class => DirectiveLocation::INPUT_FIELD_DEFINITION, + ObjectTypeDirective::class => DirectiveLocation::OBJECT, + InputObjectTypeDirective::class => DirectiveLocation::INPUT_OBJECT, + ]; + + foreach ($interfacePairs as $interface => $expectedLocation) { + $implements = $reflection->implementsInterface($interface); + $declaresLocation = in_array($expectedLocation, $locations, true); + + if ($implements && ! $declaresLocation) { + throw InvalidDirectiveException::interfaceWithoutMatchingLocation($directiveClass, $interface, $locations); + } + + if (! $implements && $declaresLocation) { + throw InvalidDirectiveException::locationWithoutMatchingInterface($directiveClass, $expectedLocation, $interface); + } + } + } + + /** + * @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, + ); + } + + /** @return array map of Attribute::TARGET_* flag → human-readable label */ + private static function requiredPhpTargetsFor(DirectiveLocation $location): array + { + return match ($location) { + DirectiveLocation::FIELD_DEFINITION => [ + Attribute::TARGET_METHOD => 'TARGET_METHOD', + Attribute::TARGET_PROPERTY => 'TARGET_PROPERTY', + ], + DirectiveLocation::INPUT_FIELD_DEFINITION => [ + Attribute::TARGET_METHOD => 'TARGET_METHOD', + Attribute::TARGET_PROPERTY => 'TARGET_PROPERTY', + Attribute::TARGET_PARAMETER => 'TARGET_PARAMETER', + ], + DirectiveLocation::OBJECT => [Attribute::TARGET_CLASS => 'TARGET_CLASS'], + DirectiveLocation::INPUT_OBJECT => [Attribute::TARGET_CLASS => 'TARGET_CLASS'], + // Deferred locations have no enforced PHP target here yet — they'll be added when their + // apply hooks are wired. + default => [], + }; + } +} diff --git a/src/Directives/Discovery/DirectiveClassFinder.php b/src/Directives/Discovery/DirectiveClassFinder.php new file mode 100644 index 000000000..585b93d11 --- /dev/null +++ b/src/Directives/Discovery/DirectiveClassFinder.php @@ -0,0 +1,75 @@ +>|null */ + private array|null $cache = null; + + public function __construct( + private readonly ClassFinder $classFinder, + private readonly ClassFinderComputedCache $classFinderComputedCache, + ) { + } + + /** @return list> */ + public function findDirectives(): array + { + if ($this->cache !== null) { + return $this->cache; + } + + /** @var list> $result */ + $result = $this->classFinderComputedCache->compute( + $this->classFinder, + 'customDirectives', + static function (ReflectionClass $refClass): GlobDirectivesCache|null { + if ($refClass->isAbstract() || $refClass->isInterface() || $refClass->isEnum()) { + return null; + } + + if (! $refClass->implementsInterface(TypeSystemDirective::class)) { + return null; + } + + /** @var class-string $directiveClass */ + $directiveClass = $refClass->getName(); + return new GlobDirectivesCache($directiveClass); + }, + static fn (array $entries): array => array_reduce( + $entries, + static function (array $carry, GlobDirectivesCache|null $entry): array { + if ($entry === null) { + return $carry; + } + $carry[] = $entry->directiveClass; + return $carry; + }, + [], + ), + ); + + $this->cache = $result; + return $result; + } +} diff --git a/src/Directives/Discovery/GlobDirectivesCache.php b/src/Directives/Discovery/GlobDirectivesCache.php new file mode 100644 index 000000000..6f85bb05d --- /dev/null +++ b/src/Directives/Discovery/GlobDirectivesCache.php @@ -0,0 +1,24 @@ + $directiveClass */ + public function __construct(public readonly string $directiveClass) + { + } +} diff --git a/src/Directives/ResolvedDirectiveArgument.php b/src/Directives/ResolvedDirectiveArgument.php new file mode 100644 index 000000000..2d218f1e2 --- /dev/null +++ b/src/Directives/ResolvedDirectiveArgument.php @@ -0,0 +1,30 @@ + Date: Mon, 1 Jun 2026 18:20:55 +0200 Subject: [PATCH 03/12] Add middleware and pipe them in existing code --- src/Directives/DirectiveAstBuilder.php | 120 ++++++++++++++++++ src/InputObjectTypeDescriptor.php | 59 +++++++++ src/InputTypeGenerator.php | 44 ++++++- src/Middlewares/DirectiveFieldMiddleware.php | 75 +++++++++++ .../DirectiveInputFieldMiddleware.php | 80 ++++++++++++ .../DirectiveInputObjectTypeMiddleware.php | 68 ++++++++++ .../DirectiveObjectTypeMiddleware.php | 67 ++++++++++ .../InputObjectTypeMiddlewarePipe.php | 29 +++++ src/Middlewares/InputObjectTypeNext.php | 35 +++++ src/Middlewares/ObjectTypeMiddlewarePipe.php | 29 +++++ src/Middlewares/ObjectTypeNext.php | 35 +++++ src/ObjectTypeDescriptor.php | 54 ++++++++ src/SchemaFactory.php | 36 +++++- src/TypeGenerator.php | 19 +++ 14 files changed, 745 insertions(+), 5 deletions(-) create mode 100644 src/Directives/DirectiveAstBuilder.php create mode 100644 src/InputObjectTypeDescriptor.php create mode 100644 src/Middlewares/DirectiveFieldMiddleware.php create mode 100644 src/Middlewares/DirectiveInputFieldMiddleware.php create mode 100644 src/Middlewares/DirectiveInputObjectTypeMiddleware.php create mode 100644 src/Middlewares/DirectiveObjectTypeMiddleware.php create mode 100644 src/Middlewares/InputObjectTypeMiddlewarePipe.php create mode 100644 src/Middlewares/InputObjectTypeNext.php create mode 100644 src/Middlewares/ObjectTypeMiddlewarePipe.php create mode 100644 src/Middlewares/ObjectTypeNext.php create mode 100644 src/ObjectTypeDescriptor.php diff --git a/src/Directives/DirectiveAstBuilder.php b/src/Directives/DirectiveAstBuilder.php new file mode 100644 index 000000000..558ae0af9 --- /dev/null +++ b/src/Directives/DirectiveAstBuilder.php @@ -0,0 +1,120 @@ +directives` on the parent definition node + * (FieldDefinitionNode, InputValueDefinitionNode, ObjectTypeDefinitionNode, + * InputObjectTypeDefinitionNode). GraphQLite does not populate `astNode` anywhere else today, so + * this builder constructs the minimal node graph required for SDL output to include each applied + * directive, with its 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/InputObjectTypeDescriptor.php b/src/InputObjectTypeDescriptor.php new file mode 100644 index 000000000..a50fc448a --- /dev/null +++ b/src/InputObjectTypeDescriptor.php @@ -0,0 +1,59 @@ + $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..80f010b93 100644 --- a/src/InputTypeGenerator.php +++ b/src/InputTypeGenerator.php @@ -6,11 +6,15 @@ use GraphQL\Type\Definition\InputObjectType; use Psr\Container\ContainerInterface; +use ReflectionClass; use ReflectionFunctionAbstract; use ReflectionMethod; +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 +40,7 @@ public function __construct( private AnnotationReader|null $annotationReader = null, private DocBlockFactory|null $docBlockFactory = null, private DescriptionResolver $descriptionResolver = new DescriptionResolver(true), + private InputObjectTypeMiddlewareInterface|null $inputObjectTypeMiddleware = null, ) { } @@ -52,7 +57,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 +65,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 +107,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 +115,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->annotationReader === null) { + return $type; + } + + $directives = $this->annotationReader->getInputObjectTypeDirectives($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..2b0106340 --- /dev/null +++ b/src/Middlewares/DirectiveFieldMiddleware.php @@ -0,0 +1,75 @@ + $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..e2c039000 --- /dev/null +++ b/src/Middlewares/DirectiveInputFieldMiddleware.php @@ -0,0 +1,80 @@ + $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..c3ede8baf --- /dev/null +++ b/src/Middlewares/DirectiveInputObjectTypeMiddleware.php @@ -0,0 +1,68 @@ +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..51a164627 --- /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/InputObjectTypeMiddlewarePipe.php b/src/Middlewares/InputObjectTypeMiddlewarePipe.php new file mode 100644 index 000000000..b7c0a90d7 --- /dev/null +++ b/src/Middlewares/InputObjectTypeMiddlewarePipe.php @@ -0,0 +1,29 @@ +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/ObjectTypeMiddlewarePipe.php b/src/Middlewares/ObjectTypeMiddlewarePipe.php new file mode 100644 index 000000000..f1210d798 --- /dev/null +++ b/src/Middlewares/ObjectTypeMiddlewarePipe.php @@ -0,0 +1,29 @@ +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..4c5546584 --- /dev/null +++ b/src/ObjectTypeDescriptor.php @@ -0,0 +1,54 @@ + $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/SchemaFactory.php b/src/SchemaFactory.php index 20b5e27c1..fecf257c1 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -18,6 +18,9 @@ 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\Directives\Discovery\DirectiveClassFinder; use TheCodingMachine\GraphQLite\Discovery\Cache\HardClassFinderComputedCache; use TheCodingMachine\GraphQLite\Discovery\Cache\SnapshotClassFinderComputedCache; use TheCodingMachine\GraphQLite\Discovery\ClassFinder; @@ -50,10 +53,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 +409,12 @@ public function createSchema(): Schema $expressionLanguage = $this->expressionLanguage ?: new ExpressionLanguage($symfonyCache); $expressionLanguage->registerProvider(new SecurityExpressionLanguageProvider()); + $directiveRegistry = new DirectiveRegistry( + new DirectiveClassFinder($classFinder, $classFinderComputedCache), + ); + $directiveRegistry->discover(); + $directiveAstBuilder = new DirectiveAstBuilder($directiveRegistry); + $fieldMiddlewarePipe = new FieldMiddlewarePipe(); foreach ($this->fieldMiddlewares as $fieldMiddleware) { $fieldMiddlewarePipe->pipe($fieldMiddleware); @@ -408,6 +423,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 +432,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 +504,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); $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); if ($this->namespaces) { $compositeTypeMapper->addTypeMapper(new ClassFinderTypeMapper( @@ -558,7 +581,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..cc560618e 100644 --- a/src/TypeGenerator.php +++ b/src/TypeGenerator.php @@ -11,6 +11,8 @@ use TheCodingMachine\GraphQLite\Annotations\ExtendType; use TheCodingMachine\GraphQLite\Annotations\Type as TypeAnnotation; 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 +51,7 @@ public function __construct( private FieldsBuilder $fieldsBuilder, private DocBlockFactory|null $docBlockFactory = null, private DescriptionResolver $descriptionResolver = new DescriptionResolver(true), + private ObjectTypeMiddlewareInterface|null $objectTypeMiddleware = null, ) { } @@ -125,6 +128,22 @@ public function mapAnnotatedObject(string $annotatedObjectClassName): MutableInt $type->description = $resolvedDescription; } + if ($type instanceof MutableObjectType && $this->objectTypeMiddleware !== null) { + $directives = $this->annotationReader->getObjectTypeDirectives($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; } From 9d9aab5304d88951c95732494f683708356ee00e Mon Sep 17 00:00:00 2001 From: Michael Georgiadis <38563912+michael-georgiadis@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:21:37 +0200 Subject: [PATCH 04/12] Add fixtures --- src/Directives/BuiltIn/OneOf.php | 43 +++++++++++++++++++ .../Directives/AuditFieldDirective.php | 28 ++++++++++++ .../Directives/CustomOneOfOverride.php | 38 ++++++++++++++++ .../InterfaceWithoutLocationDirective.php | 26 +++++++++++ .../Invalid/MissingPhpTargetDirective.php | 26 +++++++++++ .../Invalid/RepeatableMismatchDirective.php | 27 ++++++++++++ .../Invalid/ReservedNameDirective.php | 26 +++++++++++ .../UnsupportedArgumentTypeDirective.php | 31 +++++++++++++ .../Directives/TaggedObjectTypeDirective.php | 26 +++++++++++ .../Directives/TrimInputFieldDirective.php | 22 ++++++++++ .../Directives/UppercaseFieldDirective.php | 39 +++++++++++++++++ .../VersionedInputObjectDirective.php | 31 +++++++++++++ .../Directives/AuditDirective.php | 28 ++++++++++++ .../Directives/OneOfishDirective.php | 31 +++++++++++++ .../Directives/SanitizedDirective.php | 22 ++++++++++ .../Directives/TaggedDirective.php | 26 +++++++++++ .../Directives/UppercaseDirective.php | 39 +++++++++++++++++ .../Directives/VersionedDirective.php | 33 ++++++++++++++ 18 files changed, 542 insertions(+) create mode 100644 src/Directives/BuiltIn/OneOf.php create mode 100644 tests/Fixtures/Directives/AuditFieldDirective.php create mode 100644 tests/Fixtures/Directives/CustomOneOfOverride.php create mode 100644 tests/Fixtures/Directives/Invalid/InterfaceWithoutLocationDirective.php create mode 100644 tests/Fixtures/Directives/Invalid/MissingPhpTargetDirective.php create mode 100644 tests/Fixtures/Directives/Invalid/RepeatableMismatchDirective.php create mode 100644 tests/Fixtures/Directives/Invalid/ReservedNameDirective.php create mode 100644 tests/Fixtures/Directives/Invalid/UnsupportedArgumentTypeDirective.php create mode 100644 tests/Fixtures/Directives/TaggedObjectTypeDirective.php create mode 100644 tests/Fixtures/Directives/TrimInputFieldDirective.php create mode 100644 tests/Fixtures/Directives/UppercaseFieldDirective.php create mode 100644 tests/Fixtures/Directives/VersionedInputObjectDirective.php create mode 100644 tests/Fixtures/DirectivesIntegration/Directives/AuditDirective.php create mode 100644 tests/Fixtures/DirectivesIntegration/Directives/OneOfishDirective.php create mode 100644 tests/Fixtures/DirectivesIntegration/Directives/SanitizedDirective.php create mode 100644 tests/Fixtures/DirectivesIntegration/Directives/TaggedDirective.php create mode 100644 tests/Fixtures/DirectivesIntegration/Directives/UppercaseDirective.php create mode 100644 tests/Fixtures/DirectivesIntegration/Directives/VersionedDirective.php diff --git a/src/Directives/BuiltIn/OneOf.php b/src/Directives/BuiltIn/OneOf.php new file mode 100644 index 000000000..e136aadef --- /dev/null +++ b/src/Directives/BuiltIn/OneOf.php @@ -0,0 +1,43 @@ +handle($descriptor); + $type->isOneOf = true; + return $type; + } +} diff --git a/tests/Fixtures/Directives/AuditFieldDirective.php b/tests/Fixtures/Directives/AuditFieldDirective.php new file mode 100644 index 000000000..f4d9606d7 --- /dev/null +++ b/tests/Fixtures/Directives/AuditFieldDirective.php @@ -0,0 +1,28 @@ +handle($descriptor); + $type->isOneOf = true; + return $type; + } +} diff --git a/tests/Fixtures/Directives/Invalid/InterfaceWithoutLocationDirective.php b/tests/Fixtures/Directives/Invalid/InterfaceWithoutLocationDirective.php new file mode 100644 index 000000000..fb96826ad --- /dev/null +++ b/tests/Fixtures/Directives/Invalid/InterfaceWithoutLocationDirective.php @@ -0,0 +1,26 @@ +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/Fixtures/Directives/VersionedInputObjectDirective.php b/tests/Fixtures/Directives/VersionedInputObjectDirective.php new file mode 100644 index 000000000..aeb27e870 --- /dev/null +++ b/tests/Fixtures/Directives/VersionedInputObjectDirective.php @@ -0,0 +1,31 @@ +handle($descriptor); + } +} diff --git a/tests/Fixtures/DirectivesIntegration/Directives/SanitizedDirective.php b/tests/Fixtures/DirectivesIntegration/Directives/SanitizedDirective.php new file mode 100644 index 000000000..17d8a4073 --- /dev/null +++ b/tests/Fixtures/DirectivesIntegration/Directives/SanitizedDirective.php @@ -0,0 +1,22 @@ +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/Fixtures/DirectivesIntegration/Directives/VersionedDirective.php b/tests/Fixtures/DirectivesIntegration/Directives/VersionedDirective.php new file mode 100644 index 000000000..7a0a86326 --- /dev/null +++ b/tests/Fixtures/DirectivesIntegration/Directives/VersionedDirective.php @@ -0,0 +1,33 @@ + Date: Mon, 1 Jun 2026 18:22:01 +0200 Subject: [PATCH 05/12] Add fixtures follow-up --- .../Controllers/WidgetController.php | 33 +++++++++++++++++++ .../Inputs/OneOfLookup.php | 26 +++++++++++++++ .../Inputs/WidgetLookup.php | 24 ++++++++++++++ .../DirectivesIntegration/Models/Widget.php | 29 ++++++++++++++++ 4 files changed, 112 insertions(+) create mode 100644 tests/Fixtures/DirectivesIntegration/Controllers/WidgetController.php create mode 100644 tests/Fixtures/DirectivesIntegration/Inputs/OneOfLookup.php create mode 100644 tests/Fixtures/DirectivesIntegration/Inputs/WidgetLookup.php create mode 100644 tests/Fixtures/DirectivesIntegration/Models/Widget.php diff --git a/tests/Fixtures/DirectivesIntegration/Controllers/WidgetController.php b/tests/Fixtures/DirectivesIntegration/Controllers/WidgetController.php new file mode 100644 index 000000000..93b7aad75 --- /dev/null +++ b/tests/Fixtures/DirectivesIntegration/Controllers/WidgetController.php @@ -0,0 +1,33 @@ +sku ?? ('id-' . ($lookup->id ?? 0))); + } + + #[Query] + public function findOneOf(OneOfLookup $lookup): Widget + { + return new Widget($lookup->sku ?? ('id-' . ($lookup->id ?? 0))); + } +} diff --git a/tests/Fixtures/DirectivesIntegration/Inputs/OneOfLookup.php b/tests/Fixtures/DirectivesIntegration/Inputs/OneOfLookup.php new file mode 100644 index 000000000..a271842c9 --- /dev/null +++ b/tests/Fixtures/DirectivesIntegration/Inputs/OneOfLookup.php @@ -0,0 +1,26 @@ +label; + } +} From eb1f69ada0cf0604cf3f8b093f6bdde16a405c92 Mon Sep 17 00:00:00 2001 From: Michael Georgiadis <38563912+michael-georgiadis@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:22:26 +0200 Subject: [PATCH 06/12] Add tests --- tests/Directives/BuiltInDirectivesTest.php | 83 +++++++++++++ tests/Directives/DirectiveDefinitionTest.php | 36 ++++++ tests/Directives/DirectiveLocationTest.php | 49 ++++++++ tests/Directives/DirectiveRegistryTest.php | 95 +++++++++++++++ tests/Directives/DirectiveValidatorTest.php | 69 +++++++++++ tests/Integration/DirectivesEndToEndTest.php | 120 +++++++++++++++++++ 6 files changed, 452 insertions(+) create mode 100644 tests/Directives/BuiltInDirectivesTest.php create mode 100644 tests/Directives/DirectiveDefinitionTest.php create mode 100644 tests/Directives/DirectiveLocationTest.php create mode 100644 tests/Directives/DirectiveRegistryTest.php create mode 100644 tests/Directives/DirectiveValidatorTest.php create mode 100644 tests/Integration/DirectivesEndToEndTest.php diff --git a/tests/Directives/BuiltInDirectivesTest.php b/tests/Directives/BuiltInDirectivesTest.php new file mode 100644 index 000000000..911cff95a --- /dev/null +++ b/tests/Directives/BuiltInDirectivesTest.php @@ -0,0 +1,83 @@ +discover(); + + $this->assertNotNull($registry->definitionFor(OneOf::class)); + } + + public function testBuiltInDirectivesAreNotEmittedAsCustomDirectives(): void + { + $registry = self::buildRegistry([]); + $registry->discover(); + + // The registry must not contribute `@oneOf` to SchemaConfig::$directives — webonyx already + // declares it as a built-in. Custom directive list stays empty. + $names = array_map(static fn (WebonyxDirective $d) => $d->name, $registry->webonyxDirectives()); + + $this->assertSame([], $names); + $this->assertFalse($registry->hasAny()); + } + + public function testOneOfDefinitionMarksItselfAsBuiltIn(): void + { + $definition = OneOf::definition(); + + $this->assertSame('oneOf', $definition->name); + $this->assertTrue($definition->builtIn); + $this->assertSame([DirectiveLocation::INPUT_OBJECT], $definition->locations); + } + + public function testDiscoveryFindingTheBundledBuiltInClassIsIdempotent(): void + { + // Simulate a user namespace that happens to include our built-in attributes — the + // class-finder yields the same FQCN that `BUILT_IN_ATTRIBUTES` registers. Registration + // must not throw on the duplicate-class case. + $registry = self::buildRegistry([OneOf::class]); + $registry->discover(); + + $this->assertNotNull($registry->definitionFor(OneOf::class)); + } + + public function testUserOverrideOfBuiltInWinsOverBundled(): void + { + // A user supplies their own class binding to `@oneOf` (with `builtIn: true`). Our bundled + // copy must defer so the user's behavior is the one that runs. + $registry = self::buildRegistry([CustomOneOfOverride::class]); + $registry->discover(); + + $this->assertNotNull($registry->definitionFor(CustomOneOfOverride::class)); + $this->assertNull($registry->definitionFor(OneOf::class)); + } + + /** @param list> $classes */ + private static function buildRegistry(array $classes): DirectiveRegistry + { + $finder = new DirectiveClassFinder( + new StaticClassFinder($classes), + new HardClassFinderComputedCache(new Psr16Cache(new ArrayAdapter())), + ); + + return new DirectiveRegistry($finder); + } +} diff --git a/tests/Directives/DirectiveDefinitionTest.php b/tests/Directives/DirectiveDefinitionTest.php new file mode 100644 index 000000000..d8ab3bd22 --- /dev/null +++ b/tests/Directives/DirectiveDefinitionTest.php @@ -0,0 +1,36 @@ +assertSame('audit', $definition->name); + $this->assertSame([DirectiveLocation::FIELD_DEFINITION, DirectiveLocation::INPUT_FIELD_DEFINITION], $definition->locations); + $this->assertTrue($definition->repeatable); + $this->assertSame('Audit log marker', $definition->description); + } + + public function testDefaultsRepeatableFalseAndNoDescription(): void + { + $definition = new DirectiveDefinition( + name: 'noop', + locations: [DirectiveLocation::OBJECT], + ); + + $this->assertFalse($definition->repeatable); + $this->assertNull($definition->description); + } +} diff --git a/tests/Directives/DirectiveLocationTest.php b/tests/Directives/DirectiveLocationTest.php new file mode 100644 index 000000000..e217acd84 --- /dev/null +++ b/tests/Directives/DirectiveLocationTest.php @@ -0,0 +1,49 @@ +assertCount(19, DirectiveLocation::cases()); + } + + public function testBackingValuesMatchSpecStrings(): void + { + $this->assertSame('FIELD_DEFINITION', DirectiveLocation::FIELD_DEFINITION->value); + $this->assertSame('OBJECT', DirectiveLocation::OBJECT->value); + $this->assertSame('INPUT_OBJECT', DirectiveLocation::INPUT_OBJECT->value); + $this->assertSame('INPUT_FIELD_DEFINITION', DirectiveLocation::INPUT_FIELD_DEFINITION->value); + } + + public function testIsExecutableClassifiesQueryDocumentLocations(): void + { + $this->assertTrue(DirectiveLocation::QUERY->isExecutable()); + $this->assertTrue(DirectiveLocation::FIELD->isExecutable()); + $this->assertTrue(DirectiveLocation::FRAGMENT_DEFINITION->isExecutable()); + $this->assertTrue(DirectiveLocation::INLINE_FRAGMENT->isExecutable()); + $this->assertTrue(DirectiveLocation::VARIABLE_DEFINITION->isExecutable()); + } + + public function testIsTypeSystemClassifiesSchemaLocations(): void + { + $this->assertTrue(DirectiveLocation::FIELD_DEFINITION->isTypeSystem()); + $this->assertTrue(DirectiveLocation::OBJECT->isTypeSystem()); + $this->assertTrue(DirectiveLocation::INPUT_OBJECT->isTypeSystem()); + $this->assertTrue(DirectiveLocation::INPUT_FIELD_DEFINITION->isTypeSystem()); + $this->assertTrue(DirectiveLocation::ENUM->isTypeSystem()); + $this->assertTrue(DirectiveLocation::SCHEMA->isTypeSystem()); + } + + public function testIsExecutableAndIsTypeSystemArePartitions(): void + { + foreach (DirectiveLocation::cases() as $location) { + $this->assertNotSame($location->isExecutable(), $location->isTypeSystem(), "Location {$location->value} should be exactly one of executable/type-system."); + } + } +} diff --git a/tests/Directives/DirectiveRegistryTest.php b/tests/Directives/DirectiveRegistryTest.php new file mode 100644 index 000000000..f52fbe26f --- /dev/null +++ b/tests/Directives/DirectiveRegistryTest.php @@ -0,0 +1,95 @@ +discover(); + + $this->assertTrue($registry->hasAny()); + + $webonyx = $registry->webonyxDirectives(); + $this->assertCount(3, $webonyx); + + $byName = []; + foreach ($webonyx as $directive) { + $byName[$directive->name] = $directive; + } + + $this->assertArrayHasKey('uppercase', $byName); + $this->assertArrayHasKey('audit', $byName); + $this->assertArrayHasKey('versioned', $byName); + + $audit = $byName['audit']; + $this->assertTrue($audit->isRepeatable); + $this->assertSame('Marks a field as needing audit-log treatment.', $audit->description); + $this->assertCount(1, $audit->args); + $this->assertSame('reason', $audit->args[0]->name); + } + + public function testRejectsCustomDirectiveUsingReservedName(): void + { + $registry = self::buildRegistry([ReservedNameDirective::class]); + + $this->expectException(InvalidDirectiveException::class); + $this->expectExceptionMessageMatches('/reserved/'); + + $registry->discover(); + } + + public function testEmptyRegistryReportsNoDirectives(): void + { + $registry = self::buildRegistry([]); + $registry->discover(); + + $this->assertFalse($registry->hasAny()); + $this->assertSame([], $registry->webonyxDirectives()); + } + + public function testWebonyxDirectivesIsolatedFromBuiltins(): void + { + // Sanity-check that the directives we built do NOT include webonyx's built-ins — they are + // merged in at the Schema layer, not here. + $registry = self::buildRegistry([UppercaseFieldDirective::class]); + $registry->discover(); + + $names = array_map(static fn (WebonyxDirective $d) => $d->name, $registry->webonyxDirectives()); + $this->assertNotContains('skip', $names); + $this->assertNotContains('include', $names); + $this->assertNotContains('deprecated', $names); + $this->assertNotContains('oneOf', $names); + } + + /** @param list> $classes */ + private static function buildRegistry(array $classes): DirectiveRegistry + { + $finder = new DirectiveClassFinder( + new StaticClassFinder($classes), + new HardClassFinderComputedCache(new Psr16Cache(new ArrayAdapter())), + ); + + return new DirectiveRegistry($finder); + } +} diff --git a/tests/Directives/DirectiveValidatorTest.php b/tests/Directives/DirectiveValidatorTest.php new file mode 100644 index 000000000..9acb8a190 --- /dev/null +++ b/tests/Directives/DirectiveValidatorTest.php @@ -0,0 +1,69 @@ +assertSame([], $args); + } + + public function testResolvesScalarArgumentWithCorrectTypeAndNullability(): void + { + $args = DirectiveValidator::validate(AuditFieldDirective::class, AuditFieldDirective::definition()); + + $this->assertCount(1, $args); + $this->assertSame('reason', $args[0]->name); + $this->assertInstanceOf(NonNull::class, $args[0]->type); + $this->assertInstanceOf(StringType::class, $args[0]->type->getWrappedType()); + $this->assertFalse($args[0]->hasDefaultValue); + } + + public function testRejectsDirectiveMissingRequiredPhpTarget(): void + { + $this->expectException(InvalidDirectiveException::class); + $this->expectExceptionMessageMatches('/TARGET_METHOD/'); + + DirectiveValidator::validate(MissingPhpTargetDirective::class, MissingPhpTargetDirective::definition()); + } + + public function testRejectsRepeatableMismatch(): void + { + $this->expectException(InvalidDirectiveException::class); + $this->expectExceptionMessageMatches('/repeatable/'); + + DirectiveValidator::validate(RepeatableMismatchDirective::class, RepeatableMismatchDirective::definition()); + } + + public function testRejectsInterfaceWithoutMatchingLocation(): void + { + $this->expectException(InvalidDirectiveException::class); + $this->expectExceptionMessageMatches('/FieldDirective/'); + + DirectiveValidator::validate(InterfaceWithoutLocationDirective::class, InterfaceWithoutLocationDirective::definition()); + } + + public function testRejectsUnsupportedArgumentType(): void + { + $this->expectException(InvalidDirectiveException::class); + $this->expectExceptionMessageMatches('/scalar types/'); + + DirectiveValidator::validate(UnsupportedArgumentTypeDirective::class, UnsupportedArgumentTypeDirective::definition()); + } +} diff --git a/tests/Integration/DirectivesEndToEndTest.php b/tests/Integration/DirectivesEndToEndTest.php new file mode 100644 index 000000000..27fcbed96 --- /dev/null +++ b/tests/Integration/DirectivesEndToEndTest.php @@ -0,0 +1,120 @@ +prodMode(); + $factory->addNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\DirectivesIntegration'); + + return $factory->createSchema(); + } + + public function testSchemaPrintsDirectiveDefinitionsAndApplications(): void + { + $schema = $this->buildSchema(); + $sdl = SchemaPrinter::doPrint($schema); + + // Definitions + $this->assertStringContainsString('directive @uppercase on FIELD_DEFINITION', $sdl); + $this->assertStringContainsString('Marks a field for audit-log tracking.', $sdl); + $this->assertStringContainsString('directive @audit(reason: String!) repeatable on FIELD_DEFINITION', $sdl); + $this->assertStringContainsString('directive @tagged(name: String!) on OBJECT', $sdl); + $this->assertStringContainsString('directive @sanitized on INPUT_FIELD_DEFINITION', $sdl); + $this->assertStringContainsString('Marks an input with a schema version for backwards-compat tracking.', $sdl); + $this->assertStringContainsString('directive @versioned(version: Int!) on INPUT_OBJECT', $sdl); + + // Applications + $this->assertStringContainsString('@uppercase', $sdl); + $this->assertStringContainsString('@audit(reason: "pii")', $sdl); + $this->assertStringContainsString('@audit(reason: "compliance")', $sdl); + $this->assertStringContainsString('@tagged(name: "primary")', $sdl); + $this->assertStringContainsString('@versioned(version: 2)', $sdl); + $this->assertStringContainsString('@sanitized', $sdl); + + // `#[OneOf]` binds PHP behavior to webonyx's built-in `@oneOf` directive — webonyx prints + // the application from its own `isOneOf` flag, and we must NOT re-declare the directive + // alongside the custom list. + $this->assertStringNotContainsString('directive @oneOf ', $sdl); + $this->assertStringContainsString('input OneOfLookupInput @oneOf', $sdl); + } + + public function testIntrospectionExposesEveryDirective(): void + { + $schema = $this->buildSchema(); + + $result = GraphQL::executeQuery($schema, Introspection::getIntrospectionQuery())->toArray(); + $this->assertArrayNotHasKey('errors', $result); + + $directives = $result['data']['__schema']['directives']; + assert(is_array($directives)); + + $names = []; + foreach ($directives as $directive) { + $names[] = $directive['name']; + } + + // Built-ins remain present alongside custom directives. + $this->assertContains('skip', $names); + $this->assertContains('include', $names); + $this->assertContains('deprecated', $names); + + // Custom directives present. + $this->assertContains('uppercase', $names); + $this->assertContains('audit', $names); + $this->assertContains('tagged', $names); + $this->assertContains('versioned', $names); + $this->assertContains('sanitized', $names); + } + + public function testFieldDirectiveWrapsResolver(): void + { + $schema = $this->buildSchema(); + + $result = GraphQL::executeQuery($schema, '{ tagline }')->toArray(); + $this->assertArrayNotHasKey('errors', $result); + $this->assertSame('HELLO WORLD', $result['data']['tagline']); + } + + public function testInputObjectFieldsResolveWithDirectiveAttached(): void + { + $schema = $this->buildSchema(); + + $result = GraphQL::executeQuery( + $schema, + '{ findWidget(lookup: { sku: "abc" }) { label } }', + )->toArray(); + + $this->assertArrayNotHasKey('errors', $result); + // The UppercaseDirective on getLabel() should uppercase the returned string. + $this->assertSame('ABC', $result['data']['findWidget']['label']); + } +} From d88e3fea00ba05e07845c87e9989f4183df009dd Mon Sep 17 00:00:00 2001 From: Michael Georgiadis <38563912+michael-georgiadis@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:22:43 +0200 Subject: [PATCH 07/12] Add domain exception --- .../Exceptions/InvalidDirectiveException.php | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/Directives/Exceptions/InvalidDirectiveException.php diff --git a/src/Directives/Exceptions/InvalidDirectiveException.php b/src/Directives/Exceptions/InvalidDirectiveException.php new file mode 100644 index 000000000..fb9c188cf --- /dev/null +++ b/src/Directives/Exceptions/InvalidDirectiveException.php @@ -0,0 +1,113 @@ +value, + $requiredTarget, + )); + } + + public static function repeatableMismatch(string $directiveClass, bool $phpRepeatable, bool $definitionRepeatable): self + { + return new self(sprintf( + 'Directive "%s" has inconsistent repeatable settings: PHP IS_REPEATABLE=%s but DirectiveDefinition::$repeatable=%s. ' . + 'They must agree so introspection and PHP usage stay in sync.', + $directiveClass, + $phpRepeatable ? 'true' : 'false', + $definitionRepeatable ? 'true' : 'false', + )); + } + + /** @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, + )); + } +} From f1c9ffaaedfebf02860db20b9da4b3223cf54601 Mon Sep 17 00:00:00 2001 From: Michael Georgiadis <38563912+michael-georgiadis@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:11:18 +0200 Subject: [PATCH 08/12] Fix end-to-end test --- src/Schema.php | 16 ++++++++++++ tests/Integration/DirectivesEndToEndTest.php | 26 +++++++++++--------- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/Schema.php b/src/Schema.php index f90c7fbbf..061788392 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,12 +19,17 @@ */ class Schema extends \GraphQL\Type\Schema { + /** + * @param array $directives Custom (non-built-in) directive definitions to expose in + * the schema, alongside webonyx's standard directives. + */ public function __construct( QueryProviderInterface $queryProvider, RecursiveTypeMapperInterface $recursiveTypeMapper, TypeResolver $typeResolver, RootTypeMapperInterface $rootTypeMapper, SchemaConfig|null $config = null, + array $directives = [], ) { if ($config === null) { $config = SchemaConfig::create(); @@ -113,6 +119,16 @@ public function __construct( return $rootTypeMapper->mapNameToType($name); }); + // Expose custom directive definitions (introspection + SDL). webonyx replaces the standard + // directive set the moment `directives` is non-null, so merge onto the existing list — + // the user's own config directives if they supplied any, otherwise webonyx's standard set. + if ($directives !== []) { + $config->setDirectives([ + ...($config->getDirectives() ?? Directive::builtInDirectives()), + ...$directives, + ]); + } + $typeResolver->registerSchema($this); parent::__construct($config); diff --git a/tests/Integration/DirectivesEndToEndTest.php b/tests/Integration/DirectivesEndToEndTest.php index 27fcbed96..1b3c42c67 100644 --- a/tests/Integration/DirectivesEndToEndTest.php +++ b/tests/Integration/DirectivesEndToEndTest.php @@ -6,12 +6,13 @@ use GraphQL\GraphQL; use GraphQL\Type\Introspection; -use TheCodingMachine\GraphQLite\Utils\SchemaPrinter; +use GraphQL\Utils\SchemaPrinter; use PHPUnit\Framework\TestCase; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Psr16Cache; use TheCodingMachine\GraphQLite\Containers\BasicAutoWiringContainer; use TheCodingMachine\GraphQLite\Containers\EmptyContainer; +use TheCodingMachine\GraphQLite\Schema; use TheCodingMachine\GraphQLite\SchemaFactory; use function assert; @@ -22,13 +23,17 @@ * against the `tests/Fixtures/DirectivesIntegration` namespaces, then asserts: * * - SDL emits the directive definitions (`directive @uppercase on FIELD_DEFINITION`, ...). - * - SDL emits the directive applications (`tagline: String! @uppercase`, ...). * - Introspection reports each custom directive alongside the webonyx built-ins. * - A field carrying `@uppercase` actually has its resolver wrapped at runtime. + * + * Directive *applications* (`tagline: String! @uppercase`) are intentionally NOT asserted: GraphQLite + * mirrors webonyx, whose {@see \GraphQL\Utils\SchemaPrinter} prints directive definitions but not + * arbitrary applications. Applications stay attached to each element's `astNode->directives`, so a + * downstream/custom printer can still render them when needed (e.g. Apollo Federation's `@key`). */ final class DirectivesEndToEndTest extends TestCase { - private function buildSchema(): \TheCodingMachine\GraphQLite\Schema + private function buildSchema(): Schema { $cache = new Psr16Cache(new ArrayAdapter()); $factory = new SchemaFactory($cache, new BasicAutoWiringContainer(new EmptyContainer())); @@ -38,12 +43,13 @@ private function buildSchema(): \TheCodingMachine\GraphQLite\Schema return $factory->createSchema(); } - public function testSchemaPrintsDirectiveDefinitionsAndApplications(): void + public function testSchemaPrintsDirectiveDefinitions(): void { $schema = $this->buildSchema(); $sdl = SchemaPrinter::doPrint($schema); - // Definitions + // Definitions — the built-in webonyx printer emits these once the directives are registered + // on the schema (see SchemaFactory wiring of DirectiveRegistry::webonyxDirectives()). $this->assertStringContainsString('directive @uppercase on FIELD_DEFINITION', $sdl); $this->assertStringContainsString('Marks a field for audit-log tracking.', $sdl); $this->assertStringContainsString('directive @audit(reason: String!) repeatable on FIELD_DEFINITION', $sdl); @@ -52,13 +58,9 @@ public function testSchemaPrintsDirectiveDefinitionsAndApplications(): void $this->assertStringContainsString('Marks an input with a schema version for backwards-compat tracking.', $sdl); $this->assertStringContainsString('directive @versioned(version: Int!) on INPUT_OBJECT', $sdl); - // Applications - $this->assertStringContainsString('@uppercase', $sdl); - $this->assertStringContainsString('@audit(reason: "pii")', $sdl); - $this->assertStringContainsString('@audit(reason: "compliance")', $sdl); - $this->assertStringContainsString('@tagged(name: "primary")', $sdl); - $this->assertStringContainsString('@versioned(version: 2)', $sdl); - $this->assertStringContainsString('@sanitized', $sdl); + // Directive *applications* (`tagline: String! @uppercase`, `@audit(reason: "pii")`, ...) are + // intentionally NOT asserted — webonyx's SchemaPrinter does not render arbitrary directive + // applications, and GraphQLite mirrors that. See the class docblock. // `#[OneOf]` binds PHP behavior to webonyx's built-in `@oneOf` directive — webonyx prints // the application from its own `isOneOf` flag, and we must NOT re-declare the directive From 15c8e7c3867f124d5967f98ee477af467301f8f9 Mon Sep 17 00:00:00 2001 From: Michael Georgiadis <38563912+michael-georgiadis@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:47:26 +0200 Subject: [PATCH 09/12] Write the docs by hand --- src/AnnotationReader.php | 4 +- src/Directives/BehavioralFieldDirective.php | 5 +-- .../BehavioralInputFieldDirective.php | 3 +- .../BehavioralInputObjectTypeDirective.php | 4 +- .../BehavioralObjectTypeDirective.php | 4 +- src/Directives/BuiltIn/OneOf.php | 11 +++--- src/Directives/DirectiveAstBuilder.php | 10 ++--- src/Directives/DirectiveDefinition.php | 19 ++++------ src/Directives/DirectiveInterface.php | 5 +-- src/Directives/DirectiveLocation.php | 12 ++---- src/Directives/DirectiveRegistry.php | 37 ++++++++----------- src/Directives/DirectiveValidator.php | 21 +++++------ .../Discovery/DirectiveClassFinder.php | 9 ++--- .../Discovery/GlobDirectivesCache.php | 7 +--- .../Exceptions/InvalidDirectiveException.php | 5 +-- src/Directives/FieldDirective.php | 12 +++--- src/Directives/InputFieldDirective.php | 5 +-- src/Directives/InputObjectTypeDirective.php | 6 +-- src/Directives/ObjectTypeDirective.php | 5 +-- src/Directives/ResolvedDirectiveArgument.php | 9 ++--- src/Directives/TypeSystemDirective.php | 8 ++-- src/InputObjectTypeDescriptor.php | 12 +++--- src/Middlewares/DirectiveFieldMiddleware.php | 7 ++-- .../DirectiveInputFieldMiddleware.php | 7 ++-- .../DirectiveInputObjectTypeMiddleware.php | 7 ++-- .../DirectiveObjectTypeMiddleware.php | 6 +-- .../InputObjectTypeMiddlewareInterface.php | 6 +-- src/ObjectTypeDescriptor.php | 5 +-- src/Schema.php | 10 ++--- tests/Directives/BuiltInDirectivesTest.php | 11 ++---- tests/Directives/DirectiveLocationTest.php | 2 +- tests/Directives/DirectiveRegistryTest.php | 4 +- .../Directives/CustomOneOfOverride.php | 5 +-- .../InterfaceWithoutLocationDirective.php | 5 +-- .../Invalid/MissingPhpTargetDirective.php | 4 +- .../Invalid/RepeatableMismatchDirective.php | 4 +- .../Invalid/ReservedNameDirective.php | 3 +- .../UnsupportedArgumentTypeDirective.php | 4 +- .../VersionedInputObjectDirective.php | 5 +-- .../Directives/VersionedDirective.php | 6 +-- .../Inputs/OneOfLookup.php | 4 +- tests/Integration/DirectivesEndToEndTest.php | 32 +++++++--------- 42 files changed, 148 insertions(+), 202 deletions(-) diff --git a/src/AnnotationReader.php b/src/AnnotationReader.php index 1cf124811..43521ffd7 100644 --- a/src/AnnotationReader.php +++ b/src/AnnotationReader.php @@ -330,7 +330,7 @@ static function (array $parameterAnnotations): ParameterAnnotations { } /** - * Returns every {@see ObjectTypeDirective} attached to a class. + * The {@see ObjectTypeDirective}s on a class. * * @param ReflectionClass $refClass * @@ -344,7 +344,7 @@ public function getObjectTypeDirectives(ReflectionClass $refClass): array } /** - * Returns every {@see InputObjectTypeDirective} attached to a class. + * The {@see InputObjectTypeDirective}s on a class. * * @param ReflectionClass $refClass * diff --git a/src/Directives/BehavioralFieldDirective.php b/src/Directives/BehavioralFieldDirective.php index 051a861dd..154b1c547 100644 --- a/src/Directives/BehavioralFieldDirective.php +++ b/src/Directives/BehavioralFieldDirective.php @@ -9,9 +9,8 @@ use TheCodingMachine\GraphQLite\QueryFieldDescriptor; /** - * A {@see FieldDirective} that also has PHP-side behavior. Each behavioral directive's - * {@see applyToField} runs in declaration order as a sub-chain leading into the outer field pipe, - * with the standard `(descriptor, next)` middleware shape. + * A {@see FieldDirective} that also runs behavior. The {@see applyToField} hooks run in declaration + * order, chained ahead of the rest of the field pipe. */ interface BehavioralFieldDirective extends FieldDirective { diff --git a/src/Directives/BehavioralInputFieldDirective.php b/src/Directives/BehavioralInputFieldDirective.php index 58d5e8aab..4ed2cb8ee 100644 --- a/src/Directives/BehavioralInputFieldDirective.php +++ b/src/Directives/BehavioralInputFieldDirective.php @@ -9,8 +9,7 @@ use TheCodingMachine\GraphQLite\Middlewares\InputFieldHandlerInterface; /** - * An {@see InputFieldDirective} that also has PHP-side behavior. Dispatched through the input-field - * pipe. + * An {@see InputFieldDirective} that also runs behavior, dispatched through the input-field pipe. */ interface BehavioralInputFieldDirective extends InputFieldDirective { diff --git a/src/Directives/BehavioralInputObjectTypeDirective.php b/src/Directives/BehavioralInputObjectTypeDirective.php index dd13f31ef..bc9e20c24 100644 --- a/src/Directives/BehavioralInputObjectTypeDirective.php +++ b/src/Directives/BehavioralInputObjectTypeDirective.php @@ -9,8 +9,8 @@ use TheCodingMachine\GraphQLite\Types\MutableInputObjectType; /** - * An {@see InputObjectTypeDirective} that also has PHP-side behavior. Used for things like - * `@oneOf` that flip a flag on the built input type. + * An {@see InputObjectTypeDirective} that also runs behavior, e.g. `@oneOf` flipping a flag on the + * built input type. */ interface BehavioralInputObjectTypeDirective extends InputObjectTypeDirective { diff --git a/src/Directives/BehavioralObjectTypeDirective.php b/src/Directives/BehavioralObjectTypeDirective.php index ce4ac3f4c..4df2d4a99 100644 --- a/src/Directives/BehavioralObjectTypeDirective.php +++ b/src/Directives/BehavioralObjectTypeDirective.php @@ -9,8 +9,8 @@ use TheCodingMachine\GraphQLite\Types\MutableObjectType; /** - * An {@see ObjectTypeDirective} that also has PHP-side behavior — typically a metadata mutation - * applied to the built {@see MutableObjectType}. Dispatched through the new object-type pipe in + * An {@see ObjectTypeDirective} that also runs behavior, usually tweaking the built + * {@see MutableObjectType}. Dispatched through the object-type pipe in * {@see \TheCodingMachine\GraphQLite\TypeGenerator}. */ interface BehavioralObjectTypeDirective extends ObjectTypeDirective diff --git a/src/Directives/BuiltIn/OneOf.php b/src/Directives/BuiltIn/OneOf.php index e136aadef..0407bb31c 100644 --- a/src/Directives/BuiltIn/OneOf.php +++ b/src/Directives/BuiltIn/OneOf.php @@ -14,13 +14,12 @@ use TheCodingMachine\GraphQLite\Types\MutableInputObjectType; /** - * Binds PHP code to GraphQL's built-in `@oneOf` directive (defined by webonyx). Applying - * `#[OneOf]` to an `#[Input]` class flips the resulting input object's `isOneOf` flag, which both - * tightens validation (exactly one field must be supplied) and makes webonyx's schema printer emit - * `@oneOf` on the SDL element automatically. + * Binds `#[OneOf]` to webonyx's built-in `@oneOf` directive. Putting it on an `#[Input]` class sets + * the input object's `isOneOf` flag, which makes validation require one field and gets webonyx to + * print `@oneOf` in the SDL. * - * The directive itself does not need a custom schema-level registration — webonyx already provides - * the canonical definition — so {@see DirectiveDefinition::$builtIn} is `true`. + * webonyx already defines `@oneOf`, so we don't register our own definition for it + * ({@see DirectiveDefinition::$builtIn} is `true`). */ #[Attribute(Attribute::TARGET_CLASS)] final class OneOf implements BehavioralInputObjectTypeDirective diff --git a/src/Directives/DirectiveAstBuilder.php b/src/Directives/DirectiveAstBuilder.php index 558ae0af9..84d828ffb 100644 --- a/src/Directives/DirectiveAstBuilder.php +++ b/src/Directives/DirectiveAstBuilder.php @@ -17,13 +17,11 @@ use ReflectionClass; /** - * Builds the AST nodes that make directive applications visible in printed SDL. + * Builds the AST nodes that let directive applications show up in printed SDL. * - * webonyx renders directive applications via `astNode->directives` on the parent definition node - * (FieldDefinitionNode, InputValueDefinitionNode, ObjectTypeDefinitionNode, - * InputObjectTypeDefinitionNode). GraphQLite does not populate `astNode` anywhere else today, so - * this builder constructs the minimal node graph required for SDL output to include each applied - * directive, with its arguments encoded via {@see AST::astFromValue}. + * webonyx reads applications from `astNode->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 */ diff --git a/src/Directives/DirectiveDefinition.php b/src/Directives/DirectiveDefinition.php index 41d1a72ca..baf141a76 100644 --- a/src/Directives/DirectiveDefinition.php +++ b/src/Directives/DirectiveDefinition.php @@ -5,20 +5,15 @@ namespace TheCodingMachine\GraphQLite\Directives; /** - * The declarative metadata for a custom directive — name, valid locations, whether it can be - * repeated, and an optional human-readable description. + * Metadata for a directive: name, valid locations, whether it's repeatable, and an optional + * description. Returned by {@see DirectiveInterface::definition()}. * - * Returned by a directive class's static {@see DirectiveInterface::definition()} method. This - * object is deliberately binding-agnostic: it does not know whether the directive comes from a - * PHP attribute (type-system case, this branch) or from a runtime handler (executable case, - * future work). Argument types are not declared here — they are reflected from the directive - * class's constructor signature when the directive is registered with the schema. + * Argument types aren't listed here; they're read from the directive class's constructor when it's + * registered. * - * Set {@see $builtIn} to true for attributes that bind PHP behavior to a directive already - * provided by webonyx (`@skip`, `@include`, `@deprecated`, `@oneOf`). Built-in directives still - * run their apply hook, but the registry does not contribute a duplicate definition to - * {@see \GraphQL\Type\SchemaConfig::$directives} — webonyx is already the source of truth for - * their schema-level declaration. + * Set {@see $builtIn} to true when the attribute binds behavior to a directive webonyx already + * defines (`@oneOf`, `@deprecated`, ...). Those still run their apply hook, but we don't register a + * second definition for them since webonyx already declares them on the schema. */ final class DirectiveDefinition { diff --git a/src/Directives/DirectiveInterface.php b/src/Directives/DirectiveInterface.php index 1aa24c313..39a2e87a6 100644 --- a/src/Directives/DirectiveInterface.php +++ b/src/Directives/DirectiveInterface.php @@ -5,9 +5,8 @@ namespace TheCodingMachine\GraphQLite\Directives; /** - * Root marker for every GraphQLite custom directive — both type-system directives (this branch) - * and executable directives (future). The single contract is the static {@see definition} method - * that returns the directive's declarative metadata. + * Base marker for any GraphQLite custom directive. The one requirement is the static + * {@see definition} method returning the directive's metadata. */ interface DirectiveInterface { diff --git a/src/Directives/DirectiveLocation.php b/src/Directives/DirectiveLocation.php index 37b6b57d4..abb251f38 100644 --- a/src/Directives/DirectiveLocation.php +++ b/src/Directives/DirectiveLocation.php @@ -5,15 +5,11 @@ namespace TheCodingMachine\GraphQLite\Directives; /** - * GraphQL directive locations as defined in the spec. + * GraphQL directive locations from the spec, both type-system and executable. * - * Both type-system locations (used to annotate the schema) and executable locations (used inside - * client queries) are listed. Only the four type-system locations FIELD_DEFINITION, - * INPUT_FIELD_DEFINITION, OBJECT and INPUT_OBJECT have an apply hook in the current branch — the - * rest are reserved so future work can plug in without an enum extension. - * - * Backing values match the spec strings exactly, so they round-trip through SDL and webonyx's - * directive-location strings without conversion. + * Only FIELD_DEFINITION, INPUT_FIELD_DEFINITION, OBJECT and INPUT_OBJECT have apply hooks so far; + * the rest are listed but not yet wired. Backing values match the spec strings, so they line up + * with webonyx's location strings without conversion. */ enum DirectiveLocation: string { diff --git a/src/Directives/DirectiveRegistry.php b/src/Directives/DirectiveRegistry.php index 167ee3a8f..2bedc3892 100644 --- a/src/Directives/DirectiveRegistry.php +++ b/src/Directives/DirectiveRegistry.php @@ -17,15 +17,13 @@ use function method_exists; /** - * The single source of truth for custom directives in a schema. Built once during - * {@see \TheCodingMachine\GraphQLite\SchemaFactory::createSchema()} by discovering directive - * classes via {@see DirectiveClassFinder}, validating them with {@see DirectiveValidator}, and - * caching the resolved {@see DirectiveDefinition}, the constructor-argument shape, and the - * webonyx-side {@see WebonyxDirective} per class. + * Holds the custom directives for a schema. Built once in + * {@see \TheCodingMachine\GraphQLite\SchemaFactory::createSchema()}: it discovers directive classes + * via {@see DirectiveClassFinder}, validates them with {@see DirectiveValidator}, and caches the + * {@see DirectiveDefinition}, argument shape, and webonyx {@see WebonyxDirective} for each. * - * The registry is also consulted at apply time by the dispatcher middlewares to look up an - * argument shape from a directive instance's class (so the AST node builder knows how to encode - * each arg as a GraphQL value). + * The dispatcher middlewares also query it at apply time to get a directive's argument shape, which + * the AST builder needs to encode each arg as a GraphQL value. */ final class DirectiveRegistry { @@ -63,8 +61,8 @@ public function __construct( /** Run discovery + validation once. Idempotent. */ public function discover(): void { - // User classes first so a user-supplied override of a built-in (a class with `builtIn: - // true` and the same name) lands before our bundled copy gets a chance to register. + // User classes first: an override of a built-in (same name, builtIn: true) needs to land + // before our bundled copy registers. foreach ($this->classFinder->findDirectives() as $directiveClass) { $this->register($directiveClass); } @@ -80,8 +78,8 @@ public function discover(): void */ private function register(string $directiveClass): void { - // Same FQCN registered twice is a no-op — discovery, the built-in list, and user code all - // share the registry, so collisions on the same class are expected. + // Registering the same class twice is a no-op; discovery and the built-in list share this + // registry, so duplicates are expected. if (isset($this->definitionsByClass[$directiveClass])) { return; } @@ -100,9 +98,8 @@ private function register(string $directiveClass): void } if (array_key_exists($definition->name, $this->classByName)) { - // A name clash with a built-in is allowed when this side is also a built-in: it means - // the user has supplied their own implementation and we defer. Two non-built-ins with - // the same name remain an error. + // A name clash is fine when this side is also built-in: the user supplied their own + // implementation and we defer to it. Two custom directives sharing a name is an error. if ($definition->builtIn) { return; } @@ -117,8 +114,8 @@ private function register(string $directiveClass): void $this->argumentsByClass[$directiveClass] = $arguments; $this->classByName[$definition->name] = $directiveClass; - // Built-in directives are declared by webonyx itself — don't contribute a duplicate - // definition to SchemaConfig::$directives. + // webonyx already declares built-in directives, so don't add a duplicate definition to + // SchemaConfig::$directives. if ($definition->builtIn) { return; } @@ -166,8 +163,7 @@ public function webonyxDirectives(): array } /** - * Look up the argument shape for a directive class. Accepts any {@see DirectiveInterface} class - * so it composes with future executable-directive lookups. + * The argument shape for a directive class. * * @param class-string $directiveClass * @@ -179,8 +175,7 @@ public function argumentsFor(string $directiveClass): array } /** - * Look up the declarative metadata for a directive class. Accepts any - * {@see DirectiveInterface} class for the same reason as {@see argumentsFor}. + * The metadata for a directive class, or null if it isn't registered. * * @param class-string $directiveClass */ diff --git a/src/Directives/DirectiveValidator.php b/src/Directives/DirectiveValidator.php index 5fa395246..619c1c9bf 100644 --- a/src/Directives/DirectiveValidator.php +++ b/src/Directives/DirectiveValidator.php @@ -15,18 +15,16 @@ use function in_array; /** - * Validates a discovered directive class against the four design-time rules and resolves its - * constructor signature into a list of {@see ResolvedDirectiveArgument}s. + * Validates a discovered directive class and resolves its constructor into a list of + * {@see ResolvedDirectiveArgument}s. Checks: * - * Rules: - * 1. The class declares a `#[Attribute(...)]` PHP attribute whose target is a superset of every - * declared GraphQL location. - * 2. PHP `IS_REPEATABLE` agrees with `DirectiveDefinition::$repeatable`. - * 3. Every implemented family interface has its corresponding location declared (and vice versa). - * 4. Every constructor parameter resolves to a supported GraphQL input type (scalars only in v1). + * 1. The `#[Attribute(...)]` PHP target covers every declared GraphQL location. + * 2. PHP `IS_REPEATABLE` matches `DirectiveDefinition::$repeatable`. + * 3. Each family interface has a matching location declared, and vice versa. + * 4. Every constructor parameter maps to a supported input type (scalars only for now). * - * A fifth check — name uniqueness vs. webonyx built-ins and vs. other custom directives — lives on - * {@see DirectiveRegistry::add()} so it can compare against the in-progress registry. + * Name uniqueness is checked separately in {@see DirectiveRegistry}, which has the full set to + * compare against. * * @internal */ @@ -186,8 +184,7 @@ private static function requiredPhpTargetsFor(DirectiveLocation $location): arra ], DirectiveLocation::OBJECT => [Attribute::TARGET_CLASS => 'TARGET_CLASS'], DirectiveLocation::INPUT_OBJECT => [Attribute::TARGET_CLASS => 'TARGET_CLASS'], - // Deferred locations have no enforced PHP target here yet — they'll be added when their - // apply hooks are wired. + // Other locations don't have apply hooks yet, so there's nothing to enforce. default => [], }; } diff --git a/src/Directives/Discovery/DirectiveClassFinder.php b/src/Directives/Discovery/DirectiveClassFinder.php index 585b93d11..d0f9d0958 100644 --- a/src/Directives/Discovery/DirectiveClassFinder.php +++ b/src/Directives/Discovery/DirectiveClassFinder.php @@ -12,12 +12,11 @@ use function array_reduce; /** - * Locates every class in the configured namespaces that implements {@see TypeSystemDirective}. + * Finds the classes in the configured namespaces that implement {@see TypeSystemDirective}. * - * Mirrors the discovery pattern used by {@see \TheCodingMachine\GraphQLite\Mappers\ClassFinderTypeMapper}: - * map each class found by {@see ClassFinder} to either a {@see GlobDirectivesCache} entry or null, - * then reduce the per-file entries into a flat list of directive FQCNs. The - * {@see ClassFinderComputedCache} handles invalidation by file change in dev mode. + * Same pattern as {@see \TheCodingMachine\GraphQLite\Mappers\ClassFinderTypeMapper}: map each class + * from {@see ClassFinder} to a {@see GlobDirectivesCache} entry (or null), then collect the FQCNs. + * {@see ClassFinderComputedCache} handles cache invalidation in dev mode. * * @internal */ diff --git a/src/Directives/Discovery/GlobDirectivesCache.php b/src/Directives/Discovery/GlobDirectivesCache.php index 6f85bb05d..d587eaba1 100644 --- a/src/Directives/Discovery/GlobDirectivesCache.php +++ b/src/Directives/Discovery/GlobDirectivesCache.php @@ -7,11 +7,8 @@ use TheCodingMachine\GraphQLite\Directives\TypeSystemDirective; /** - * Cache entry for a single file produced by {@see DirectiveClassFinder}. Holds the FQCN of the - * directive class found there (or null when the file contained no directive class). - * - * The {@see \TheCodingMachine\GraphQLite\Discovery\Cache\ClassFinderComputedCache} dedupes entries - * by filename and invalidates them when files change in dev mode. + * Cache entry for one file scanned by {@see DirectiveClassFinder}, holding the FQCN of the directive + * class it found. Files with no directive class get no entry. * * @internal */ diff --git a/src/Directives/Exceptions/InvalidDirectiveException.php b/src/Directives/Exceptions/InvalidDirectiveException.php index fb9c188cf..ea198f0d9 100644 --- a/src/Directives/Exceptions/InvalidDirectiveException.php +++ b/src/Directives/Exceptions/InvalidDirectiveException.php @@ -12,9 +12,8 @@ use function sprintf; /** - * Thrown at schema build time when a custom directive declaration violates one of the validator's - * rules: target/location mismatch, repeatable parity, interface/location agreement, unmappable - * argument types, or a directive-name collision. + * Thrown at schema build time when a directive declaration is invalid, e.g. a bad PHP target, + * repeatable mismatch, missing interface/location, an unsupported argument type, or a name clash. */ final class InvalidDirectiveException extends GraphQLRuntimeException { diff --git a/src/Directives/FieldDirective.php b/src/Directives/FieldDirective.php index feded484f..14126d5f9 100644 --- a/src/Directives/FieldDirective.php +++ b/src/Directives/FieldDirective.php @@ -7,15 +7,13 @@ use TheCodingMachine\GraphQLite\Annotations\MiddlewareAnnotationInterface; /** - * Marker contract for a custom directive that decorates a field, query, mutation or subscription - * (GraphQL `FIELD_DEFINITION` location). + * Marker for a directive on a field, query, mutation or subscription (`FIELD_DEFINITION` location). * - * Implementing this interface alone registers the directive with the schema and renders it on any - * field it's applied to, but adds no PHP behavior — pure metadata. To wrap the resolver or - * otherwise act on the field, implement {@see BehavioralFieldDirective} instead. + * This alone registers the directive and prints it on the field, but adds no behavior. Implement + * {@see BehavioralFieldDirective} to wrap the resolver or otherwise act on the field. * - * Because the interface extends {@see MiddlewareAnnotationInterface}, instances are collected by - * the existing {@see \TheCodingMachine\GraphQLite\AnnotationReader::getMiddlewareAnnotations()}. + * It extends {@see MiddlewareAnnotationInterface}, so instances are picked up by + * {@see \TheCodingMachine\GraphQLite\AnnotationReader::getMiddlewareAnnotations()}. */ interface FieldDirective extends TypeSystemDirective, MiddlewareAnnotationInterface { diff --git a/src/Directives/InputFieldDirective.php b/src/Directives/InputFieldDirective.php index 8173eebd9..26a20a7c2 100644 --- a/src/Directives/InputFieldDirective.php +++ b/src/Directives/InputFieldDirective.php @@ -7,9 +7,8 @@ use TheCodingMachine\GraphQLite\Annotations\MiddlewareAnnotationInterface; /** - * Marker contract for a custom directive that decorates an input object field (GraphQL - * `INPUT_FIELD_DEFINITION` location). Pure metadata — implement {@see BehavioralInputFieldDirective} - * to act on the input field at build time. + * Marker for a directive on an input object field (`INPUT_FIELD_DEFINITION` location). Metadata + * only; implement {@see BehavioralInputFieldDirective} to act on the input field at build time. */ interface InputFieldDirective extends TypeSystemDirective, MiddlewareAnnotationInterface { diff --git a/src/Directives/InputObjectTypeDirective.php b/src/Directives/InputObjectTypeDirective.php index c9e5e1e78..637a368ed 100644 --- a/src/Directives/InputObjectTypeDirective.php +++ b/src/Directives/InputObjectTypeDirective.php @@ -5,9 +5,9 @@ namespace TheCodingMachine\GraphQLite\Directives; /** - * Marker contract for a custom directive that decorates an input object type (GraphQL - * `INPUT_OBJECT` location). Pure metadata — implement {@see BehavioralInputObjectTypeDirective} to - * mutate the built input type (e.g. flip its `isOneOf` flag). + * Marker for a directive on an input object type (`INPUT_OBJECT` location). Metadata only; + * implement {@see BehavioralInputObjectTypeDirective} to change the built input type (e.g. its + * `isOneOf` flag). */ interface InputObjectTypeDirective extends TypeSystemDirective { diff --git a/src/Directives/ObjectTypeDirective.php b/src/Directives/ObjectTypeDirective.php index 5960bfae9..d9ca7ade4 100644 --- a/src/Directives/ObjectTypeDirective.php +++ b/src/Directives/ObjectTypeDirective.php @@ -5,9 +5,8 @@ namespace TheCodingMachine\GraphQLite\Directives; /** - * Marker contract for a custom directive that decorates an object type (GraphQL `OBJECT` - * location). Pure metadata — implement {@see BehavioralObjectTypeDirective} to mutate or wrap the - * type at build time. + * Marker for a directive on an object type (`OBJECT` location). This alone is metadata only; + * implement {@see BehavioralObjectTypeDirective} to act on the type at build time. */ interface ObjectTypeDirective extends TypeSystemDirective { diff --git a/src/Directives/ResolvedDirectiveArgument.php b/src/Directives/ResolvedDirectiveArgument.php index 2d218f1e2..9113e72fa 100644 --- a/src/Directives/ResolvedDirectiveArgument.php +++ b/src/Directives/ResolvedDirectiveArgument.php @@ -8,12 +8,11 @@ use GraphQL\Type\Definition\Type; /** - * A single directive argument resolved from a directive class's constructor parameter. + * One directive argument, resolved from a constructor parameter of the directive class. The + * parameter name doubles as the GraphQL argument name. * - * Stores the GraphQL input type, the PHP parameter name (which is also the GraphQL argument name), - * an optional description, and the default value (if any). The {@see DirectiveRegistry} builds one - * of these per constructor parameter at validation time, then uses the list to construct the - * webonyx {@see \GraphQL\Type\Definition\Directive} that's registered with the schema. + * {@see DirectiveRegistry} builds one per parameter and uses the list to construct the webonyx + * {@see \GraphQL\Type\Definition\Directive}. * * @internal */ diff --git a/src/Directives/TypeSystemDirective.php b/src/Directives/TypeSystemDirective.php index 64d1ffc15..02819de62 100644 --- a/src/Directives/TypeSystemDirective.php +++ b/src/Directives/TypeSystemDirective.php @@ -5,10 +5,10 @@ namespace TheCodingMachine\GraphQLite\Directives; /** - * Marker for directives that decorate the schema (as opposed to executable directives that decorate - * client queries). All four family interfaces — {@see FieldDirective}, {@see InputFieldDirective}, - * {@see ObjectTypeDirective}, {@see InputObjectTypeDirective} — extend this marker so discovery can - * find every type-system directive with a single interface check. + * Marker for directives that annotate the schema, rather than executable directives used in client + * queries. The four family interfaces ({@see FieldDirective}, {@see InputFieldDirective}, + * {@see ObjectTypeDirective}, {@see InputObjectTypeDirective}) extend it so discovery can find them + * with one interface check. */ interface TypeSystemDirective extends DirectiveInterface { diff --git a/src/InputObjectTypeDescriptor.php b/src/InputObjectTypeDescriptor.php index a50fc448a..3b5baa15e 100644 --- a/src/InputObjectTypeDescriptor.php +++ b/src/InputObjectTypeDescriptor.php @@ -10,14 +10,12 @@ use TheCodingMachine\GraphQLite\Utils\Cloneable; /** - * Carries an in-progress {@see MutableInputObjectType} through the - * {@see Middlewares\InputObjectTypeMiddlewarePipe} so {@see Directives\InputObjectTypeDirective}s - * (and any future input-object middleware) can inspect and decorate the type before it is - * registered. + * Carries a {@see MutableInputObjectType} through the + * {@see Middlewares\InputObjectTypeMiddlewarePipe} so {@see Directives\InputObjectTypeDirective}s can + * decorate the type before it's registered. * - * Both `#[Input]`-driven (`InputType`) and `#[Factory]`-driven - * (`ResolvableMutableInputObjectType`) types extend `MutableInputObjectType`, so the descriptor - * accepts either. + * Both `#[Input]` types (`InputType`) and `#[Factory]` types (`ResolvableMutableInputObjectType`) + * extend `MutableInputObjectType`, so either works here. */ class InputObjectTypeDescriptor { diff --git a/src/Middlewares/DirectiveFieldMiddleware.php b/src/Middlewares/DirectiveFieldMiddleware.php index 2b0106340..cb6f86f60 100644 --- a/src/Middlewares/DirectiveFieldMiddleware.php +++ b/src/Middlewares/DirectiveFieldMiddleware.php @@ -15,10 +15,9 @@ use function array_values; /** - * Dispatches every {@see FieldDirective} attached to a field. Only the subset that also implements - * {@see BehavioralFieldDirective} runs its `applyToField` hook as a sub-chain leading into the - * outer pipe's `$next`; pure-metadata directives are skipped at apply time but still contribute - * their `astNode` so the directive appears in SDL output. + * Dispatches the {@see FieldDirective}s on a field. The ones implementing + * {@see BehavioralFieldDirective} run their `applyToField` hook; metadata-only directives don't run + * anything but still get their `astNode` so they show up in SDL. * * @internal */ diff --git a/src/Middlewares/DirectiveInputFieldMiddleware.php b/src/Middlewares/DirectiveInputFieldMiddleware.php index e2c039000..4ff9217ea 100644 --- a/src/Middlewares/DirectiveInputFieldMiddleware.php +++ b/src/Middlewares/DirectiveInputFieldMiddleware.php @@ -15,10 +15,9 @@ use function array_values; /** - * Dispatches every {@see InputFieldDirective} attached to an input field. Only directives that - * also implement {@see BehavioralInputFieldDirective} run their `applyToInputField` hook; - * pure-metadata directives still contribute their `astNode` so SDL output reflects every - * application. + * Dispatches the {@see InputFieldDirective}s on an input field. The ones implementing + * {@see BehavioralInputFieldDirective} run their `applyToInputField` hook; metadata-only directives + * still get their `astNode` so they show up in SDL. * * @internal */ diff --git a/src/Middlewares/DirectiveInputObjectTypeMiddleware.php b/src/Middlewares/DirectiveInputObjectTypeMiddleware.php index c3ede8baf..c72afb90c 100644 --- a/src/Middlewares/DirectiveInputObjectTypeMiddleware.php +++ b/src/Middlewares/DirectiveInputObjectTypeMiddleware.php @@ -14,10 +14,9 @@ use function array_values; /** - * Dispatches every {@see \TheCodingMachine\GraphQLite\Directives\InputObjectTypeDirective} declared - * on a class with `#[Input]` (or via `#[Factory]`). Only the subset implementing - * {@see BehavioralInputObjectTypeDirective} runs its `applyToInputObjectType` hook; metadata-only - * directives still get an `astNode` for SDL emission. + * Dispatches the {@see \TheCodingMachine\GraphQLite\Directives\InputObjectTypeDirective}s on an + * `#[Input]` (or `#[Factory]`) class. The ones implementing {@see BehavioralInputObjectTypeDirective} + * run their `applyToInputObjectType` hook; metadata-only directives still get an `astNode` for SDL. * * @internal */ diff --git a/src/Middlewares/DirectiveObjectTypeMiddleware.php b/src/Middlewares/DirectiveObjectTypeMiddleware.php index 51a164627..06bcd8c11 100644 --- a/src/Middlewares/DirectiveObjectTypeMiddleware.php +++ b/src/Middlewares/DirectiveObjectTypeMiddleware.php @@ -14,9 +14,9 @@ use function array_values; /** - * Dispatches every {@see \TheCodingMachine\GraphQLite\Directives\ObjectTypeDirective} declared on a - * class with `#[Type]`. Only the subset implementing {@see BehavioralObjectTypeDirective} runs its - * `applyToObjectType` hook; metadata-only directives still get an `astNode` for SDL emission. + * Dispatches the {@see \TheCodingMachine\GraphQLite\Directives\ObjectTypeDirective}s on a `#[Type]` + * class. The ones implementing {@see BehavioralObjectTypeDirective} run their `applyToObjectType` + * hook; metadata-only directives still get an `astNode` for SDL. * * @internal */ diff --git a/src/Middlewares/InputObjectTypeMiddlewareInterface.php b/src/Middlewares/InputObjectTypeMiddlewareInterface.php index 4b4b599e8..3d252f674 100644 --- a/src/Middlewares/InputObjectTypeMiddlewareInterface.php +++ b/src/Middlewares/InputObjectTypeMiddlewareInterface.php @@ -8,9 +8,9 @@ use TheCodingMachine\GraphQLite\Types\MutableInputObjectType; /** - * A middleware in the {@see InputObjectTypeMiddlewarePipe} used to decorate - * {@see MutableInputObjectType} instances right after construction. Covers both `#[Input]` classes - * and `#[Factory]`-produced types. + * A middleware in the {@see InputObjectTypeMiddlewarePipe} that decorates + * {@see MutableInputObjectType} instances right after they're built (both `#[Input]` and + * `#[Factory]` types). * * @unstable See https://graphqlite.thecodingmachine.io/docs/semver.html */ diff --git a/src/ObjectTypeDescriptor.php b/src/ObjectTypeDescriptor.php index 4c5546584..79bdf38ae 100644 --- a/src/ObjectTypeDescriptor.php +++ b/src/ObjectTypeDescriptor.php @@ -10,9 +10,8 @@ use TheCodingMachine\GraphQLite\Utils\Cloneable; /** - * Carries an in-progress {@see MutableObjectType} through the {@see Middlewares\ObjectTypeMiddlewarePipe} - * so {@see Directives\ObjectTypeDirective}s (and any future object-type middleware) can inspect and - * decorate the type before it is registered. + * Carries a {@see MutableObjectType} through the {@see Middlewares\ObjectTypeMiddlewarePipe} so + * {@see Directives\ObjectTypeDirective}s can decorate the type before it's registered. */ class ObjectTypeDescriptor { diff --git a/src/Schema.php b/src/Schema.php index 061788392..3b30354a8 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -20,8 +20,8 @@ class Schema extends \GraphQL\Type\Schema { /** - * @param array $directives Custom (non-built-in) directive definitions to expose in - * the schema, alongside webonyx's standard directives. + * @param array $directives Custom directive definitions to add alongside webonyx's + * standard ones. */ public function __construct( QueryProviderInterface $queryProvider, @@ -119,9 +119,9 @@ public function __construct( return $rootTypeMapper->mapNameToType($name); }); - // Expose custom directive definitions (introspection + SDL). webonyx replaces the standard - // directive set the moment `directives` is non-null, so merge onto the existing list — - // the user's own config directives if they supplied any, otherwise webonyx's standard set. + // 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()), diff --git a/tests/Directives/BuiltInDirectivesTest.php b/tests/Directives/BuiltInDirectivesTest.php index 911cff95a..685ece3f6 100644 --- a/tests/Directives/BuiltInDirectivesTest.php +++ b/tests/Directives/BuiltInDirectivesTest.php @@ -31,8 +31,7 @@ public function testBuiltInDirectivesAreNotEmittedAsCustomDirectives(): void $registry = self::buildRegistry([]); $registry->discover(); - // The registry must not contribute `@oneOf` to SchemaConfig::$directives — webonyx already - // declares it as a built-in. Custom directive list stays empty. + // @oneOf is a webonyx built-in, so the registry shouldn't add it to the custom list. $names = array_map(static fn (WebonyxDirective $d) => $d->name, $registry->webonyxDirectives()); $this->assertSame([], $names); @@ -50,9 +49,8 @@ public function testOneOfDefinitionMarksItselfAsBuiltIn(): void public function testDiscoveryFindingTheBundledBuiltInClassIsIdempotent(): void { - // Simulate a user namespace that happens to include our built-in attributes — the - // class-finder yields the same FQCN that `BUILT_IN_ATTRIBUTES` registers. Registration - // must not throw on the duplicate-class case. + // If discovery finds our own built-in class (same FQCN as BUILT_IN_ATTRIBUTES), registering + // it twice shouldn't throw. $registry = self::buildRegistry([OneOf::class]); $registry->discover(); @@ -61,8 +59,7 @@ public function testDiscoveryFindingTheBundledBuiltInClassIsIdempotent(): void public function testUserOverrideOfBuiltInWinsOverBundled(): void { - // A user supplies their own class binding to `@oneOf` (with `builtIn: true`). Our bundled - // copy must defer so the user's behavior is the one that runs. + // User binds their own class to @oneOf (builtIn: true); ours should defer to it. $registry = self::buildRegistry([CustomOneOfOverride::class]); $registry->discover(); diff --git a/tests/Directives/DirectiveLocationTest.php b/tests/Directives/DirectiveLocationTest.php index e217acd84..bccc7f239 100644 --- a/tests/Directives/DirectiveLocationTest.php +++ b/tests/Directives/DirectiveLocationTest.php @@ -43,7 +43,7 @@ public function testIsTypeSystemClassifiesSchemaLocations(): void public function testIsExecutableAndIsTypeSystemArePartitions(): void { foreach (DirectiveLocation::cases() as $location) { - $this->assertNotSame($location->isExecutable(), $location->isTypeSystem(), "Location {$location->value} should be exactly one of executable/type-system."); + $this->assertNotSame($location->isExecutable(), $location->isTypeSystem(), "Location {$location->value} should be executable or type-system, not both."); } } } diff --git a/tests/Directives/DirectiveRegistryTest.php b/tests/Directives/DirectiveRegistryTest.php index f52fbe26f..15c0cd0ff 100644 --- a/tests/Directives/DirectiveRegistryTest.php +++ b/tests/Directives/DirectiveRegistryTest.php @@ -70,8 +70,8 @@ public function testEmptyRegistryReportsNoDirectives(): void public function testWebonyxDirectivesIsolatedFromBuiltins(): void { - // Sanity-check that the directives we built do NOT include webonyx's built-ins — they are - // merged in at the Schema layer, not here. + // The built directives shouldn't include webonyx's built-ins; those get merged in at the + // Schema layer, not here. $registry = self::buildRegistry([UppercaseFieldDirective::class]); $registry->discover(); diff --git a/tests/Fixtures/Directives/CustomOneOfOverride.php b/tests/Fixtures/Directives/CustomOneOfOverride.php index 214a1c390..3cbece707 100644 --- a/tests/Fixtures/Directives/CustomOneOfOverride.php +++ b/tests/Fixtures/Directives/CustomOneOfOverride.php @@ -13,9 +13,8 @@ use TheCodingMachine\GraphQLite\Types\MutableInputObjectType; /** - * Simulates a user-supplied replacement for the bundled `OneOf` built-in attribute. Claims the - * same `@oneOf` name and marks itself as `builtIn: true` so the registry treats it as the active - * binder for that directive name. + * Stands in for a user-supplied replacement of the bundled `OneOf`. Same `@oneOf` name, marked + * `builtIn: true`, so the registry uses it instead of ours. */ #[Attribute(Attribute::TARGET_CLASS)] final class CustomOneOfOverride implements BehavioralInputObjectTypeDirective diff --git a/tests/Fixtures/Directives/Invalid/InterfaceWithoutLocationDirective.php b/tests/Fixtures/Directives/Invalid/InterfaceWithoutLocationDirective.php index fb96826ad..0eb38163c 100644 --- a/tests/Fixtures/Directives/Invalid/InterfaceWithoutLocationDirective.php +++ b/tests/Fixtures/Directives/Invalid/InterfaceWithoutLocationDirective.php @@ -9,9 +9,8 @@ use TheCodingMachine\GraphQLite\Directives\FieldDirective; /** - * Implements FieldDirective but declares no FIELD_DEFINITION location — should be rejected by the - * validator's interface/location agreement rule. The empty locations list avoids tripping the PHP - * target rule first. + * Implements FieldDirective but declares no FIELD_DEFINITION location, so the validator's + * interface/location check rejects it. Empty locations keeps the PHP-target check from firing first. */ #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_PROPERTY)] final class InterfaceWithoutLocationDirective implements FieldDirective diff --git a/tests/Fixtures/Directives/Invalid/MissingPhpTargetDirective.php b/tests/Fixtures/Directives/Invalid/MissingPhpTargetDirective.php index eabff78d2..85eb29a78 100644 --- a/tests/Fixtures/Directives/Invalid/MissingPhpTargetDirective.php +++ b/tests/Fixtures/Directives/Invalid/MissingPhpTargetDirective.php @@ -10,8 +10,8 @@ use TheCodingMachine\GraphQLite\Directives\FieldDirective; /** - * Declares FIELD_DEFINITION but PHP target is TARGET_CLASS only — should be rejected by the - * validator's "PHP target ⊇ GraphQL locations" rule. + * Declares FIELD_DEFINITION but the PHP target is TARGET_CLASS only, so the validator rejects it: + * the PHP target has to cover the GraphQL locations. */ #[Attribute(Attribute::TARGET_CLASS)] final class MissingPhpTargetDirective implements FieldDirective diff --git a/tests/Fixtures/Directives/Invalid/RepeatableMismatchDirective.php b/tests/Fixtures/Directives/Invalid/RepeatableMismatchDirective.php index 2afd0b773..cfab3f210 100644 --- a/tests/Fixtures/Directives/Invalid/RepeatableMismatchDirective.php +++ b/tests/Fixtures/Directives/Invalid/RepeatableMismatchDirective.php @@ -10,8 +10,8 @@ use TheCodingMachine\GraphQLite\Directives\FieldDirective; /** - * PHP IS_REPEATABLE is set but DirectiveDefinition::$repeatable is false — should be rejected by - * the validator's repeatable-parity rule. + * PHP IS_REPEATABLE is set but DirectiveDefinition::$repeatable is false, so the validator rejects + * the mismatch. */ #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] final class RepeatableMismatchDirective implements FieldDirective diff --git a/tests/Fixtures/Directives/Invalid/ReservedNameDirective.php b/tests/Fixtures/Directives/Invalid/ReservedNameDirective.php index b50a92bcf..2c587ba70 100644 --- a/tests/Fixtures/Directives/Invalid/ReservedNameDirective.php +++ b/tests/Fixtures/Directives/Invalid/ReservedNameDirective.php @@ -10,8 +10,7 @@ use TheCodingMachine\GraphQLite\Directives\InputObjectTypeDirective; /** - * Reuses webonyx's built-in `@oneOf` directive name — should be rejected by the registry's - * reserved-name check. + * Reuses webonyx's built-in `@oneOf` name, so the registry's reserved-name check rejects it. */ #[Attribute(Attribute::TARGET_CLASS)] final class ReservedNameDirective implements InputObjectTypeDirective diff --git a/tests/Fixtures/Directives/Invalid/UnsupportedArgumentTypeDirective.php b/tests/Fixtures/Directives/Invalid/UnsupportedArgumentTypeDirective.php index 1c8538d9b..0e319974e 100644 --- a/tests/Fixtures/Directives/Invalid/UnsupportedArgumentTypeDirective.php +++ b/tests/Fixtures/Directives/Invalid/UnsupportedArgumentTypeDirective.php @@ -11,8 +11,8 @@ use TheCodingMachine\GraphQLite\Directives\FieldDirective; /** - * Constructor parameter is a non-scalar object — should be rejected by the validator's mappable-arg - * rule. + * Constructor parameter is a non-scalar object, which the validator rejects (args must map to a + * scalar). */ #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_PROPERTY)] final class UnsupportedArgumentTypeDirective implements FieldDirective diff --git a/tests/Fixtures/Directives/VersionedInputObjectDirective.php b/tests/Fixtures/Directives/VersionedInputObjectDirective.php index aeb27e870..35c2b3e4b 100644 --- a/tests/Fixtures/Directives/VersionedInputObjectDirective.php +++ b/tests/Fixtures/Directives/VersionedInputObjectDirective.php @@ -10,9 +10,8 @@ use TheCodingMachine\GraphQLite\Directives\InputObjectTypeDirective; /** - * Demonstrates a custom (non-built-in) input-object directive carrying a constructor argument. - * Used in tests to exercise the full custom-directive pipeline for `INPUT_OBJECT`: schema-level - * declaration, SDL application rendering, and argument encoding. Pure metadata — no apply method. + * A custom input-object directive with a constructor argument. Metadata only (no apply method), + * used to exercise the `INPUT_OBJECT` path: definition, argument encoding, and SDL output. */ #[Attribute(Attribute::TARGET_CLASS)] final class VersionedInputObjectDirective implements InputObjectTypeDirective diff --git a/tests/Fixtures/DirectivesIntegration/Directives/VersionedDirective.php b/tests/Fixtures/DirectivesIntegration/Directives/VersionedDirective.php index 7a0a86326..d6ba8affe 100644 --- a/tests/Fixtures/DirectivesIntegration/Directives/VersionedDirective.php +++ b/tests/Fixtures/DirectivesIntegration/Directives/VersionedDirective.php @@ -10,10 +10,8 @@ use TheCodingMachine\GraphQLite\Directives\InputObjectTypeDirective; /** - * Marks an input object with a schema version. A representative custom (non-built-in) input-object - * directive: it goes through the full pipeline (declared in SDL, applied with rendered args, - * registered for introspection) and demonstrates that the custom path runs alongside the bundled - * `#[OneOf]` built-in. + * Marks an input object with a schema version. A custom input-object directive used in the + * integration test to check the custom path runs alongside the bundled `#[OneOf]`. */ #[Attribute(Attribute::TARGET_CLASS)] final class VersionedDirective implements InputObjectTypeDirective diff --git a/tests/Fixtures/DirectivesIntegration/Inputs/OneOfLookup.php b/tests/Fixtures/DirectivesIntegration/Inputs/OneOfLookup.php index a271842c9..ad6b39986 100644 --- a/tests/Fixtures/DirectivesIntegration/Inputs/OneOfLookup.php +++ b/tests/Fixtures/DirectivesIntegration/Inputs/OneOfLookup.php @@ -9,8 +9,8 @@ use TheCodingMachine\GraphQLite\Directives\BuiltIn\OneOf; /** - * Uses the built-in `@oneOf` directive — flips webonyx's `isOneOf` flag so exactly one of `sku` - * or `id` is required at execution time. + * Uses the built-in `@oneOf` directive, which sets webonyx's `isOneOf` flag so callers pass either + * `sku` or `id`, not both. */ #[Input] #[OneOf] diff --git a/tests/Integration/DirectivesEndToEndTest.php b/tests/Integration/DirectivesEndToEndTest.php index 1b3c42c67..765bd87ee 100644 --- a/tests/Integration/DirectivesEndToEndTest.php +++ b/tests/Integration/DirectivesEndToEndTest.php @@ -19,17 +19,13 @@ use function is_array; /** - * End-to-end verification of custom directive support. Boots a schema via {@see SchemaFactory} - * against the `tests/Fixtures/DirectivesIntegration` namespaces, then asserts: + * End-to-end test for custom directives. Builds a schema from the + * `tests/Fixtures/DirectivesIntegration` namespaces and checks that directive definitions show up + * in SDL and introspection, and that behavioral directives wrap their resolver at runtime. * - * - SDL emits the directive definitions (`directive @uppercase on FIELD_DEFINITION`, ...). - * - Introspection reports each custom directive alongside the webonyx built-ins. - * - A field carrying `@uppercase` actually has its resolver wrapped at runtime. - * - * Directive *applications* (`tagline: String! @uppercase`) are intentionally NOT asserted: GraphQLite - * mirrors webonyx, whose {@see \GraphQL\Utils\SchemaPrinter} prints directive definitions but not - * arbitrary applications. Applications stay attached to each element's `astNode->directives`, so a - * downstream/custom printer can still render them when needed (e.g. Apollo Federation's `@key`). + * We don't assert directive applications in SDL (e.g. `tagline: String! @uppercase`): webonyx's + * SchemaPrinter doesn't render those, and we follow its behavior. The applications are still on each + * element's `astNode->directives` for anyone who wants to print them with their own printer. */ final class DirectivesEndToEndTest extends TestCase { @@ -48,8 +44,8 @@ public function testSchemaPrintsDirectiveDefinitions(): void $schema = $this->buildSchema(); $sdl = SchemaPrinter::doPrint($schema); - // Definitions — the built-in webonyx printer emits these once the directives are registered - // on the schema (see SchemaFactory wiring of DirectiveRegistry::webonyxDirectives()). + // Definitions: the webonyx printer emits these once the directives are registered on the + // schema (see SchemaFactory's wiring of DirectiveRegistry::webonyxDirectives()). $this->assertStringContainsString('directive @uppercase on FIELD_DEFINITION', $sdl); $this->assertStringContainsString('Marks a field for audit-log tracking.', $sdl); $this->assertStringContainsString('directive @audit(reason: String!) repeatable on FIELD_DEFINITION', $sdl); @@ -58,13 +54,11 @@ public function testSchemaPrintsDirectiveDefinitions(): void $this->assertStringContainsString('Marks an input with a schema version for backwards-compat tracking.', $sdl); $this->assertStringContainsString('directive @versioned(version: Int!) on INPUT_OBJECT', $sdl); - // Directive *applications* (`tagline: String! @uppercase`, `@audit(reason: "pii")`, ...) are - // intentionally NOT asserted — webonyx's SchemaPrinter does not render arbitrary directive - // applications, and GraphQLite mirrors that. See the class docblock. + // No assertions on directive applications here; webonyx's SchemaPrinter doesn't render them. + // See the class docblock. - // `#[OneOf]` binds PHP behavior to webonyx's built-in `@oneOf` directive — webonyx prints - // the application from its own `isOneOf` flag, and we must NOT re-declare the directive - // alongside the custom list. + // @oneOf is webonyx's built-in: it prints the application from the isOneOf flag, and we + // don't re-declare it in the custom list. $this->assertStringNotContainsString('directive @oneOf ', $sdl); $this->assertStringContainsString('input OneOfLookupInput @oneOf', $sdl); } @@ -116,7 +110,7 @@ public function testInputObjectFieldsResolveWithDirectiveAttached(): void )->toArray(); $this->assertArrayNotHasKey('errors', $result); - // The UppercaseDirective on getLabel() should uppercase the returned string. + // getLabel() carries @uppercase, so "abc" comes back uppercased. $this->assertSame('ABC', $result['data']['findWidget']['label']); } } From f406648e295a4edae77618e108daecd3770ad262 Mon Sep 17 00:00:00 2001 From: Michael Georgiadis <38563912+michael-georgiadis@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:04:50 +0200 Subject: [PATCH 10/12] Infer repeatable from attribute, extract subunits from registry and validator --- src/Directives/DirectiveDefinition.php | 6 +- src/Directives/DirectiveReflection.php | 31 ++++++ src/Directives/DirectiveRegistry.php | 87 +++++---------- src/Directives/DirectiveValidator.php | 105 ++---------------- .../Exceptions/InvalidDirectiveException.php | 15 +-- src/Directives/ResolvedDirective.php | 25 +++++ tests/Directives/DirectiveDefinitionTest.php | 5 +- tests/Directives/DirectiveValidatorTest.php | 40 ------- .../Directives/AuditFieldDirective.php | 1 - .../Invalid/RepeatableMismatchDirective.php | 27 ----- .../Directives/AuditDirective.php | 1 - 11 files changed, 98 insertions(+), 245 deletions(-) create mode 100644 src/Directives/DirectiveReflection.php create mode 100644 src/Directives/ResolvedDirective.php delete mode 100644 tests/Fixtures/Directives/Invalid/RepeatableMismatchDirective.php diff --git a/src/Directives/DirectiveDefinition.php b/src/Directives/DirectiveDefinition.php index baf141a76..51ee64e17 100644 --- a/src/Directives/DirectiveDefinition.php +++ b/src/Directives/DirectiveDefinition.php @@ -5,8 +5,9 @@ namespace TheCodingMachine\GraphQLite\Directives; /** - * Metadata for a directive: name, valid locations, whether it's repeatable, and an optional - * description. Returned by {@see DirectiveInterface::definition()}. + * Metadata for a directive: name, valid locations, and an optional description. Returned by + * {@see DirectiveInterface::definition()}. Repeatability isn't here; it's read from the directive + * class's `#[Attribute]` flags (see {@see DirectiveValidator::isRepeatable()}). * * Argument types aren't listed here; they're read from the directive class's constructor when it's * registered. @@ -21,7 +22,6 @@ final class DirectiveDefinition public function __construct( public readonly string $name, public readonly array $locations, - public readonly bool $repeatable = false, public readonly string|null $description = null, public readonly bool $builtIn = false, ) { 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 index 2bedc3892..1b6420440 100644 --- a/src/Directives/DirectiveRegistry.php +++ b/src/Directives/DirectiveRegistry.php @@ -10,31 +10,23 @@ use TheCodingMachine\GraphQLite\Directives\Exceptions\InvalidDirectiveException; use function array_key_exists; -use function array_map; -use function array_values; use function assert; use function in_array; use function method_exists; /** - * Holds the custom directives for a schema. Built once in - * {@see \TheCodingMachine\GraphQLite\SchemaFactory::createSchema()}: it discovers directive classes - * via {@see DirectiveClassFinder}, validates them with {@see DirectiveValidator}, and caches the - * {@see DirectiveDefinition}, argument shape, and webonyx {@see WebonyxDirective} for each. + * Holds the custom directives for a schema, built once in + * {@see \TheCodingMachine\GraphQLite\SchemaFactory::createSchema()}. For each discovered class it + * runs {@see DirectiveValidator}, caches what {@see DirectiveResolver} produces, and enforces name + * uniqueness across the set. * - * The dispatcher middlewares also query it at apply time to get a directive's argument shape, which - * the AST builder needs to encode each arg as a GraphQL value. + * The dispatcher middlewares query it at apply time for a directive's argument shape, which the AST + * builder needs to encode each arg as a GraphQL value. */ final class DirectiveRegistry { - /** @var array, DirectiveDefinition> */ - private array $definitionsByClass = []; - - /** @var array, list> */ - private array $argumentsByClass = []; - - /** @var array, WebonyxDirective> */ - private array $webonyxByClass = []; + /** @var array, ResolvedDirective> */ + private array $resolvedByClass = []; /** @var array> */ private array $classByName = []; @@ -80,7 +72,7 @@ private function register(string $directiveClass): void { // Registering the same class twice is a no-op; discovery and the built-in list share this // registry, so duplicates are expected. - if (isset($this->definitionsByClass[$directiveClass])) { + if (isset($this->resolvedByClass[$directiveClass])) { return; } @@ -91,7 +83,7 @@ private function register(string $directiveClass): void $definition = $directiveClass::definition(); assert($definition instanceof DirectiveDefinition); - $arguments = DirectiveValidator::validate($directiveClass, $definition); + DirectiveValidator::validate($directiveClass, $definition); if (! $definition->builtIn && in_array($definition->name, self::RESERVED_NAMES, true)) { throw InvalidDirectiveException::reservedName($definition->name, $directiveClass); @@ -110,56 +102,33 @@ private function register(string $directiveClass): void ); } - $this->definitionsByClass[$directiveClass] = $definition; - $this->argumentsByClass[$directiveClass] = $arguments; + $this->resolvedByClass[$directiveClass] = DirectiveResolver::resolve($directiveClass, $definition); $this->classByName[$definition->name] = $directiveClass; - - // webonyx already declares built-in directives, so don't add a duplicate definition to - // SchemaConfig::$directives. - if ($definition->builtIn) { - return; - } - - $this->webonyxByClass[$directiveClass] = self::buildWebonyxDirective($definition, $arguments); } - /** @param list $arguments */ - private static function buildWebonyxDirective(DirectiveDefinition $definition, array $arguments): WebonyxDirective + public function hasAny(): bool { - $argsConfig = []; - foreach ($arguments as $argument) { - $config = ['type' => $argument->type]; - if ($argument->hasDefaultValue) { - $config['defaultValue'] = $argument->defaultValue; + foreach ($this->resolvedByClass as $resolved) { + if ($resolved->webonyxDirective !== null) { + return true; } - 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' => $definition->repeatable, - 'args' => $argsConfig, - ]; - if ($definition->description !== null) { - $config['description'] = $definition->description; } - return new WebonyxDirective($config); - } - - public function hasAny(): bool - { - return $this->webonyxByClass !== []; + return false; } /** @return list */ public function webonyxDirectives(): array { - return array_values($this->webonyxByClass); + $directives = []; + foreach ($this->resolvedByClass as $resolved) { + if ($resolved->webonyxDirective === null) { + continue; + } + $directives[] = $resolved->webonyxDirective; + } + + return $directives; } /** @@ -171,7 +140,9 @@ public function webonyxDirectives(): array */ public function argumentsFor(string $directiveClass): array { - return $this->argumentsByClass[$directiveClass] ?? []; + $resolved = $this->resolvedByClass[$directiveClass] ?? null; + + return $resolved === null ? [] : $resolved->arguments; } /** @@ -181,6 +152,6 @@ public function argumentsFor(string $directiveClass): array */ public function definitionFor(string $directiveClass): DirectiveDefinition|null { - return $this->definitionsByClass[$directiveClass] ?? null; + return ($this->resolvedByClass[$directiveClass] ?? null)?->definition; } } diff --git a/src/Directives/DirectiveValidator.php b/src/Directives/DirectiveValidator.php index 619c1c9bf..a0588c6b9 100644 --- a/src/Directives/DirectiveValidator.php +++ b/src/Directives/DirectiveValidator.php @@ -5,26 +5,19 @@ namespace TheCodingMachine\GraphQLite\Directives; use Attribute; -use GraphQL\Type\Definition\NonNull; -use GraphQL\Type\Definition\Type; use ReflectionClass; -use ReflectionNamedType; -use ReflectionParameter; use TheCodingMachine\GraphQLite\Directives\Exceptions\InvalidDirectiveException; use function in_array; /** - * Validates a discovered directive class and resolves its constructor into a list of - * {@see ResolvedDirectiveArgument}s. Checks: + * Validates a discovered directive class. Checks: * * 1. The `#[Attribute(...)]` PHP target covers every declared GraphQL location. - * 2. PHP `IS_REPEATABLE` matches `DirectiveDefinition::$repeatable`. - * 3. Each family interface has a matching location declared, and vice versa. - * 4. Every constructor parameter maps to a supported input type (scalars only for now). + * 2. Each family interface has a matching location declared, and vice versa. * - * Name uniqueness is checked separately in {@see DirectiveRegistry}, which has the full set to - * compare against. + * Producing the arguments, repeatability, and webonyx directive is {@see DirectiveResolver}'s job; + * name uniqueness is checked in {@see DirectiveRegistry}. * * @internal */ @@ -33,27 +26,18 @@ final class DirectiveValidator /** * @param class-string $directiveClass * - * @return list - * * @throws InvalidDirectiveException */ - public static function validate(string $directiveClass, DirectiveDefinition $definition): array + public static function validate(string $directiveClass, DirectiveDefinition $definition): void { $reflection = new ReflectionClass($directiveClass); - $attributeAttributes = $reflection->getAttributes(Attribute::class); - if ($attributeAttributes === []) { + if ($reflection->getAttributes(Attribute::class) === []) { throw InvalidDirectiveException::notAttribute($directiveClass); } - $attributeArgs = $attributeAttributes[0]->getArguments(); - $phpFlags = $attributeArgs[0] ?? $attributeArgs['flags'] ?? Attribute::TARGET_ALL; - - self::checkPhpTargets($directiveClass, $definition, $phpFlags); - self::checkRepeatableParity($directiveClass, $definition, $phpFlags); + self::checkPhpTargets($directiveClass, $definition, DirectiveReflection::attributeFlags($reflection)); self::checkInterfaceAndLocationAgreement($directiveClass, $definition, $reflection); - - return self::resolveArguments($directiveClass, $reflection); } private static function checkPhpTargets(string $directiveClass, DirectiveDefinition $definition, int $phpFlags): void @@ -69,16 +53,6 @@ private static function checkPhpTargets(string $directiveClass, DirectiveDefinit } } - private static function checkRepeatableParity(string $directiveClass, DirectiveDefinition $definition, int $phpFlags): void - { - $phpRepeatable = ($phpFlags & Attribute::IS_REPEATABLE) === Attribute::IS_REPEATABLE; - if ($phpRepeatable === $definition->repeatable) { - return; - } - - throw InvalidDirectiveException::repeatableMismatch($directiveClass, $phpRepeatable, $definition->repeatable); - } - /** @param ReflectionClass $reflection */ private static function checkInterfaceAndLocationAgreement(string $directiveClass, DirectiveDefinition $definition, ReflectionClass $reflection): void { @@ -104,71 +78,6 @@ private static function checkInterfaceAndLocationAgreement(string $directiveClas } } - /** - * @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, - ); - } - /** @return array map of Attribute::TARGET_* flag → human-readable label */ private static function requiredPhpTargetsFor(DirectiveLocation $location): array { diff --git a/src/Directives/Exceptions/InvalidDirectiveException.php b/src/Directives/Exceptions/InvalidDirectiveException.php index ea198f0d9..9542cfe63 100644 --- a/src/Directives/Exceptions/InvalidDirectiveException.php +++ b/src/Directives/Exceptions/InvalidDirectiveException.php @@ -12,8 +12,8 @@ use function sprintf; /** - * Thrown at schema build time when a directive declaration is invalid, e.g. a bad PHP target, - * repeatable mismatch, missing interface/location, an unsupported argument type, or a name clash. + * Thrown at schema build time when a directive declaration is invalid, e.g. a bad PHP target, a + * missing interface/location, an unsupported argument type, or a name clash. */ final class InvalidDirectiveException extends GraphQLRuntimeException { @@ -28,17 +28,6 @@ public static function phpTargetMissingForLocation(string $directiveClass, Direc )); } - public static function repeatableMismatch(string $directiveClass, bool $phpRepeatable, bool $definitionRepeatable): self - { - return new self(sprintf( - 'Directive "%s" has inconsistent repeatable settings: PHP IS_REPEATABLE=%s but DirectiveDefinition::$repeatable=%s. ' . - 'They must agree so introspection and PHP usage stay in sync.', - $directiveClass, - $phpRepeatable ? 'true' : 'false', - $definitionRepeatable ? 'true' : 'false', - )); - } - /** @param list $locations */ public static function interfaceWithoutMatchingLocation(string $directiveClass, string $interface, array $locations): self { diff --git a/src/Directives/ResolvedDirective.php b/src/Directives/ResolvedDirective.php new file mode 100644 index 000000000..7f9736d30 --- /dev/null +++ b/src/Directives/ResolvedDirective.php @@ -0,0 +1,25 @@ + $arguments */ + public function __construct( + public readonly DirectiveDefinition $definition, + public readonly array $arguments, + public readonly WebonyxDirective|null $webonyxDirective, + ) { + } +} diff --git a/tests/Directives/DirectiveDefinitionTest.php b/tests/Directives/DirectiveDefinitionTest.php index d8ab3bd22..3fa2000f4 100644 --- a/tests/Directives/DirectiveDefinitionTest.php +++ b/tests/Directives/DirectiveDefinitionTest.php @@ -13,24 +13,21 @@ public function testStoresProvidedFields(): void $definition = new DirectiveDefinition( name: 'audit', locations: [DirectiveLocation::FIELD_DEFINITION, DirectiveLocation::INPUT_FIELD_DEFINITION], - repeatable: true, description: 'Audit log marker', ); $this->assertSame('audit', $definition->name); $this->assertSame([DirectiveLocation::FIELD_DEFINITION, DirectiveLocation::INPUT_FIELD_DEFINITION], $definition->locations); - $this->assertTrue($definition->repeatable); $this->assertSame('Audit log marker', $definition->description); } - public function testDefaultsRepeatableFalseAndNoDescription(): void + public function testDefaultsDescriptionToNull(): void { $definition = new DirectiveDefinition( name: 'noop', locations: [DirectiveLocation::OBJECT], ); - $this->assertFalse($definition->repeatable); $this->assertNull($definition->description); } } diff --git a/tests/Directives/DirectiveValidatorTest.php b/tests/Directives/DirectiveValidatorTest.php index 9acb8a190..e28b6b1ce 100644 --- a/tests/Directives/DirectiveValidatorTest.php +++ b/tests/Directives/DirectiveValidatorTest.php @@ -4,37 +4,13 @@ namespace TheCodingMachine\GraphQLite\Directives; -use GraphQL\Type\Definition\NonNull; -use GraphQL\Type\Definition\StringType; use PHPUnit\Framework\TestCase; use TheCodingMachine\GraphQLite\Directives\Exceptions\InvalidDirectiveException; -use TheCodingMachine\GraphQLite\Fixtures\Directives\AuditFieldDirective; use TheCodingMachine\GraphQLite\Fixtures\Directives\Invalid\InterfaceWithoutLocationDirective; use TheCodingMachine\GraphQLite\Fixtures\Directives\Invalid\MissingPhpTargetDirective; -use TheCodingMachine\GraphQLite\Fixtures\Directives\Invalid\RepeatableMismatchDirective; -use TheCodingMachine\GraphQLite\Fixtures\Directives\Invalid\UnsupportedArgumentTypeDirective; -use TheCodingMachine\GraphQLite\Fixtures\Directives\UppercaseFieldDirective; final class DirectiveValidatorTest extends TestCase { - public function testValidDirectiveWithNoArgsResolvesToEmptyArgumentList(): void - { - $args = DirectiveValidator::validate(UppercaseFieldDirective::class, UppercaseFieldDirective::definition()); - - $this->assertSame([], $args); - } - - public function testResolvesScalarArgumentWithCorrectTypeAndNullability(): void - { - $args = DirectiveValidator::validate(AuditFieldDirective::class, AuditFieldDirective::definition()); - - $this->assertCount(1, $args); - $this->assertSame('reason', $args[0]->name); - $this->assertInstanceOf(NonNull::class, $args[0]->type); - $this->assertInstanceOf(StringType::class, $args[0]->type->getWrappedType()); - $this->assertFalse($args[0]->hasDefaultValue); - } - public function testRejectsDirectiveMissingRequiredPhpTarget(): void { $this->expectException(InvalidDirectiveException::class); @@ -43,14 +19,6 @@ public function testRejectsDirectiveMissingRequiredPhpTarget(): void DirectiveValidator::validate(MissingPhpTargetDirective::class, MissingPhpTargetDirective::definition()); } - public function testRejectsRepeatableMismatch(): void - { - $this->expectException(InvalidDirectiveException::class); - $this->expectExceptionMessageMatches('/repeatable/'); - - DirectiveValidator::validate(RepeatableMismatchDirective::class, RepeatableMismatchDirective::definition()); - } - public function testRejectsInterfaceWithoutMatchingLocation(): void { $this->expectException(InvalidDirectiveException::class); @@ -58,12 +26,4 @@ public function testRejectsInterfaceWithoutMatchingLocation(): void DirectiveValidator::validate(InterfaceWithoutLocationDirective::class, InterfaceWithoutLocationDirective::definition()); } - - public function testRejectsUnsupportedArgumentType(): void - { - $this->expectException(InvalidDirectiveException::class); - $this->expectExceptionMessageMatches('/scalar types/'); - - DirectiveValidator::validate(UnsupportedArgumentTypeDirective::class, UnsupportedArgumentTypeDirective::definition()); - } } diff --git a/tests/Fixtures/Directives/AuditFieldDirective.php b/tests/Fixtures/Directives/AuditFieldDirective.php index f4d9606d7..c2b894cd6 100644 --- a/tests/Fixtures/Directives/AuditFieldDirective.php +++ b/tests/Fixtures/Directives/AuditFieldDirective.php @@ -21,7 +21,6 @@ public static function definition(): DirectiveDefinition return new DirectiveDefinition( name: 'audit', locations: [DirectiveLocation::FIELD_DEFINITION], - repeatable: true, description: 'Marks a field as needing audit-log treatment.', ); } diff --git a/tests/Fixtures/Directives/Invalid/RepeatableMismatchDirective.php b/tests/Fixtures/Directives/Invalid/RepeatableMismatchDirective.php deleted file mode 100644 index cfab3f210..000000000 --- a/tests/Fixtures/Directives/Invalid/RepeatableMismatchDirective.php +++ /dev/null @@ -1,27 +0,0 @@ - Date: Mon, 1 Jun 2026 22:05:20 +0200 Subject: [PATCH 11/12] Add `DirectiveResolver` --- src/Directives/DirectiveResolver.php | 145 +++++++++++++++++++++ tests/Directives/DirectiveResolverTest.php | 55 ++++++++ 2 files changed, 200 insertions(+) create mode 100644 src/Directives/DirectiveResolver.php create mode 100644 tests/Directives/DirectiveResolverTest.php 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/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()); + } +} From fb1ec056dd02f6e7d7f0c66587369b4f522bd122 Mon Sep 17 00:00:00 2001 From: Michael Georgiadis <38563912+michael-georgiadis@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:25:50 +0200 Subject: [PATCH 12/12] Remove unused fixture --- .../Directives/OneOfishDirective.php | 31 ------------------- 1 file changed, 31 deletions(-) delete mode 100644 tests/Fixtures/DirectivesIntegration/Directives/OneOfishDirective.php diff --git a/tests/Fixtures/DirectivesIntegration/Directives/OneOfishDirective.php b/tests/Fixtures/DirectivesIntegration/Directives/OneOfishDirective.php deleted file mode 100644 index 745bef086..000000000 --- a/tests/Fixtures/DirectivesIntegration/Directives/OneOfishDirective.php +++ /dev/null @@ -1,31 +0,0 @@ -handle($descriptor); - } -}