diff --git a/src/Directives/DirectiveRegistry.php b/src/Directives/DirectiveRegistry.php index c4718a47e9..fcab2e0dc2 100644 --- a/src/Directives/DirectiveRegistry.php +++ b/src/Directives/DirectiveRegistry.php @@ -9,43 +9,99 @@ use TheCodingMachine\GraphQLite\AnnotationReader; use TheCodingMachine\GraphQLite\Directives\BuiltIn\Deprecated; use TheCodingMachine\GraphQLite\Directives\BuiltIn\OneOf; +use TheCodingMachine\GraphQLite\Directives\Discovery\DirectiveClassFinder; use TheCodingMachine\GraphQLite\Directives\Exceptions\InvalidDirectiveException; +use function array_key_exists; +use function in_array; + /** - * Holds the directives known to a schema. In this layer that's the built-ins that bind PHP behavior - * to directives webonyx already declares (`@oneOf`, `@deprecated`); a later layer adds user-defined - * directives on top. - * - * 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. + * Discovers, validates and holds a schema's directives: the user-defined ones plus the built-ins + * (`@oneOf`, `@deprecated`). Middlewares query it at apply time for a directive's argument shape. */ final class DirectiveRegistry { /** @var array, ResolvedDirective> */ private array $resolvedByClass = []; - /** Attribute classes that bind PHP behavior to webonyx's built-in directives. */ + /** @var array> */ + private array $classByName = []; + + /** Attributes we bind to webonyx's built-ins; a custom directive reuses these names only via `builtIn: true`. */ private const BUILT_IN_ATTRIBUTES = [ OneOf::class, Deprecated::class, ]; + /** webonyx directives we don't bind; no custom directive may take these names. */ + private const RESERVED_WEBONYX_NAMES = [ + WebonyxDirective::SKIP_NAME, + WebonyxDirective::INCLUDE_NAME, + WebonyxDirective::SPECIFIED_BY_NAME, + ]; + public function __construct( private readonly AnnotationReader $annotationReader, + private readonly DirectiveClassFinder $classFinder, ) { } - /** Resolve the built-in directives once. Idempotent. */ + /** Idempotent. User directives first, so a `builtIn: true` override lands before the bundled copy. */ public function discover(): void { + foreach ($this->classFinder->findDirectives() as $directiveClass) { + $this->register($directiveClass); + } foreach (self::BUILT_IN_ATTRIBUTES as $directiveClass) { - /** @var class-string $directiveClass */ - if (isset($this->resolvedByClass[$directiveClass])) { - continue; + $this->register($directiveClass); + } + } + + /** @param class-string $directiveClass */ + private function register(string $directiveClass): void + { + // Discovery and the built-in list overlap, so a repeat is expected. + if (isset($this->resolvedByClass[$directiveClass])) { + return; + } + + $definition = $directiveClass::definition(); + + DirectiveValidator::validate($directiveClass, $definition); + + if (! $definition->builtIn && in_array($definition->name, self::RESERVED_WEBONYX_NAMES, true)) { + throw InvalidDirectiveException::reservedName($definition->name, $directiveClass); + } + + if (! $definition->builtIn && $this->isReservedBuiltInName($definition->name)) { + throw InvalidDirectiveException::reservedName($definition->name, $directiveClass); + } + + if (array_key_exists($definition->name, $this->classByName)) { + // A builtIn override defers to the already-registered user class; two customs is an error. + if ($definition->builtIn) { + return; } + throw InvalidDirectiveException::duplicateName( + $definition->name, + $this->classByName[$definition->name], + $directiveClass, + ); + } - $this->resolvedByClass[$directiveClass] = DirectiveResolver::resolve($directiveClass, $directiveClass::definition()); + $this->resolvedByClass[$directiveClass] = DirectiveResolver::resolve($directiveClass, $definition); + $this->classByName[$definition->name] = $directiveClass; + } + + private function isReservedBuiltInName(string $name): bool + { + foreach (self::BUILT_IN_ATTRIBUTES as $builtInClass) { + if ($builtInClass::definition()->name === $name) { + return true; + } } + + return false; } public function hasAny(): bool diff --git a/src/Directives/DirectiveValidator.php b/src/Directives/DirectiveValidator.php index bf3d700ef2..44af2a718e 100644 --- a/src/Directives/DirectiveValidator.php +++ b/src/Directives/DirectiveValidator.php @@ -4,27 +4,37 @@ namespace TheCodingMachine\GraphQLite\Directives; +use Attribute; use ReflectionClass; use TheCodingMachine\GraphQLite\Directives\Exceptions\InvalidDirectiveException; +use TheCodingMachine\GraphQLite\Directives\Validation\AttributeTargetValidator; +use TheCodingMachine\GraphQLite\Directives\Validation\InterfaceLocationValidator; use function in_array; use function is_a; /** - * Validates that directives are applied where they're allowed. PHP's `#[Attribute]` targets can't - * tell a `#[Type]` class from an `#[Input]` class (both are `TARGET_CLASS`), so a class-level - * directive like `#[OneOf]` could be placed on the wrong kind of class. This reports that instead - * of letting the interface-based collectors drop the misplaced directive silently. + * Validates a directive at discovery time ({@see self::validate()}, delegating target and + * interface/location checks) and its placement at apply time ({@see self::assertDirectivesUsableAt()}). * * @internal */ final class DirectiveValidator { - /** - * @param ReflectionClass $refClass - * - * @throws InvalidDirectiveException when a directive on $refClass isn't allowed at $location. - */ + /** @param class-string $directiveClass */ + public static function validate(string $directiveClass, DirectiveDefinition $definition): void + { + $reflection = new ReflectionClass($directiveClass); + + if ($reflection->getAttributes(Attribute::class) === []) { + throw InvalidDirectiveException::notAttribute($directiveClass); + } + + AttributeTargetValidator::validate($directiveClass, $definition, DirectiveReflection::attributeFlags($reflection)); + InterfaceLocationValidator::validate($directiveClass, $definition, $reflection); + } + + /** @param ReflectionClass $refClass */ public static function assertDirectivesUsableAt(ReflectionClass $refClass, DirectiveLocation $location): void { foreach ($refClass->getAttributes() as $attribute) { diff --git a/src/Directives/Discovery/DirectiveClassFinder.php b/src/Directives/Discovery/DirectiveClassFinder.php new file mode 100644 index 0000000000..639215ff64 --- /dev/null +++ b/src/Directives/Discovery/DirectiveClassFinder.php @@ -0,0 +1,71 @@ +>|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 0000000000..f1c2c711f7 --- /dev/null +++ b/src/Directives/Discovery/GlobDirectivesCache.php @@ -0,0 +1,20 @@ + $directiveClass */ + public function __construct(public readonly string $directiveClass) + { + } +} diff --git a/src/Directives/Exceptions/InvalidDirectiveException.php b/src/Directives/Exceptions/InvalidDirectiveException.php index e032900b39..2e24582604 100644 --- a/src/Directives/Exceptions/InvalidDirectiveException.php +++ b/src/Directives/Exceptions/InvalidDirectiveException.php @@ -103,17 +103,9 @@ public static function duplicateName(string $name, string $existingClass, string 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.', + 'Directive "%s" declares the name "@%s", which is reserved for a built-in directive. Pick a different name, or set builtIn: true on its definition to intentionally override the built-in.', $directiveClass, $name, )); } - - public static function noDefinitionMethod(string $directiveClass): self - { - return new self(sprintf( - 'Directive "%s" implements DirectiveInterface but does not declare a static `definition(): DirectiveDefinition` method.', - $directiveClass, - )); - } } diff --git a/src/Directives/Validation/AttributeTargetValidator.php b/src/Directives/Validation/AttributeTargetValidator.php new file mode 100644 index 0000000000..950d1cbc1d --- /dev/null +++ b/src/Directives/Validation/AttributeTargetValidator.php @@ -0,0 +1,51 @@ +locations as $location) { + foreach (self::requiredPhpTargetsFor($location) as $requiredTarget => $label) { + if (($phpFlags & $requiredTarget) === $requiredTarget) { + continue; + } + + throw InvalidDirectiveException::phpTargetMissingForLocation($directiveClass, $location, $label); + } + } + } + + /** @return array required TARGET_* flag => 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'], + // No apply hooks for the other locations yet. + default => [], + }; + } +} diff --git a/src/Directives/Validation/InterfaceLocationValidator.php b/src/Directives/Validation/InterfaceLocationValidator.php new file mode 100644 index 0000000000..d473fab3d3 --- /dev/null +++ b/src/Directives/Validation/InterfaceLocationValidator.php @@ -0,0 +1,51 @@ + $reflection */ + public static function validate(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); + } + } + } +} diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index 4aea95bb3e..3dee20609f 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -20,6 +20,7 @@ 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; @@ -408,7 +409,10 @@ public function createSchema(): Schema $expressionLanguage = $this->expressionLanguage ?: new ExpressionLanguage($symfonyCache); $expressionLanguage->registerProvider(new SecurityExpressionLanguageProvider()); - $directiveRegistry = new DirectiveRegistry($annotationReader); + $directiveRegistry = new DirectiveRegistry( + $annotationReader, + new DirectiveClassFinder($classFinder, $classFinderComputedCache), + ); $directiveRegistry->discover(); $directiveAstBuilder = new DirectiveAstBuilder($directiveRegistry); diff --git a/tests/Directives/BuiltInDirectivesTest.php b/tests/Directives/BuiltInDirectivesTest.php new file mode 100644 index 0000000000..02026c437d --- /dev/null +++ b/tests/Directives/BuiltInDirectivesTest.php @@ -0,0 +1,80 @@ +discover(); + + $this->assertNotNull($registry->definitionFor(OneOf::class)); + } + + public function testBuiltInDirectivesAreNotEmittedAsCustomDirectives(): void + { + $registry = self::buildRegistry([]); + $registry->discover(); + + // @oneOf is a webonyx built-in, not part of the custom list. + $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 + { + // Discovery finding our own built-in shouldn't double-register. + $registry = self::buildRegistry([OneOf::class]); + $registry->discover(); + + $this->assertNotNull($registry->definitionFor(OneOf::class)); + } + + public function testUserOverrideOfBuiltInWinsOverBundled(): void + { + // User override wins over the bundled OneOf. + $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(new AnnotationReader(), $finder); + } +} diff --git a/tests/Directives/DirectiveDefinitionTest.php b/tests/Directives/DirectiveDefinitionTest.php new file mode 100644 index 0000000000..3fa2000f4a --- /dev/null +++ b/tests/Directives/DirectiveDefinitionTest.php @@ -0,0 +1,33 @@ +assertSame('audit', $definition->name); + $this->assertSame([DirectiveLocation::FIELD_DEFINITION, DirectiveLocation::INPUT_FIELD_DEFINITION], $definition->locations); + $this->assertSame('Audit log marker', $definition->description); + } + + public function testDefaultsDescriptionToNull(): void + { + $definition = new DirectiveDefinition( + name: 'noop', + locations: [DirectiveLocation::OBJECT], + ); + + $this->assertNull($definition->description); + } +} diff --git a/tests/Directives/DirectiveLocationTest.php b/tests/Directives/DirectiveLocationTest.php new file mode 100644 index 0000000000..69739c6e94 --- /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 executable or type-system, not both.'); + } + } +} diff --git a/tests/Directives/DirectiveRegistryTest.php b/tests/Directives/DirectiveRegistryTest.php index e39bb87112..9b03735428 100644 --- a/tests/Directives/DirectiveRegistryTest.php +++ b/tests/Directives/DirectiveRegistryTest.php @@ -4,24 +4,29 @@ namespace TheCodingMachine\GraphQLite\Directives; +use GraphQL\Type\Definition\Directive as WebonyxDirective; use PHPUnit\Framework\TestCase; use ReflectionClass; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Psr16Cache; use TheCodingMachine\GraphQLite\AnnotationReader; use TheCodingMachine\GraphQLite\Directives\BuiltIn\Deprecated; use TheCodingMachine\GraphQLite\Directives\BuiltIn\OneOf; +use TheCodingMachine\GraphQLite\Directives\Discovery\DirectiveClassFinder; use TheCodingMachine\GraphQLite\Directives\Exceptions\InvalidDirectiveException; +use TheCodingMachine\GraphQLite\Discovery\Cache\HardClassFinderComputedCache; +use TheCodingMachine\GraphQLite\Discovery\StaticClassFinder; +use TheCodingMachine\GraphQLite\Fixtures\Directives\InternalFieldDirective; +use TheCodingMachine\GraphQLite\Fixtures\Directives\Invalid\ReservedNameDirective; +use TheCodingMachine\GraphQLite\Fixtures\Directives\Invalid\WebonyxReservedNameDirective; use TheCodingMachine\GraphQLite\Fixtures\Directives\MisusedOneOfOnType; +use TheCodingMachine\GraphQLite\Fixtures\Directives\NoteFieldDirective; +use TheCodingMachine\GraphQLite\Fixtures\Directives\RevisionInputObjectDirective; + +use function array_map; final class DirectiveRegistryTest extends TestCase { - private function registry(): DirectiveRegistry - { - $registry = new DirectiveRegistry(new AnnotationReader()); - $registry->discover(); - - return $registry; - } - public function testRegistersTheBuiltInDirectives(): void { $registry = $this->registry(); @@ -32,8 +37,7 @@ public function testRegistersTheBuiltInDirectives(): void public function testBuiltInsAreNotAddedToTheSchemaDirectiveList(): void { - // webonyx already declares @oneOf and @deprecated, so the registry contributes nothing to - // SchemaConfig::$directives. + // webonyx already declares these, so the registry adds nothing to the schema. $registry = $this->registry(); $this->assertSame([], $registry->webonyxDirectives()); @@ -50,7 +54,7 @@ public function testResolvesBuiltInArguments(): void public function testDiscoverIsIdempotent(): void { - $registry = new DirectiveRegistry(new AnnotationReader()); + $registry = self::buildRegistry(); $registry->discover(); $registry->discover(); @@ -64,4 +68,96 @@ public function testObjectTypeDirectivesRejectsMisplacedDirective(): void $this->registry()->objectTypeDirectives(new ReflectionClass(MisusedOneOfOnType::class)); } + + public function testDiscoversAndRegistersValidDirectives(): void + { + $registry = self::buildRegistry([ + InternalFieldDirective::class, + NoteFieldDirective::class, + RevisionInputObjectDirective::class, + ]); + $registry->discover(); + + $this->assertTrue($registry->hasAny()); + + $webonyx = $registry->webonyxDirectives(); + $this->assertCount(3, $webonyx); + + $byName = []; + foreach ($webonyx as $directive) { + $byName[$directive->name] = $directive; + } + + $this->assertArrayHasKey('internal', $byName); + $this->assertArrayHasKey('note', $byName); + $this->assertArrayHasKey('revision', $byName); + + $note = $byName['note']; + $this->assertTrue($note->isRepeatable); + $this->assertSame('Attaches a freeform note to a field.', $note->description); + $this->assertCount(1, $note->args); + $this->assertSame('text', $note->args[0]->name); + } + + public function testRejectsCustomDirectiveUsingReservedName(): void + { + $registry = self::buildRegistry([ReservedNameDirective::class]); + + $this->expectException(InvalidDirectiveException::class); + $this->expectExceptionMessageMatches('/reserved/'); + + $registry->discover(); + } + + public function testRejectsCustomDirectiveUsingWebonyxReservedName(): void + { + // @skip is a webonyx built-in we don't bind. + $registry = self::buildRegistry([WebonyxReservedNameDirective::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 + { + // Built-ins merge in at the Schema layer, not here. + $registry = self::buildRegistry([InternalFieldDirective::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); + } + + private function registry(): DirectiveRegistry + { + $registry = self::buildRegistry(); + $registry->discover(); + + return $registry; + } + + /** @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(new AnnotationReader(), $finder); + } } diff --git a/tests/Directives/DirectiveResolverTest.php b/tests/Directives/DirectiveResolverTest.php new file mode 100644 index 0000000000..b1c7114f0c --- /dev/null +++ b/tests/Directives/DirectiveResolverTest.php @@ -0,0 +1,55 @@ +assertSame([], $resolved->arguments); + } + + public function testResolvesScalarArgumentWithCorrectTypeAndNullability(): void + { + $resolved = DirectiveResolver::resolve(NoteFieldDirective::class, NoteFieldDirective::definition()); + + $this->assertCount(1, $resolved->arguments); + $this->assertSame('text', $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 + { + $note = DirectiveResolver::resolve(NoteFieldDirective::class, NoteFieldDirective::definition())->webonyxDirective; + $internal = DirectiveResolver::resolve(InternalFieldDirective::class, InternalFieldDirective::definition())->webonyxDirective; + + $this->assertInstanceOf(WebonyxDirective::class, $note); + $this->assertTrue($note->isRepeatable); + + $this->assertInstanceOf(WebonyxDirective::class, $internal); + $this->assertFalse($internal->isRepeatable); + } + + public function testRejectsUnsupportedArgumentType(): void + { + $this->expectException(InvalidDirectiveException::class); + $this->expectExceptionMessageMatches('/scalar types/'); + + DirectiveResolver::resolve(UnsupportedArgumentTypeDirective::class, UnsupportedArgumentTypeDirective::definition()); + } +} diff --git a/tests/Directives/DirectiveValidatorTest.php b/tests/Directives/DirectiveValidatorTest.php new file mode 100644 index 0000000000..e28b6b1ce4 --- /dev/null +++ b/tests/Directives/DirectiveValidatorTest.php @@ -0,0 +1,29 @@ +expectException(InvalidDirectiveException::class); + $this->expectExceptionMessageMatches('/TARGET_METHOD/'); + + DirectiveValidator::validate(MissingPhpTargetDirective::class, MissingPhpTargetDirective::definition()); + } + + public function testRejectsInterfaceWithoutMatchingLocation(): void + { + $this->expectException(InvalidDirectiveException::class); + $this->expectExceptionMessageMatches('/FieldDirective/'); + + DirectiveValidator::validate(InterfaceWithoutLocationDirective::class, InterfaceWithoutLocationDirective::definition()); + } +} diff --git a/tests/Fixtures/Directives/CustomOneOfOverride.php b/tests/Fixtures/Directives/CustomOneOfOverride.php new file mode 100644 index 0000000000..70039bc6ca --- /dev/null +++ b/tests/Fixtures/Directives/CustomOneOfOverride.php @@ -0,0 +1,34 @@ +handle($descriptor); + $type->isOneOf = true; + return $type; + } +} diff --git a/tests/Fixtures/Directives/InternalFieldDirective.php b/tests/Fixtures/Directives/InternalFieldDirective.php new file mode 100644 index 0000000000..b8fb54e9ca --- /dev/null +++ b/tests/Fixtures/Directives/InternalFieldDirective.php @@ -0,0 +1,23 @@ +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/Directives/AuditDirective.php b/tests/Fixtures/DirectivesIntegration/Directives/AuditDirective.php new file mode 100644 index 0000000000..7104860cc3 --- /dev/null +++ b/tests/Fixtures/DirectivesIntegration/Directives/AuditDirective.php @@ -0,0 +1,27 @@ +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 0000000000..7f19285eff --- /dev/null +++ b/tests/Fixtures/DirectivesIntegration/Directives/VersionedDirective.php @@ -0,0 +1,28 @@ +label; + } +} diff --git a/tests/Integration/DirectivesEndToEndTest.php b/tests/Integration/DirectivesEndToEndTest.php new file mode 100644 index 0000000000..2d043a2290 --- /dev/null +++ b/tests/Integration/DirectivesEndToEndTest.php @@ -0,0 +1,197 @@ +directives`. + */ +final class DirectivesEndToEndTest extends TestCase +{ + private function buildSchema(): Schema + { + $cache = new Psr16Cache(new ArrayAdapter()); + $factory = new SchemaFactory($cache, new BasicAutoWiringContainer(new EmptyContainer())); + $factory->prodMode(); + $factory->addNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\DirectivesIntegration'); + + return $factory->createSchema(); + } + + public function testSchemaPrintsDirectiveDefinitions(): void + { + $schema = $this->buildSchema(); + $sdl = SchemaPrinter::doPrint($schema); + + $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); + + // @oneOf is webonyx's: printed from the isOneOf flag, not re-declared in 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 present alongside custom directives. + $this->assertContains('skip', $names); + $this->assertContains('include', $names); + $this->assertContains('deprecated', $names); + + $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); + // getLabel() carries @uppercase. + $this->assertSame('ABC', $result['data']['findWidget']['label']); + } + + public function testFieldDirectiveUppercasesMixedCaseValue(): void + { + $schema = $this->buildSchema(); + + $result = GraphQL::executeQuery( + $schema, + '{ findWidget(lookup: { sku: "MixedCase" }) { label } }', + )->toArray(); + + $this->assertArrayNotHasKey('errors', $result); + $this->assertSame('MIXEDCASE', $result['data']['findWidget']['label']); + } + + public function testRepeatableDirectiveAttachesOncePerUsageToFieldAst(): void + { + $widget = $this->buildSchema()->getType('Widget'); + assert($widget instanceof ObjectType); + + $astNode = $widget->getField('label')->astNode; + $this->assertNotNull($astNode); + + $names = []; + foreach ($astNode->directives as $directive) { + $names[] = $directive->name->value; + } + + // @uppercase once, repeatable @audit twice. + $this->assertContains('uppercase', $names); + $this->assertSame(2, count(array_filter($names, static fn (string $name) => $name === 'audit'))); + } + + public function testMetadataDirectivesAttachToTheirElementsAst(): void + { + $schema = $this->buildSchema(); + + // Applications land on each element's AST node, though the printer omits them. + $widget = $schema->getType('Widget'); + assert($widget instanceof ObjectType); + $this->assertSame(['tagged' => ['name' => 'primary']], self::astDirectives($widget->astNode?->directives ?? [])); + + $lookup = $schema->getType('WidgetLookupInput'); + assert($lookup instanceof InputObjectType); + $this->assertSame(['versioned' => ['version' => '2']], self::astDirectives($lookup->astNode?->directives ?? [])); + + $sku = $lookup->getField('sku')->astNode; + $this->assertNotNull($sku); + $this->assertSame(['sanitized' => []], self::astDirectives($sku->directives)); + } + + public function testOneOfInputEnforcesExactlyOneField(): void + { + $schema = $this->buildSchema(); + + // One field resolves; @uppercase still applies to the label. + $ok = GraphQL::executeQuery($schema, '{ findOneOf(lookup: { sku: "widget-1" }) { label } }')->toArray(); + $this->assertArrayNotHasKey('errors', $ok); + $this->assertSame('WIDGET-1', $ok['data']['findOneOf']['label']); + + // Two fields violates @oneOf. + $both = GraphQL::executeQuery($schema, '{ findOneOf(lookup: { sku: "a", id: 1 }) { label } }')->toArray(); + $this->assertArrayHasKey('errors', $both); + $this->assertStringContainsString('exactly one field', $both['errors'][0]['message']); + + // Zero fields also violates @oneOf. + $none = GraphQL::executeQuery($schema, '{ findOneOf(lookup: {}) { label } }')->toArray(); + $this->assertArrayHasKey('errors', $none); + } + + /** + * @param iterable $directives + * + * @return array> name => [arg => value] + */ + private static function astDirectives(iterable $directives): array + { + $result = []; + foreach ($directives as $directive) { + $args = []; + foreach ($directive->arguments as $argument) { + $args[$argument->name->value] = $argument->value->value; + } + $result[$directive->name->value] = $args; + } + + return $result; + } +}