diff --git a/src/Planner/SelectionSetPlanner.php b/src/Planner/SelectionSetPlanner.php index 2fa718c..3f2e6b3 100644 --- a/src/Planner/SelectionSetPlanner.php +++ b/src/Planner/SelectionSetPlanner.php @@ -1130,9 +1130,20 @@ private function collectRequiredFieldsFromSelectionSet( ) : void { foreach ($selectionSet->selections as $selection) { if ($selection instanceof FieldNode && $selection->name->value !== '__typename') { - // Hook fields are synthesized client-side and never present in the raw response, - // so they must not be used as presence guards for discriminating union/interface variants. - if ($this->directiveProcessor->getHookDirective($selection->directives) !== null) { + // The synthetic hook field itself is never present in the raw response, so it + // cannot guard the variant. But the data the hook `requires` IS injected into + // the operation and returned by the server, so those fields must guard the + // variant — otherwise the parent's optional payload offsets are never narrowed + // for the leaf class that consumes them. + $hookName = $this->directiveProcessor->getHookDirective($selection->directives); + + if ($hookName !== null) { + $hook = $this->config->hooks[$hookName] ?? null; + + if ($hook !== null) { + $this->collectRequiredFieldsFromSelectionSet($hook->requiresFragment->selectionSet, $requiredFields); + } + continue; } diff --git a/tests/HooksInterface/FindOwnerHook.php b/tests/HooksInterface/FindOwnerHook.php new file mode 100644 index 0000000..7cd2040 --- /dev/null +++ b/tests/HooksInterface/FindOwnerHook.php @@ -0,0 +1,31 @@ + $users + */ + public function __construct( + private array $users = [], + ) {} + + public function __invoke(ProjectOwnerId $project) : ?User + { + return $this->users[$project->ownerId] ?? null; + } +} diff --git a/tests/HooksInterface/Generated/Hook/ProjectOwnerId.php b/tests/HooksInterface/Generated/Hook/ProjectOwnerId.php new file mode 100644 index 0000000..cc35ebc --- /dev/null +++ b/tests/HooksInterface/Generated/Hook/ProjectOwnerId.php @@ -0,0 +1,24 @@ + $this->ownerId ??= $this->data['ownerId']; + } + + /** + * @param array{ + * 'ownerId': string, + * ..., + * } $data + */ + public function __construct( + private readonly array $data, + ) {} +} diff --git a/tests/HooksInterface/Generated/Query/Test/Data.php b/tests/HooksInterface/Generated/Query/Test/Data.php new file mode 100644 index 0000000..e8a0986 --- /dev/null +++ b/tests/HooksInterface/Generated/Query/Test/Data.php @@ -0,0 +1,50 @@ + $this->node ??= new Node($this->data['node'], $this->hooks); + } + + /** + * @var list + */ + public readonly array $errors; + + /** + * @param array{ + * 'node': array{ + * '__typename': string, + * 'ownerId'?: string, + * ..., + * }, + * ..., + * } $data + * @param list $errors + * @param array{ + * 'findOwner': FindOwnerHook, + * ..., + * } $hooks + */ + public function __construct( + private readonly array $data, + array $errors, + private readonly array $hooks, + ) { + $this->errors = array_map(fn(array $error) => new Error($error), $errors); + } +} diff --git a/tests/HooksInterface/Generated/Query/Test/Data/Node.php b/tests/HooksInterface/Generated/Query/Test/Data/Node.php new file mode 100644 index 0000000..08cca76 --- /dev/null +++ b/tests/HooksInterface/Generated/Query/Test/Data/Node.php @@ -0,0 +1,55 @@ +asProject)) { + return $this->asProject; + } + + if ($this->data['__typename'] !== 'Project') { + return $this->asProject = null; + } + + if (! array_key_exists('ownerId', $this->data)) { + return $this->asProject = null; + } + + return $this->asProject = new AsProject($this->data, $this->hooks); + } + } + + /** + * @api + * @phpstan-assert-if-true !null $this->asProject + */ + public bool $isProject { + get => $this->isProject ??= $this->data['__typename'] === 'Project'; + } + + /** + * @param array{ + * '__typename': string, + * 'ownerId'?: string, + * ..., + * } $data + * @param array{ + * 'findOwner': FindOwnerHook, + * ..., + * } $hooks + */ + public function __construct( + private readonly array $data, + private readonly array $hooks, + ) {} +} diff --git a/tests/HooksInterface/Generated/Query/Test/Data/Node/AsProject.php b/tests/HooksInterface/Generated/Query/Test/Data/Node/AsProject.php new file mode 100644 index 0000000..c6300fb --- /dev/null +++ b/tests/HooksInterface/Generated/Query/Test/Data/Node/AsProject.php @@ -0,0 +1,42 @@ + $this->owner ??= $this->hooks['findOwner']->__invoke($this->buildProjectOwnerId()); + } + + /** + * @param array{ + * '__typename': 'Project', + * 'ownerId': string, + * ..., + * } $data + * @param array{ + * 'findOwner': FindOwnerHook, + * ..., + * } $hooks + */ + public function __construct( + private readonly array $data, + private readonly array $hooks, + ) {} + + /** + * @internal + */ + public function buildProjectOwnerId() : ProjectOwnerId + { + return new ProjectOwnerId($this->data); + } +} diff --git a/tests/HooksInterface/Generated/Query/Test/Error.php b/tests/HooksInterface/Generated/Query/Test/Error.php new file mode 100644 index 0000000..5bd208b --- /dev/null +++ b/tests/HooksInterface/Generated/Query/Test/Error.php @@ -0,0 +1,24 @@ +message = $error['debugMessage'] ?? $error['message']; + } +} diff --git a/tests/HooksInterface/Generated/Query/Test/TestQuery.php b/tests/HooksInterface/Generated/Query/Test/TestQuery.php new file mode 100644 index 0000000..42abb62 --- /dev/null +++ b/tests/HooksInterface/Generated/Query/Test/TestQuery.php @@ -0,0 +1,57 @@ +client->graphql( + self::OPERATION_DEFINITION, + [ + ], + self::OPERATION_NAME, + ); + + return new Data( + $data['data'] ?? [], // @phpstan-ignore argument.type + $data['errors'] ?? [], // @phpstan-ignore argument.type + $this->hooks, + ); + } +} diff --git a/tests/HooksInterface/HooksInterfaceTest.php b/tests/HooksInterface/HooksInterfaceTest.php new file mode 100644 index 0000000..8de4076 --- /dev/null +++ b/tests/HooksInterface/HooksInterfaceTest.php @@ -0,0 +1,50 @@ +withHook(FindOwnerHook::class); + } + + public function testGenerate() : void + { + $this->assertActualMatchesExpected(); + } + + public function testQuery() : void + { + $findOwner = new FindOwnerHook([ + 'user-123' => new User('user-123'), + ]); + + $result = new TestQuery( + $this->getClient([ + 'data' => [ + 'node' => [ + '__typename' => 'Project', + 'ownerId' => 'user-123', + ], + ], + ]), + [ + 'findOwner' => $findOwner, + ], + )->execute(); + + self::assertNotNull($result->node->asProject); + self::assertNotNull($result->node->asProject->owner); + self::assertSame('user-123', $result->node->asProject->owner->id); + } +} diff --git a/tests/HooksInterface/Schema.graphql b/tests/HooksInterface/Schema.graphql new file mode 100644 index 0000000..884137f --- /dev/null +++ b/tests/HooksInterface/Schema.graphql @@ -0,0 +1,22 @@ +type Query { + node: Node! +} + +interface Node { + id: ID! +} + +type Project implements Node { + id: ID! + name: String! + ownerId: ID! +} + +type Organization implements Node { + id: ID! + title: String! +} + +type User { + id: ID! +} diff --git a/tests/HooksInterface/Test.graphql b/tests/HooksInterface/Test.graphql new file mode 100644 index 0000000..932c559 --- /dev/null +++ b/tests/HooksInterface/Test.graphql @@ -0,0 +1,7 @@ +query Test { + node { + ... on Project { + owner @hook(name: "findOwner") + } + } +} diff --git a/tests/HooksInterface/User.php b/tests/HooksInterface/User.php new file mode 100644 index 0000000..2f17989 --- /dev/null +++ b/tests/HooksInterface/User.php @@ -0,0 +1,12 @@ +