Skip to content

Commit d64cb99

Browse files
authored
Allow callable() as field deferring mechanism (#739)
* Allow callable() as field deferring mechanism * Improve coverage * Bump dependencies for proper callable() type support * Revert deferred test, add a separate one for callable
1 parent 6e36bfd commit d64cb99

File tree

13 files changed

+279
-15
lines changed

13 files changed

+279
-15
lines changed

composer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
"php": ">=8.1",
1414
"ext-json": "*",
1515
"composer/package-versions-deprecated": "^1.8",
16-
"phpdocumentor/reflection-docblock": "^4.3 || ^5.0",
17-
"phpdocumentor/type-resolver": "^1.4",
16+
"phpdocumentor/reflection-docblock": "^5.4",
17+
"phpdocumentor/type-resolver": "^1.7",
1818
"psr/container": "^1.1 || ^2",
1919
"psr/http-factory": "^1",
2020
"psr/http-message": "^1.0.1 || ^2.0",

src/Mappers/CannotMapTypeException.php

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use GraphQL\Type\Definition\Type;
1414
use phpDocumentor\Reflection\Type as PhpDocumentorType;
1515
use phpDocumentor\Reflection\Types\Array_;
16+
use phpDocumentor\Reflection\Types\Callable_;
1617
use phpDocumentor\Reflection\Types\Iterable_;
1718
use phpDocumentor\Reflection\Types\Mixed_;
1819
use phpDocumentor\Reflection\Types\Object_;
@@ -124,7 +125,7 @@ public static function extendTypeWithBadTargetedClass(string $className, ExtendT
124125
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).');
125126
}
126127

