diff --git a/composer.json b/composer.json index 33751a0af7..fadb2e608b 100644 --- a/composer.json +++ b/composer.json @@ -13,8 +13,8 @@ "php": ">=8.1", "ext-json": "*", "composer/package-versions-deprecated": "^1.8", - "phpdocumentor/reflection-docblock": "^4.3 || ^5.0", - "phpdocumentor/type-resolver": "^1.4", + "phpdocumentor/reflection-docblock": "^5.4", + "phpdocumentor/type-resolver": "^1.7", "psr/container": "^1.1 || ^2", "psr/http-factory": "^1", "psr/http-message": "^1.0.1 || ^2.0", diff --git a/src/Mappers/CannotMapTypeException.php b/src/Mappers/CannotMapTypeException.php index 7ea34ea8cf..aa1331747d 100644 --- a/src/Mappers/CannotMapTypeException.php +++ b/src/Mappers/CannotMapTypeException.php @@ -13,6 +13,7 @@ use GraphQL\Type\Definition\Type; use phpDocumentor\Reflection\Type as PhpDocumentorType; use phpDocumentor\Reflection\Types\Array_; +use phpDocumentor\Reflection\Types\Callable_; use phpDocumentor\Reflection\Types\Iterable_; use phpDocumentor\Reflection\Types\Mixed_; use phpDocumentor\Reflection\Types\Object_; @@ -124,7 +125,7 @@ public static function extendTypeWithBadTargetedClass(string $className, ExtendT return new self('For ' . self::extendTypeToString($extendType) . ' annotation declared in class "' . $className . '", the pointed at GraphQL type cannot be extended. You can only target types extending the MutableObjectType (like types created with the @Type annotation).'); } - /** @param Array_|Iterable_|Object_|Mixed_ $type */ + /** @param Array_|Iterable_|Object_|Mixed_|Callable_ $type */ public static function createForMissingPhpDoc(PhpDocumentorType $type, ReflectionMethod|ReflectionProperty $reflector, string|null $argumentName = null): self { $typeStr = ''; @@ -136,6 +137,8 @@ public static function createForMissingPhpDoc(PhpDocumentorType $type, Reflectio $typeStr = sprintf('object ("%s")', $type->getFqsen()); } elseif ($type instanceof Mixed_) { $typeStr = 'mixed'; + } elseif ($type instanceof Callable_) { + $typeStr = 'callable'; } assert($typeStr !== ''); if ($argumentName === null) { @@ -177,4 +180,19 @@ public static function createForNonNullReturnByTypeMapper(): self { return new self('a type mapper returned a GraphQL\Type\Definition\NonNull instance. All instances returned by type mappers should be nullable. It is the role of the NullableTypeMapperAdapter class to make a GraphQL type in a "NonNull". Note: this is an error in the TypeMapper code or in GraphQLite itself. Please check your custom type mappers or open an issue on GitHub if you don\'t have any custom type mapper.'); } + + public static function createForUnexpectedCallableParameters(): self + { + return new self('callable() type-hint must not specify any parameters.'); + } + + public static function createForMissingCallableReturnType(): self + { + return new self('callable() type-hint must specify its return type. For instance: callable(): int'); + } + + public static function createForCallableAsInput(): self + { + return new self('callable() type-hint can only be used as output type.'); + } } diff --git a/src/Mappers/Parameters/TypeHandler.php b/src/Mappers/Parameters/TypeHandler.php index cd1a2d6b5e..77fdad3f55 100644 --- a/src/Mappers/Parameters/TypeHandler.php +++ b/src/Mappers/Parameters/TypeHandler.php @@ -16,6 +16,7 @@ use phpDocumentor\Reflection\Type; use phpDocumentor\Reflection\TypeResolver as PhpDocumentorTypeResolver; use phpDocumentor\Reflection\Types\Array_; +use phpDocumentor\Reflection\Types\Callable_; use phpDocumentor\Reflection\Types\Collection; use phpDocumentor\Reflection\Types\Compound; use phpDocumentor\Reflection\Types\Iterable_; @@ -375,6 +376,7 @@ private function mapType( $innerType instanceof Array_ || $innerType instanceof Iterable_ || $innerType instanceof Mixed_ + || $innerType instanceof Callable_ // Try to match generic phpdoc-provided iterables with non-generic return-type-provided iterables // Example: (return type `\ArrayObject`, phpdoc `\ArrayObject`) || ($innerType instanceof Object_ diff --git a/src/Mappers/Root/CallableTypeMapper.php b/src/Mappers/Root/CallableTypeMapper.php new file mode 100644 index 0000000000..7571d15d0a --- /dev/null +++ b/src/Mappers/Root/CallableTypeMapper.php @@ -0,0 +1,61 @@ +next->toGraphQLOutputType($type, $subType, $reflector, $docBlockObj); + } + + if ($type->getParameters()) { + throw CannotMapTypeException::createForUnexpectedCallableParameters(); + } + + $returnType = $type->getReturnType(); + + if (! $returnType) { + throw CannotMapTypeException::createForMissingCallableReturnType(); + } + + return $this->topRootTypeMapper->toGraphQLOutputType($returnType, null, $reflector, $docBlockObj); + } + + public function toGraphQLInputType(Type $type, InputType|null $subType, string $argumentName, ReflectionMethod|ReflectionProperty $reflector, DocBlock $docBlockObj): InputType&GraphQLType + { + if (! $type instanceof Callable_) { + return $this->next->toGraphQLInputType($type, $subType, $argumentName, $reflector, $docBlockObj); + } + + throw CannotMapTypeException::createForCallableAsInput(); + } + + public function mapNameToType(string $typeName): NamedType&GraphQLType + { + return $this->next->mapNameToType($typeName); + } +} diff --git a/src/QueryField.php b/src/QueryField.php index 5a224b70a9..3027cf3c71 100644 --- a/src/QueryField.php +++ b/src/QueryField.php @@ -5,6 +5,7 @@ namespace TheCodingMachine\GraphQLite; use Closure; +use GraphQL\Deferred; use GraphQL\Error\ClientAware; use GraphQL\Executor\Promise\Adapter\SyncPromise; use GraphQL\Language\AST\FieldDefinitionNode; @@ -20,6 +21,8 @@ use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; use TheCodingMachine\GraphQLite\Parameters\SourceParameter; +use function is_callable; + /** * A GraphQL field that maps to a PHP method automatically. * @@ -92,6 +95,13 @@ public function __construct( private function resolveWithPromise(mixed $result, ResolverInterface $originalResolver): mixed { + // Shorthand for deferring field execution. This does two things: + // - removes the dependency on `GraphQL\Deferred` from user land code + // - allows inferring the type from PHPDoc (callable(): Type), unlike Deferred, which is not generic + if (is_callable($result)) { + $result = new Deferred($result); + } + if ($result instanceof SyncPromise) { return $result->then(fn ($resolvedValue) => $this->resolveWithPromise($resolvedValue, $originalResolver)); } diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index 53b43948b6..92f263d448 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -35,6 +35,7 @@ use TheCodingMachine\GraphQLite\Mappers\PorpaginasTypeMapper; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\BaseTypeMapper; +use TheCodingMachine\GraphQLite\Mappers\Root\CallableTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\CompoundTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\EnumTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\FinalRootTypeMapper; @@ -399,6 +400,7 @@ public function createSchema(): Schema $lastTopRootTypeMapper = new LastDelegatingTypeMapper(); $topRootTypeMapper = new NullableTypeMapperAdapter($lastTopRootTypeMapper); $topRootTypeMapper = new VoidTypeMapper($topRootTypeMapper); + $topRootTypeMapper = new CallableTypeMapper($topRootTypeMapper, $lastTopRootTypeMapper); $errorRootTypeMapper = new FinalRootTypeMapper($recursiveTypeMapper); $rootTypeMapper = new BaseTypeMapper($errorRootTypeMapper, $recursiveTypeMapper, $topRootTypeMapper); diff --git a/tests/AbstractQueryProvider.php b/tests/AbstractQueryProvider.php index 88ae390f47..132616dc8b 100644 --- a/tests/AbstractQueryProvider.php +++ b/tests/AbstractQueryProvider.php @@ -41,6 +41,7 @@ use TheCodingMachine\GraphQLite\Mappers\Parameters\ResolveInfoParameterHandler; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\BaseTypeMapper; +use TheCodingMachine\GraphQLite\Mappers\Root\CallableTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\CompoundTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\EnumTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\FinalRootTypeMapper; @@ -359,6 +360,7 @@ protected function buildRootTypeMapper(): RootTypeMapperInterface $lastTopRootTypeMapper = new LastDelegatingTypeMapper(); $topRootTypeMapper = new NullableTypeMapperAdapter($lastTopRootTypeMapper); $topRootTypeMapper = new VoidTypeMapper($topRootTypeMapper); + $topRootTypeMapper = new CallableTypeMapper($topRootTypeMapper, $lastTopRootTypeMapper); $errorRootTypeMapper = new FinalRootTypeMapper($this->getTypeMapper()); $rootTypeMapper = new BaseTypeMapper( diff --git a/tests/Fixtures/Integration/Models/Blog.php b/tests/Fixtures/Integration/Models/Blog.php index 80812119f2..6ced0ee506 100644 --- a/tests/Fixtures/Integration/Models/Blog.php +++ b/tests/Fixtures/Integration/Models/Blog.php @@ -80,4 +80,10 @@ public static function prefetchSubBlogs(iterable $blogs): array return $subBlogs; } + + /** @return callable(): User */ + #[Field] + public function author(): callable { + return fn () => new User('Author', 'author@graphqlite'); + } } diff --git a/tests/Integration/EndToEndTest.php b/tests/Integration/EndToEndTest.php index 2423be089d..27503359b1 100644 --- a/tests/Integration/EndToEndTest.php +++ b/tests/Integration/EndToEndTest.php @@ -2359,6 +2359,9 @@ public function testPrefetchingOfSameTypeInDifferentNestingLevels(): void } } } + author { + email + } posts { title comments { @@ -2399,6 +2402,9 @@ public function testPrefetchingOfSameTypeInDifferentNestingLevels(): void ], ], ], + 'author' => [ + 'email' => 'author@graphqlite', + ], 'posts' => [ [ 'title' => 'post-1.1', @@ -2435,6 +2441,9 @@ public function testPrefetchingOfSameTypeInDifferentNestingLevels(): void ], ], ], + 'author' => [ + 'email' => 'author@graphqlite', + ], 'posts' => [ [ 'title' => 'post-2.1', diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php index 1b1aa796b9..b6b34e0936 100644 --- a/tests/Integration/IntegrationTestCase.php +++ b/tests/Integration/IntegrationTestCase.php @@ -41,6 +41,7 @@ use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapper; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapperInterface; use TheCodingMachine\GraphQLite\Mappers\Root\BaseTypeMapper; +use TheCodingMachine\GraphQLite\Mappers\Root\CallableTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\CompoundTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\EnumTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\FinalRootTypeMapper; @@ -296,10 +297,13 @@ public function createContainer(array $overloadedServices = []): ContainerInterf ); }, RootTypeMapperInterface::class => static function (ContainerInterface $container) { - return new VoidTypeMapper( - new NullableTypeMapperAdapter( - $container->get('topRootTypeMapper') - ) + return new CallableTypeMapper( + new VoidTypeMapper( + new NullableTypeMapperAdapter( + $container->get('topRootTypeMapper') + ) + ), + $container->get('topRootTypeMapper') ); }, 'topRootTypeMapper' => static function () { diff --git a/tests/Mappers/Root/CallableTypeMapperTest.php b/tests/Mappers/Root/CallableTypeMapperTest.php new file mode 100644 index 0000000000..53d9c9d147 --- /dev/null +++ b/tests/Mappers/Root/CallableTypeMapperTest.php @@ -0,0 +1,131 @@ +createMock(RootTypeMapperInterface::class); + $topRootMapper->expects($this->once()) + ->method('toGraphQLOutputType') + ->with($returnType, null, $reflection, $docBlock) + ->willReturn(GraphQLType::string()); + + $mapper = new CallableTypeMapper( + $this->createMock(RootTypeMapperInterface::class), + $topRootMapper, + ); + + $result = $mapper->toGraphQLOutputType(new Callable_(returnType: $returnType), null, $reflection, $docBlock); + + $this->assertSame(GraphQLType::string(), $result); + } + + public function testThrowsWhenUsingCallableWithParameters(): void + { + $this->expectExceptionObject(CannotMapTypeException::createForUnexpectedCallableParameters()); + + $mapper = new CallableTypeMapper( + $this->createMock(RootTypeMapperInterface::class), + $this->createMock(RootTypeMapperInterface::class) + ); + + $type = new Callable_( + parameters: [ + new CallableParameter(new String_()) + ] + ); + + $mapper->toGraphQLOutputType($type, null, new ReflectionMethod(__CLASS__, 'testSkipsNonCallables'), new DocBlock()); + } + + public function testThrowsWhenUsingCallableWithoutReturnType(): void + { + $this->expectExceptionObject(CannotMapTypeException::createForMissingCallableReturnType()); + + $mapper = new CallableTypeMapper( + $this->createMock(RootTypeMapperInterface::class), + $this->createMock(RootTypeMapperInterface::class) + ); + + $mapper->toGraphQLOutputType(new Callable_(), null, new ReflectionMethod(__CLASS__, 'testSkipsNonCallables'), new DocBlock()); + } + + public function testThrowsWhenUsingCallableAsInputType(): void + { + $this->expectExceptionObject(CannotMapTypeException::createForCallableAsInput()); + + $mapper = new CallableTypeMapper( + $this->createMock(RootTypeMapperInterface::class), + $this->createMock(RootTypeMapperInterface::class) + ); + + $mapper->toGraphQLInputType(new Callable_(), null, 'arg1', new ReflectionMethod(__CLASS__, 'testSkipsNonCallables'), new DocBlock()); + } + + #[DataProvider('skipsNonCallablesProvider')] + public function testSkipsNonCallables(callable $createType): void + { + $type = $createType(); + $reflection = new ReflectionMethod(__CLASS__, 'testSkipsNonCallables'); + $docBlock = new DocBlock(); + + $next = $this->createMock(RootTypeMapperInterface::class); + $next->expects($this->once()) + ->method('toGraphQLOutputType') + ->with($type, null, $reflection, $docBlock) + ->willReturn(GraphQLType::string()); + $next->expects($this->once()) + ->method('toGraphQLInputType') + ->with($type, null, 'arg1', $reflection, $docBlock) + ->willReturn(GraphQLType::int()); + $next->expects($this->once()) + ->method('mapNameToType') + ->with('Name') + ->willReturn(GraphQLType::float()); + + $mapper = new CallableTypeMapper($next, $this->createMock(RootTypeMapperInterface::class)); + + $this->assertSame(GraphQLType::string(), $mapper->toGraphQLOutputType($type, null, $reflection, $docBlock)); + $this->assertSame(GraphQLType::int(), $mapper->toGraphQLInputType($type, null, 'arg1', $reflection, $docBlock)); + $this->assertSame(GraphQLType::float(), $mapper->mapNameToType('Name')); + } + + public static function skipsNonCallablesProvider(): iterable + { + yield [fn () => new Object_()]; + yield [fn () => new Array_()]; + yield [fn () => new String_()]; + } +} diff --git a/tests/QueryFieldTest.php b/tests/QueryFieldTest.php index 38b0f19f3b..a3e05f69f1 100644 --- a/tests/QueryFieldTest.php +++ b/tests/QueryFieldTest.php @@ -5,6 +5,9 @@ namespace TheCodingMachine\GraphQLite; use GraphQL\Error\Error; +use GraphQL\Executor\Promise\Adapter\SyncPromise; +use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter; +use GraphQL\Executor\Promise\Promise; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; use PHPUnit\Framework\TestCase; @@ -45,4 +48,21 @@ public function testParametersDescription(): void $this->assertEquals('Foo argument', $queryField->args[0]->description); } + + public function testWrapsCallableInDeferred(): void + { + $sourceResolver = new ServiceResolver(static fn () => function () { + return 123; + }); + $queryField = new QueryField('foo', Type::string(), [], $sourceResolver, $sourceResolver, null, null, []); + + $deferred = ($queryField->resolveFn)(null, [], null, $this->createStub(ResolveInfo::class)); + + $this->assertInstanceOf(SyncPromise::class, $deferred); + + $syncPromiseAdapter = new SyncPromiseAdapter(); + $syncPromiseAdapter->wait(new Promise($deferred, $syncPromiseAdapter)); + + $this->assertSame(123, $deferred->result); + } } diff --git a/website/docs/type-mapping.mdx b/website/docs/type-mapping.mdx index d5b6c4dc29..89f7acc557 100644 --- a/website/docs/type-mapping.mdx +++ b/website/docs/type-mapping.mdx @@ -332,12 +332,11 @@ query { ## Promise mapping -GraphQL includes a native \GraphQL\Deferred type. -You can map the return type by adding a detailed `@return` statement in the PHPDoc. -An alternative to the `@return` statement is using `#[Field(outputType: SomeGQLType)]`. +You can defer execution of fields by returning a callable. To specify the field type, add a `@return` PHPDoc annotation +with a return type, like so: `@return callable(): YourTypeHere`. The callable must not have any parameters. -All the previously mentioned mappings work with Promises, except when a return type is explicitly declared -in the method signature. +An alternative way is to return `\GraphQL\Deferred` instances, along with specifying the type through the +`outputType` parameter of field attributes: `#[Field(outputType: SomeGQLType)]`. This allows you to use \Overblog\DataLoader\DataLoader as an alternative for resolving N+1 query issues and caching intermediate results. @@ -349,12 +348,12 @@ class Product // ... /** - * @return string + * @return callable(): string */ #[Field] - public function getName(): Deferred + public function getName(): callable { - return new Deferred(fn() => $this->name); + return fn() => $this->name; } #[Field(outputType: "Float")]