From 5be1f531c53ff07229e308570505244a4c2a06e8 Mon Sep 17 00:00:00 2001 From: Ruud Kamphuis Date: Tue, 9 Jun 2026 14:23:40 +0200 Subject: [PATCH] Guard inline-fragment getters on hook-required fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `@hook` placed inside `... on ConcreteType` on an interface or union generated a parent `asConcreteType` getter that handed its own `$data` straight to the leaf constructor. The leaf requires the fields the hook `requires` — they are injected into the operation and returned by the server — but the parent types them as optional offsets, since they sit behind a type condition. PHPStan then rejected the construction with an `argument.type` error, so any such query failed analysis at max level. Required-field collection skipped every `@hook` field. That is correct for the synthetic hook field itself, which is never in the response, but it also dropped the hook's `requires` fields. Those now feed the variant's presence guards, so the getter narrows them with `array_key_exists` before constructing the leaf. --- src/Planner/SelectionSetPlanner.php | 17 +++++- tests/HooksInterface/FindOwnerHook.php | 31 ++++++++++ .../Generated/Hook/ProjectOwnerId.php | 24 ++++++++ .../Generated/Query/Test/Data.php | 50 ++++++++++++++++ .../Generated/Query/Test/Data/Node.php | 55 ++++++++++++++++++ .../Query/Test/Data/Node/AsProject.php | 42 ++++++++++++++ .../Generated/Query/Test/Error.php | 24 ++++++++ .../Generated/Query/Test/TestQuery.php | 57 +++++++++++++++++++ tests/HooksInterface/HooksInterfaceTest.php | 50 ++++++++++++++++ tests/HooksInterface/Schema.graphql | 22 +++++++ tests/HooksInterface/Test.graphql | 7 +++ tests/HooksInterface/User.php | 12 ++++ 12 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 tests/HooksInterface/FindOwnerHook.php create mode 100644 tests/HooksInterface/Generated/Hook/ProjectOwnerId.php create mode 100644 tests/HooksInterface/Generated/Query/Test/Data.php create mode 100644 tests/HooksInterface/Generated/Query/Test/Data/Node.php create mode 100644 tests/HooksInterface/Generated/Query/Test/Data/Node/AsProject.php create mode 100644 tests/HooksInterface/Generated/Query/Test/Error.php create mode 100644 tests/HooksInterface/Generated/Query/Test/TestQuery.php create mode 100644 tests/HooksInterface/HooksInterfaceTest.php create mode 100644 tests/HooksInterface/Schema.graphql create mode 100644 tests/HooksInterface/Test.graphql create mode 100644 tests/HooksInterface/User.php 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 @@ +