127-
/** @param Array_|Iterable_|Object_|Mixed_ $type */
128+
/** @param Array_|Iterable_|Object_|Mixed_|Callable_ $type */
128129
public static function createForMissingPhpDoc(PhpDocumentorType $type, ReflectionMethod|ReflectionProperty $reflector, string|null $argumentName = null): self
129130
{
130131
$typeStr = '';
@@ -136,6 +137,8 @@ public static function createForMissingPhpDoc(PhpDocumentorType $type, Reflectio
136137
$typeStr = sprintf('object ("%s")', $type->getFqsen());
137138
} elseif ($type instanceof Mixed_) {
138139
$typeStr = 'mixed';
140+
} elseif ($type instanceof Callable_) {
141+
$typeStr = 'callable';
139142
}
140143
assert($typeStr !== '');
141144
if ($argumentName === null) {
@@ -177,4 +180,19 @@ public static function createForNonNullReturnByTypeMapper(): self
177180
{
178181
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.');
179182
}
183+
184+
public static function createForUnexpectedCallableParameters(): self
185+
{
186+
return new self('callable() type-hint must not specify any parameters.');
187+
}
188+
189+
public static function createForMissingCallableReturnType(): self
190+
{
191+
return new self('callable() type-hint must specify its return type. For instance: callable(): int');
192+
}
193+
194+
public static function createForCallableAsInput(): self
195+
{
196+
return new self('callable() type-hint can only be used as output type.');
197+
}
180198
}

src/Mappers/Parameters/TypeHandler.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use phpDocumentor\Reflection\Type;
1717
use phpDocumentor\Reflection\TypeResolver as PhpDocumentorTypeResolver;
1818
use phpDocumentor\Reflection\Types\Array_;
19+
use phpDocumentor\Reflection\Types\Callable_;
1920
use phpDocumentor\Reflection\Types\Collection;
2021
use phpDocumentor\Reflection\Types\Compound;
2122
use phpDocumentor\Reflection\Types\Iterable_;
@@ -375,6 +376,7 @@ private function mapType(
375376
$innerType instanceof Array_
376377
|| $innerType instanceof Iterable_
377378
|| $innerType instanceof Mixed_
379+
|| $innerType instanceof Callable_
378380
// Try to match generic phpdoc-provided iterables with non-generic return-type-provided iterables
379381
// Example: (return type `\ArrayObject`, phpdoc `\ArrayObject<string, TestObject>`)
380382
|| ($innerType instanceof Object_
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite\Mappers\Root;
6+
7+
use GraphQL\Type\Definition\InputType;
8+
use GraphQL\Type\Definition\NamedType;
9+
use GraphQL\Type\Definition\OutputType;
10+
use GraphQL\Type\Definition\Type as GraphQLType;
11+
use phpDocumentor\Reflection\DocBlock;
12+
use phpDocumentor\Reflection\Type;
13+
use phpDocumentor\Reflection\Types\Callable_;
14+
use ReflectionMethod;
15+
use ReflectionProperty;
16+
use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException;
17+
18+
/**
19+
* This mapper maps callable types into their return types, so that fields can defer their execution.
20+
*/
21+
class CallableTypeMapper implements RootTypeMapperInterface
22+
{
23+
public function __construct(
24+
private readonly RootTypeMapperInterface $next,
25+
private readonly RootTypeMapperInterface $topRootTypeMapper,
26+
) {
27+
}
28+
29+
public function toGraphQLOutputType(Type $type, OutputType|null $subType, ReflectionMethod|ReflectionProperty $reflector, DocBlock $docBlockObj): OutputType&GraphQLType
30+
{
31+
if (! $type instanceof Callable_) {
32+
return $this->next->toGraphQLOutputType($type, $subType, $reflector, $docBlockObj);
33+
}
34+
35+
if ($type->getParameters()) {
36+
throw CannotMapTypeException::createForUnexpectedCallableParameters();
37+
}
38+
39+
$returnType = $type->getReturnType();
40+
41+
if (! $returnType) {
42+
throw CannotMapTypeException::createForMissingCallableReturnType();
43+
}
44+
45+
return $this->topRootTypeMapper->toGraphQLOutputType($returnType, null, $reflector, $docBlockObj);
46+
}
47+
48+
public function toGraphQLInputType(Type $type, InputType|null $subType, string $argumentName, ReflectionMethod|ReflectionProperty $reflector, DocBlock $docBlockObj): InputType&GraphQLType
49+
{
50+
if (! $type instanceof Callable_) {
51+
return $this->next->toGraphQLInputType($type, $subType, $argumentName, $reflector, $docBlockObj);
52+
}
53+
54+
throw CannotMapTypeException::createForCallableAsInput();
55+
}
56+
57+
public function mapNameToType(string $typeName): NamedType&GraphQLType
58+
{
59+
return $this->next->mapNameToType($typeName);
60+
}
61+
}

src/QueryField.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace TheCodingMachine\GraphQLite;
66

77
use Closure;
8+
use GraphQL\Deferred;
89
use GraphQL\Error\ClientAware;
910
use GraphQL\Executor\Promise\Adapter\SyncPromise;
1011
use GraphQL\Language\AST\FieldDefinitionNode;
@@ -20,6 +21,8 @@
2021
use TheCodingMachine\GraphQLite\Parameters\ParameterInterface;
2122
use TheCodingMachine\GraphQLite\Parameters\SourceParameter;
2223

24+
use function is_callable;
25+
2326
/**
2427
* A GraphQL field that maps to a PHP method automatically.
2528
*
@@ -92,6 +95,13 @@ public function __construct(
9295

9396
private function resolveWithPromise(mixed $result, ResolverInterface $originalResolver): mixed
9497
{
98+
// Shorthand for deferring field execution. This does two things:
99+
// - removes the dependency on `GraphQL\Deferred` from user land code
100+
// - allows inferring the type from PHPDoc (callable(): Type), unlike Deferred, which is not generic
101+
if (is_callable($result)) {
102+
$result = new Deferred($result);
103+
}
104+
95105
if ($result instanceof SyncPromise) {
96106
return $result->then(fn ($resolvedValue) => $this->resolveWithPromise($resolvedValue, $originalResolver));
97107
}

src/SchemaFactory.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
use TheCodingMachine\GraphQLite\Mappers\PorpaginasTypeMapper;
3636
use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapper;
3737
use TheCodingMachine\GraphQLite\Mappers\Root\BaseTypeMapper;
38+
use TheCodingMachine\GraphQLite\Mappers\Root\CallableTypeMapper;
3839
use TheCodingMachine\GraphQLite\Mappers\Root\CompoundTypeMapper;
3940
use TheCodingMachine\GraphQLite\Mappers\Root\EnumTypeMapper;
4041
use TheCodingMachine\GraphQLite\Mappers\Root\FinalRootTypeMapper;
@@ -399,6 +400,7 @@ public function createSchema(): Schema
399400
$lastTopRootTypeMapper = new LastDelegatingTypeMapper();
400401
$topRootTypeMapper = new NullableTypeMapperAdapter($lastTopRootTypeMapper);
401402
$topRootTypeMapper = new VoidTypeMapper($topRootTypeMapper);
403+
$topRootTypeMapper = new CallableTypeMapper($topRootTypeMapper, $lastTopRootTypeMapper);
402404

403405
$errorRootTypeMapper = new FinalRootTypeMapper($recursiveTypeMapper);
404406
$rootTypeMapper = new BaseTypeMapper($errorRootTypeMapper, $recursiveTypeMapper, $topRootTypeMapper);

tests/AbstractQueryProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
use TheCodingMachine\GraphQLite\Mappers\Parameters\ResolveInfoParameterHandler;
4242
use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapper;
4343
use TheCodingMachine\GraphQLite\Mappers\Root\BaseTypeMapper;
44+
use TheCodingMachine\GraphQLite\Mappers\Root\CallableTypeMapper;
4445
use TheCodingMachine\GraphQLite\Mappers\Root\CompoundTypeMapper;
4546
use TheCodingMachine\GraphQLite\Mappers\Root\EnumTypeMapper;
4647
use TheCodingMachine\GraphQLite\Mappers\Root\FinalRootTypeMapper;
@@ -359,6 +360,7 @@ protected function buildRootTypeMapper(): RootTypeMapperInterface
359360
$lastTopRootTypeMapper = new LastDelegatingTypeMapper();
360361
$topRootTypeMapper = new NullableTypeMapperAdapter($lastTopRootTypeMapper);
361362
$topRootTypeMapper = new VoidTypeMapper($topRootTypeMapper);
363+
$topRootTypeMapper = new CallableTypeMapper($topRootTypeMapper, $lastTopRootTypeMapper);
362364

363365
$errorRootTypeMapper = new FinalRootTypeMapper($this->getTypeMapper());
364366
$rootTypeMapper = new BaseTypeMapper(

tests/Fixtures/Integration/Models/Blog.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,10 @@ public static function prefetchSubBlogs(iterable $blogs): array
8080

8181
return $subBlogs;
8282
}
83+
84+
/** @return callable(): User */
85+
#[Field]
86+
public function author(): callable {
87+
return fn () => new User('Author', 'author@graphqlite');
88+
}
8389
}

tests/Integration/EndToEndTest.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2359,6 +2359,9 @@ public function testPrefetchingOfSameTypeInDifferentNestingLevels(): void
23592359
}
23602360
}
23612361
}
2362+
author {
2363+
email
2364+
}
23622365
posts {
23632366
title
23642367
comments {
@@ -2399,6 +2402,9 @@ public function testPrefetchingOfSameTypeInDifferentNestingLevels(): void
23992402
],
24002403
],
24012404
],
2405+
'author' => [
2406+
'email' => 'author@graphqlite',
2407+
],
24022408
'posts' => [
24032409
[
24042410
'title' => 'post-1.1',
@@ -2435,6 +2441,9 @@ public function testPrefetchingOfSameTypeInDifferentNestingLevels(): void
24352441
],
24362442
],
24372443
],
2444+
'author' => [
2445+
'email' => 'author@graphqlite',
2446+
],
24382447
'posts' => [
24392448
[
24402449
'title' => 'post-2.1',

tests/Integration/IntegrationTestCase.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapper;
4242
use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapperInterface;
4343
use TheCodingMachine\GraphQLite\Mappers\Root\BaseTypeMapper;
44+
use TheCodingMachine\GraphQLite\Mappers\Root\CallableTypeMapper;
4445
use TheCodingMachine\GraphQLite\Mappers\Root\CompoundTypeMapper;
4546
use TheCodingMachine\GraphQLite\Mappers\Root\EnumTypeMapper;
4647
use TheCodingMachine\GraphQLite\Mappers\Root\FinalRootTypeMapper;
@@ -296,10 +297,13 @@ public function createContainer(array $overloadedServices = []): ContainerInterf
296297
);
297298
},
298299
RootTypeMapperInterface::class => static function (ContainerInterface $container) {
299-
return new VoidTypeMapper(
300-
new NullableTypeMapperAdapter(
301-
$container->get('topRootTypeMapper')
302-
)
300+
return new CallableTypeMapper(
301+
new VoidTypeMapper(
302+
new NullableTypeMapperAdapter(
303+
$container->get('topRootTypeMapper')
304+
)
305+
),
306+
$container->get('topRootTypeMapper')
303307
);
304308
},
305309
'topRootTypeMapper' => static function () {

0 commit comments

Comments
 (0)