Skip to content

Commit 03499a3

Browse files
sudevvaAndrii Sudiev
andauthored
Fix bug with prefetching on different nesting levels (#702)
* Fix bug with prefetching on different nesting levels Allow Promise to be returned from prefetchMethod with returnRequested: true * revert to previous Prefetch behavior * allow promise to be returned in methods * add nesting preload test * CR fixes * CR fixes * add Promise type mapping docs --------- Co-authored-by: Andrii Sudiev <[email protected]>
1 parent 68de6c9 commit 03499a3

File tree

12 files changed

+408
-60
lines changed

12 files changed

+408
-60
lines changed

src/Parameters/PrefetchDataParameter.php

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,24 +52,31 @@ public function resolve(object|null $source, array $args, mixed $context, Resolv
5252
// So we record all of these ->resolve() calls, collect them together and when a value is actually
5353
// needed, GraphQL calls the callback of Deferred below. That's when we call the prefetch method,
5454
// already knowing all the requested fields (source-arguments combinations).
55-
return new Deferred(function () use ($info, $context, $args, $prefetchBuffer) {
56-
if (! $prefetchBuffer->hasResult($args, $info)) {
57-
$prefetchResult = $this->computePrefetch($args, $context, $info, $prefetchBuffer);
58-
59-
$prefetchBuffer->storeResult($prefetchResult, $args, $info);
55+
return new Deferred(function () use ($source, $info, $context, $args, $prefetchBuffer) {
56+
if (! $prefetchBuffer->hasResult($source)) {
57+
$this->processPrefetch($args, $context, $info, $prefetchBuffer);
6058
}
6159

62-
return $prefetchResult ?? $prefetchBuffer->getResult($args, $info);
60+
$result = $prefetchBuffer->getResult($source);
61+
// clear internal storage
62+
$prefetchBuffer->purgeResult($source);
63+
return $result;
6364
});
6465
}
6566

6667
/** @param array<string, mixed> $args */
67-
private function computePrefetch(array $args, mixed $context, ResolveInfo $info, PrefetchBuffer $prefetchBuffer): mixed
68+
private function processPrefetch(array $args, mixed $context, ResolveInfo $info, PrefetchBuffer $prefetchBuffer): void
6869
{
6970
$sources = $prefetchBuffer->getObjectsByArguments($args, $info);
71+
$prefetchBuffer->purge($args, $info);
7072
$toPassPrefetchArgs = QueryField::paramsToArguments($this->fieldName, $this->parameters, null, $args, $context, $info, $this->resolver);
7173

72-
return ($this->resolver)($sources, ...$toPassPrefetchArgs);
74+
$resolvedValues = ($this->resolver)($sources, ...$toPassPrefetchArgs);
75+
76+
foreach ($sources as $source) {
77+
// map results to each source to support old prefetch behavior
78+
$prefetchBuffer->storeResult($source, $resolvedValues);
79+
}
7380
}
7481

7582
/** @inheritDoc */

src/PrefetchBuffer.php

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
namespace TheCodingMachine\GraphQLite;
66

77
use GraphQL\Type\Definition\ResolveInfo;
8+
use SplObjectStorage;
89

9-
use function array_key_exists;
1010
use function md5;
1111
use function serialize;
1212

@@ -18,8 +18,13 @@ class PrefetchBuffer
1818
/** @var array<string, array<int, object>> An array of buffered, indexed by hash of arguments. */
1919
private array $objects = [];
2020

21-
/** @var array<string, mixed> An array of prefetch method results, indexed by hash of arguments. */
22-
private array $results = [];
21+
/** @var SplObjectStorage A Storage of prefetch method results, holds source to resolved values. */
22+
private SplObjectStorage $results;
23+
24+
public function __construct()
25+
{
26+
$this->results = new SplObjectStorage();
27+
}
2328

2429
/** @param array<array-key, mixed> $arguments The input arguments passed from GraphQL to the field. */
2530
public function register(
@@ -66,28 +71,28 @@ public function purge(
6671
unset($this->objects[$this->computeHash($arguments, $info)]);
6772
}
6873

69-
/** @param array<array-key, mixed> $arguments The input arguments passed from GraphQL to the field. */
7074
public function storeResult(
75+
object $source,
7176
mixed $result,
72-
array $arguments,
73-
ResolveInfo|null $info = null,
7477
): void {
75-
$this->results[$this->computeHash($arguments, $info)] = $result;
78+
$this->results->offsetSet($source, $result);
7679
}
7780

78-
/** @param array<array-key, mixed> $arguments The input arguments passed from GraphQL to the field. */
7981
public function hasResult(
80-
array $arguments,
81-
ResolveInfo|null $info = null,
82+
object $source,
8283
): bool {
83-
return array_key_exists($this->computeHash($arguments, $info), $this->results);
84+
return $this->results->offsetExists($source);
8485
}
8586

86-
/** @param array<array-key, mixed> $arguments The input arguments passed from GraphQL to the field. */
8787
public function getResult(
88-
array $arguments,
89-
ResolveInfo|null $info = null,
88+
object $source,
9089
): mixed {
91-
return $this->results[$this->computeHash($arguments, $info)];
90+
return $this->results->offsetGet($source);
91+
}
92+
93+
public function purgeResult(
94+
object $source,
95+
): void {
96+
$this->results->offsetUnset($source);
9297
}
9398
}

src/QueryField.php

Lines changed: 40 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,9 @@
44

55
namespace TheCodingMachine\GraphQLite;
66

7-
use GraphQL\Deferred;
7+
use Closure;
88
use GraphQL\Error\ClientAware;
99
use GraphQL\Executor\Promise\Adapter\SyncPromise;
10-
use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter;
11-
use GraphQL\Executor\Promise\Promise;
1210
use GraphQL\Language\AST\FieldDefinitionNode;
1311
use GraphQL\Type\Definition\FieldDefinition;
1412
use GraphQL\Type\Definition\ListOfType;
@@ -22,9 +20,6 @@
2220
use TheCodingMachine\GraphQLite\Parameters\ParameterInterface;
2321
use TheCodingMachine\GraphQLite\Parameters\SourceParameter;
2422

25-
use function array_filter;
26-
use function array_map;
27-
2823
/**
2924
* A GraphQL field that maps to a PHP method automatically.
3025
*
@@ -79,42 +74,39 @@ public function __construct(
7974
$callResolver = function (...$args) use ($originalResolver, $source, $resolver) {
8075
$result = $resolver($source, ...$args);
8176

82-
try {
83-
$this->assertReturnType($result);
84-
} catch (TypeMismatchRuntimeException $e) {
85-
$e->addInfo($this->name, $originalResolver->toString());
86-
87-
throw $e;
88-
}
89-
90-
return $result;
77+
return $this->resolveWithPromise($result, $originalResolver);
9178
};
9279

93-
$deferred = (bool) array_filter($toPassArgs, static fn (mixed $value) => $value instanceof SyncPromise);
94-
9580
// GraphQL allows deferring resolving the field's value using promises, i.e. they call the resolve
9681
// function ahead of time for all of the fields (allowing us to gather all calls and do something
9782
// in batch, like prefetch) and then resolve the promises as needed. To support that for prefetch,
9883
// we're checking if any of the resolved parameters returned a promise. If they did, we know
9984
// that the value should also be resolved using a promise, so we're wrapping it in one.
100-
return $deferred ? new Deferred(static function () use ($toPassArgs, $callResolver) {
101-
$syncPromiseAdapter = new SyncPromiseAdapter();
102-
103-
// Wait for every deferred parameter.
104-
$toPassArgs = array_map(
105-
static fn (mixed $value) => $value instanceof SyncPromise ? $syncPromiseAdapter->wait(new Promise($value, $syncPromiseAdapter)) : $value,
106-
$toPassArgs,
107-
);
108-
109-
return $callResolver(...$toPassArgs);
110-
}) : $callResolver(...$toPassArgs);
85+
return $this->deferred($toPassArgs, $callResolver);
11186
};
11287

11388
$config += $additionalConfig;
11489

11590
parent::__construct($config);
11691
}
11792

93+
private function resolveWithPromise(mixed $result, ResolverInterface $originalResolver): mixed
94+
{
95+
if ($result instanceof SyncPromise) {
96+
return $result->then(fn ($resolvedValue) => $this->resolveWithPromise($resolvedValue, $originalResolver));
97+
}
98+
99+
try {
100+
$this->assertReturnType($result);
101+
} catch (TypeMismatchRuntimeException $e) {
102+
$e->addInfo($this->name, $originalResolver->toString());
103+
104+
throw $e;
105+
}
106+
107+
return $result;
108+
}
109+
118110
/**
119111
* This method checks the returned value of the resolver to be sure it matches the documented return type.
120112
* We are sure the returned value is of the correct type... except if the return type is type-hinted as an array.
@@ -204,4 +196,24 @@ public static function paramsToArguments(string $name, array $parameters, object
204196

205197
return $toPassArgs;
206198
}
199+
200+
/**
201+
* @param array<int, mixed> $toPassArgs
202+
* Create Deferred if any of arguments contain promise
203+
*/
204+
public static function deferred(array $toPassArgs, Closure $callResolver): mixed
205+
{
206+
$deferredArgument = null;
207+
foreach ($toPassArgs as $position => $toPassArg) {
208+
if ($toPassArg instanceof SyncPromise) {
209+
$deferredArgument = $toPassArg->then(static function ($resolvedValue) use ($toPassArgs, $position, $callResolver) {
210+
$toPassArgs[$position] = $resolvedValue;
211+
return self::deferred($toPassArgs, $callResolver);
212+
});
213+
break;
214+
}
215+
}
216+
217+
return $deferredArgument ?? $callResolver(...$toPassArgs);
218+
}
207219
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite\Fixtures\Integration\Controllers;
6+
7+
use TheCodingMachine\GraphQLite\Annotations\Query;
8+
use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Blog;
9+
10+
class BlogController
11+
{
12+
/** @return Blog[] */
13+
#[Query]
14+
public function getBlogs(): array
15+
{
16+
return [
17+
new Blog(1),
18+
new Blog(2),
19+
];
20+
}
21+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite\Fixtures\Integration\Models;
6+
7+
use GraphQL\Deferred;
8+
use TheCodingMachine\GraphQLite\Annotations\Field;
9+
use TheCodingMachine\GraphQLite\Annotations\Prefetch;
10+
use TheCodingMachine\GraphQLite\Annotations\Type;
11+
12+
#[Type]
13+
class Blog
14+
{
15+
public function __construct(
16+
private readonly int $id,
17+
) {
18+
}
19+
20+
#[Field(outputType: 'ID!')]
21+
public function getId(): int
22+
{
23+
return $this->id;
24+
}
25+
26+
/**
27+
* @param Post[][] $prefetchedPosts
28+
*
29+
* @return Post[]
30+
*/
31+
#[Field]
32+
public function getPosts(
33+
#[Prefetch('prefetchPosts')]
34+
array $prefetchedPosts,
35+
): array {
36+
return $prefetchedPosts[$this->id];
37+
}
38+
39+
/** @param Blog[][] $prefetchedSubBlogs */
40+
#[Field(outputType: '[Blog!]!')]
41+
public function getSubBlogs(
42+
#[Prefetch('prefetchSubBlogs')]
43+
array $prefetchedSubBlogs,
44+
): Deferred {
45+
return new Deferred(fn () => $prefetchedSubBlogs[$this->id]);
46+
}
47+
48+
/**
49+
* @param Blog[] $blogs
50+
*
51+
* @return Post[][]
52+
*/
53+
public static function prefetchPosts(iterable $blogs): array
54+
{
55+
$posts = [];
56+
foreach ($blogs as $blog) {
57+
$blogId = $blog->getId();
58+
$posts[$blog->getId()] = [
59+
new Post('post-' . $blogId . '.1'),
60+
new Post('post-' . $blogId . '.2'),
61+
];
62+
}
63+
64+
return $posts;
65+
}
66+
67+
/**
68+
* @param Blog[] $blogs
69+
*
70+
* @return Blog[][]
71+
*/
72+
public static function prefetchSubBlogs(iterable $blogs): array
73+
{
74+
$subBlogs = [];
75+
foreach ($blogs as $blog) {
76+
$blogId = $blog->getId();
77+
$subBlogId = $blogId * 10;
78+
$subBlogs[$blog->id] = [new Blog($subBlogId)];
79+
}
80+
81+
return $subBlogs;
82+
}
83+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite\Fixtures\Integration\Models;
6+
7+
use TheCodingMachine\GraphQLite\Annotations\Field;
8+
use TheCodingMachine\GraphQLite\Annotations\Type;
9+
10+
#[Type]
11+
class Comment
12+
{
13+
public function __construct(
14+
private readonly string $text,
15+
) {
16+
}
17+
18+
#[Field]
19+
public function getText(): string
20+
{
21+
return $this->text;
22+
}
23+
}

tests/Fixtures/Integration/Types/CompanyType.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,27 @@
1313
#[ExtendType(class:Company::class)]
1414
class CompanyType
1515
{
16-
1716
#[Field]
1817
public function getName(Company $company): string
1918
{
2019
return $company->name;
2120
}
2221

22+
/** @param Contact[] $contacts */
2323
#[Field]
2424
public function getContact(
2525
Company $company,
2626
#[Prefetch('prefetchContacts')]
27-
array $contacts
28-
): ?Contact {
27+
array $contacts,
28+
): Contact|null {
2929
return $contacts[$company->name] ?? null;
3030
}
3131

32+
/**
33+
* @param Company[] $companies
34+
*
35+
* @return Contact[]
36+
*/
3237
public static function prefetchContacts(array $companies): array
3338
{
3439
$contacts = [];

0 commit comments

Comments
 (0)