From cf8be050a8b33e23ebf44276c2b8da30253cfe9a Mon Sep 17 00:00:00 2001 From: Ruud Kamphuis Date: Fri, 22 May 2026 15:31:30 +0200 Subject: [PATCH] Introduce self-describing hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@hook(input: ["creator.id"])` forced every call site to know and select the exact fields a hook reads internally. Updating a hook's needs meant touching every query that used it — the call site was coupled to the hook's implementation. A hook now declares the data it needs as a named GraphQL fragment via `#[Hook(name: ..., requires: ...)]`. The call site shrinks to a bare `@hook(name: ...)`. The generator builds a typed data class from the `requires` fragment (emitted into `Generated/Hook/`), validates that the `@hook` field sits on a type the fragment's `on Type` condition allows — an object type, or an interface, in which case any implementer — and injects the required selection into the operation sent to the server. The hook receives the typed object. Fields a hook needs but the caller did not select are merged into the raw payload only, never surfacing as properties on the caller's generated classes, so the typed API stays free of hook internals. Batched hooks (`batched: true`) carry over: the `HookLoader` collects the typed data objects instead of scalar tuples. This is a breaking change — the `@hook(input:)` argument is removed; `DirectiveProcessor` raises a migration error if it is still used. Hooks must move their field list into a `requires` fragment. --- README.md | 102 +++--- src/Attribute/Hook.php | 10 +- src/Config/Config.php | 70 ++++- src/Config/HookDefinition.php | 13 +- src/DirectiveProcessor.php | 33 +- src/Generator/DataClassGenerator.php | 294 +++++------------- .../FragmentDefinitionNodeWithSource.php | 5 +- src/GraphQL/HookDirectiveSchemaExtender.php | 8 +- src/Planner.php | 81 ++++- src/Planner/PayloadShapeBuilder.php | 46 ++- src/Planner/Plan/DataClassPlan.php | 3 +- src/Planner/SelectionSetPlanner.php | 75 ++++- src/Planner/Source/HookInputSource.php | 22 ++ src/Type/ArrayTupleType.php | 27 -- src/Type/HookPropertyType.php | 13 +- src/Type/TypeDumper.php | 7 - src/Visitor/HookFieldInjector.php | 96 ++++++ src/Visitor/HookFieldRemover.php | 53 ---- tests/Console/UnusedHookForTesting.php | 19 +- tests/Hooks/FindUserByIdHook.php | 16 +- .../Hooks/Generated/Hook/ProjectCreatorId.php | 29 ++ .../Hook/ProjectCreatorId/Creator.php | 24 ++ .../Query/Test/Data/Viewer/Project.php | 11 +- .../Hooks/Generated/Query/Test/TestQuery.php | 5 + tests/Hooks/Test.graphql | 2 +- tests/HooksBatched/ComputeAccessHook.php | 20 +- tests/HooksBatched/FindOrgPlanHook.php | 19 +- tests/HooksBatched/FindUserByIdHook.php | 20 +- .../Generated/Hook/OrganizationId.php | 24 ++ .../Generated/Hook/RepositoryAccessFields.php | 29 ++ .../Generated/Hook/RepositoryOwnerId.php | 24 ++ .../Generated/Query/Test/Data.php | 21 +- .../Query/Test/Data/Organization.php | 17 +- .../Test/Data/Organization/Repository.php | 22 +- .../Generated/Query/Test/TestQuery.php | 10 + tests/HooksBatched/HooksBatchedTest.php | 42 +-- tests/HooksBatched/Test.graphql | 6 +- .../HooksInUnionVariant/FindUserByIdHook.php | 14 +- .../Generated/Hook/VariantAId.php | 24 ++ .../Query/Test/Data/Thing/AsVariantA.php | 11 +- .../Generated/Query/Test/TestQuery.php | 3 + tests/HooksInUnionVariant/Test.graphql | 2 +- .../FindDiscountCodeByIdHook.php | 14 +- .../Fragment/ShowPaymentFlow/Order.php | 11 +- .../Generated/Hook/OrderDiscountId.php | 24 ++ .../Generated/Query/Test/TestQuery.php | 3 + .../ShowPaymentFlow.graphql | 2 +- .../FindUserByIdHook.php | 16 +- .../Generated/Hook/ProjectCreatorId.php | 29 ++ .../Hook/ProjectCreatorId/Creator.php | 24 ++ .../Query/Test/Data/Viewer/Project.php | 11 +- .../Generated/Query/Test/TestQuery.php | 5 + .../Test.graphql | 2 +- .../FindUserByIdHook.php | 16 +- .../Generated/Fragment/ProjectSummary.php | 11 +- .../Generated/Hook/ProjectCreatorId.php | 29 ++ .../Hook/ProjectCreatorId/Creator.php | 24 ++ .../Generated/Query/Test/TestQuery.php | 5 + .../ProjectSummary.graphql | 2 +- .../FindOwnerHook.php | 36 +++ .../Generated/Hook/NodeId.php | 24 ++ .../Generated/Query/Test/Data.php | 66 ++++ .../Generated/Query/Test/Data/Article.php | 46 +++ .../Generated/Query/Test/Data/Video.php | 46 +++ .../Generated/Query/Test/Error.php | 24 ++ .../Generated/Query/Test/TestQuery.php | 61 ++++ .../HooksWithInterfaceRequiresTest.php | 70 +++++ tests/HooksWithInterfaceRequires/Owner.php | 13 + .../HooksWithInterfaceRequires/Schema.graphql | 18 ++ tests/HooksWithInterfaceRequires/Test.graphql | 12 + .../FindUsersByIdsHook.php | 15 +- .../Generated/Hook/ProjectContributorIds.php | 27 ++ .../Query/Test/Data/Viewer/Project.php | 11 +- .../Generated/Query/Test/TestQuery.php | 3 + tests/HooksWithListReturn/Test.graphql | 2 +- .../FindUserByIdHook.php | 16 +- .../Generated/Hook/ProjectCreatorId.php | 29 ++ .../Hook/ProjectCreatorId/Creator.php | 24 ++ .../Query/Test/Data/Viewer/Project.php | 11 +- .../Generated/Query/Test/TestQuery.php | 5 + tests/HooksWithSymfonyAutowire/Test.graphql | 2 +- tests/Planner/SelectionSetPlannerTest.php | 4 + 82 files changed, 1661 insertions(+), 504 deletions(-) create mode 100644 src/Planner/Source/HookInputSource.php delete mode 100644 src/Type/ArrayTupleType.php create mode 100644 src/Visitor/HookFieldInjector.php delete mode 100644 src/Visitor/HookFieldRemover.php create mode 100644 tests/Hooks/Generated/Hook/ProjectCreatorId.php create mode 100644 tests/Hooks/Generated/Hook/ProjectCreatorId/Creator.php create mode 100644 tests/HooksBatched/Generated/Hook/OrganizationId.php create mode 100644 tests/HooksBatched/Generated/Hook/RepositoryAccessFields.php create mode 100644 tests/HooksBatched/Generated/Hook/RepositoryOwnerId.php create mode 100644 tests/HooksInUnionVariant/Generated/Hook/VariantAId.php create mode 100644 tests/HooksThroughSoleFragmentSpread/Generated/Hook/OrderDiscountId.php create mode 100644 tests/HooksWithCustomTypeInitializer/Generated/Hook/ProjectCreatorId.php create mode 100644 tests/HooksWithCustomTypeInitializer/Generated/Hook/ProjectCreatorId/Creator.php create mode 100644 tests/HooksWithFragmentSpread/Generated/Hook/ProjectCreatorId.php create mode 100644 tests/HooksWithFragmentSpread/Generated/Hook/ProjectCreatorId/Creator.php create mode 100644 tests/HooksWithInterfaceRequires/FindOwnerHook.php create mode 100644 tests/HooksWithInterfaceRequires/Generated/Hook/NodeId.php create mode 100644 tests/HooksWithInterfaceRequires/Generated/Query/Test/Data.php create mode 100644 tests/HooksWithInterfaceRequires/Generated/Query/Test/Data/Article.php create mode 100644 tests/HooksWithInterfaceRequires/Generated/Query/Test/Data/Video.php create mode 100644 tests/HooksWithInterfaceRequires/Generated/Query/Test/Error.php create mode 100644 tests/HooksWithInterfaceRequires/Generated/Query/Test/TestQuery.php create mode 100644 tests/HooksWithInterfaceRequires/HooksWithInterfaceRequiresTest.php create mode 100644 tests/HooksWithInterfaceRequires/Owner.php create mode 100644 tests/HooksWithInterfaceRequires/Schema.graphql create mode 100644 tests/HooksWithInterfaceRequires/Test.graphql create mode 100644 tests/HooksWithListReturn/Generated/Hook/ProjectContributorIds.php create mode 100644 tests/HooksWithSymfonyAutowire/Generated/Hook/ProjectCreatorId.php create mode 100644 tests/HooksWithSymfonyAutowire/Generated/Hook/ProjectCreatorId/Creator.php diff --git a/README.md b/README.md index efef474..c5c14dc 100644 --- a/README.md +++ b/README.md @@ -891,29 +891,47 @@ Enrich query results with data that does not come from the GraphQL server. Common case: the backend returns an ID, and you want the generated response to also expose the fully hydrated local entity (from your database, cache, etc.) lazily on first access. -**Step 1 — write an invokable hook class** and tag it with `#[Hook(name: ...)]`: +A hook is **self-describing**: it declares the data it needs as a GraphQL fragment, and the +generator injects that selection into your queries automatically. The call site is just +`@hook(name: ...)` — it never has to know what the hook reads internally. + +**Step 1 — write an invokable hook class.** Tag it with `#[Hook(name: ..., requires: ...)]`, +where `requires` is a named GraphQL fragment describing the data the hook needs: ```php namespace App\Hooks; use App\Entity\User; use App\Repository\UserRepository; +use App\Generated\Hook\ProjectCreator; use Ruudk\GraphQLCodeGenerator\Attribute\Hook; -#[Hook(name: 'findUserById')] +#[Hook( + name: 'findUserById', + requires: <<<'GRAPHQL' + fragment ProjectCreator on Project { + creator { + id + } + } + GRAPHQL, +)] final readonly class FindUserByIdHook { public function __construct(private UserRepository $users) {} - public function __invoke(string $id): ?User + public function __invoke(ProjectCreator $project): ?User { - return $this->users->find($id); + return $this->users->find($project->creator->id); } } ``` -The class must define `__invoke`. The return type is inferred from that signature — no need to -declare it in config. +The fragment's **name** (`ProjectCreator`) becomes the generated data class the hook receives — +emitted into `Generated/Hook/`, read through typed properties (so custom scalars arrive fully +instantiated). The fragment's **type condition** (`on Project`) is the type a `@hook` field may +be attached to; it may be an interface, in which case the hook works on any implementer. The +hook's return type is inferred from `__invoke`. **Step 2 — register the hook** in your config: @@ -922,26 +940,25 @@ Config::create(/* ... */) ->withHook(App\Hooks\FindUserByIdHook::class); ``` -**Step 3 — use `@hook` in your query**: +**Step 3 — use `@hook` in your query** — no input list, just the name: ```graphql query Project { project(id: "42") { name - creator { - id - } - # Synthetic field populated by the hook. Positional arguments are - # resolved against the surrounding selection set. - user @hook(name: "findUserById", input: ["creator.id"]) + # Synthetic field populated by the hook. The fields the hook needs + # (creator { id }) are injected into the query automatically. + user @hook(name: "findUserById") } } ``` -The `user` field doesn't exist in the schema — it's a generator-only marker. The `input` list -holds dotted paths into the enclosing selection; each becomes a positional argument to -`__invoke` at runtime. +The `user` field doesn't exist in the schema — it's a generator-only marker. Before the +operation is sent to the server the generator substitutes it with the hook's `requires` +selection, merged with whatever the caller already selected (no duplicate network fields). +Fields the hook needs but the caller didn't ask for stay invisible on the caller's generated +classes — they only feed the hook's data object. **Step 4 — pass hook instances when executing**: @@ -953,10 +970,6 @@ $project = new ProjectQuery($client, [ $project->user; // ?User, resolved lazily by the hook on first access ``` -The generator does not strip the `@hook` directive from validation inputs — it removes hook -fields from the outgoing operation, so the server never sees them. Fragment spreads and `@indexBy` -are unaffected. - **Symfony autowire shortcut.** Call `enableSymfonyAutowireHooks()` on the config and the generated query class's `$hooks` parameter is annotated with `#[Autowire([...])]`, so the DI container wires the map automatically: @@ -972,9 +985,6 @@ public function __construct( ) {} ``` -Hook signature mismatches are caught at generation (return type inference) and by PHPStan at -call sites — if you pass the wrong shape, CI fails before production. - #### ⚡ Batched Hooks (Hook Loaders) By default a hook is resolved **once per object instance**. When a hooked field lives in a @@ -983,46 +993,44 @@ list — or a list nested in a list — the hook fires once per element: a class Opt into **batching** with `batched: true` on the `#[Hook]` attribute. A batched hook is invoked **exactly once per operation**, lazily, on first access of any hooked property. The generator emits a `HookLoader` (into your `Generated/` namespace, zero dependencies) that -walks the typed result graph once, collects every occurrence's input, and calls the hook a -single time with the whole batch. +walks the typed result graph once, collects every occurrence's data object, and calls the hook +a single time with the whole batch. -The `__invoke` signature changes: instead of positional arguments per item, a batched hook -receives **one array of input tuples** and returns/yields the results, echoing back the -integer keys it was given: +The `__invoke` signature changes: a batched hook receives **one array of data objects** and +returns/yields the results, echoing back the integer keys it was given: ```php -use Ruudk\GraphQLCodeGenerator\Attribute\Hook; - -#[Hook(name: 'findUserById', batched: true)] +#[Hook( + name: 'findUserById', + requires: <<<'GRAPHQL' + fragment ProjectCreator on Project { + creator { id } + } + GRAPHQL, + batched: true, +)] final class FindUserByIdHook { public function __construct(private UserRepository $users) {} /** - * @param array $inputs one [id] tuple per occurrence - * @return iterable echo the same integer keys + * @param array $inputs one per occurrence + * @return iterable echo the same integer keys */ public function __invoke(array $inputs): iterable { - foreach ($inputs as $key => [$id]) { - yield $key => $this->users->find($id); + foreach ($inputs as $key => $project) { + yield $key => $this->users->find($project->creator->id); } } } ``` -The query is unchanged — the same `@hook(name: ..., input: [...])` directive drives both -modes. Each input tuple holds the `input` paths in declaration order, read through the typed -properties (so custom scalars arrive fully instantiated). The hook's return value type is -inferred from the `iterable` it yields; declare `@return iterable` so the -generator can read it. - -Notes: -- The loader **de-duplicates inputs by value** before calling the hook — identical input - tuples across the batch collapse to a single entry, so the hook never does the same lookup - twice. -- Omitting `batched` (or `batched: false`) keeps the existing per-instance behaviour — this is - not a breaking change. Legacy and batched hooks can be mixed in one query. +The query is unchanged — the same `@hook(name: ...)` directive drives both modes. The hook's +return value type is inferred from the `iterable` it yields; declare +`@return iterable` so the generator can read it. Omitting `batched` (or +`batched: false`) keeps the per-instance behaviour; legacy and batched hooks can be mixed in +one query. ### 🎭 Inject Extra Arguments with `withOperationArgument()` diff --git a/src/Attribute/Hook.php b/src/Attribute/Hook.php index b2e00de..da096a6 100644 --- a/src/Attribute/Hook.php +++ b/src/Attribute/Hook.php @@ -10,14 +10,22 @@ final readonly class Hook { /** + * @param string $requires A named GraphQL fragment describing the data the hook + * needs, e.g. `fragment RefundApprovalContext on Refund { id }`. + * The fragment name becomes the generated data class the hook + * receives; its type condition is the type the `@hook` field + * may be attached to (an object type, or an interface — then + * any implementer). The generator injects the selection into + * queries automatically; callers just write `@hook(name: ...)`. * @param bool $batched When true, the hook is resolved as a batched "hook loader": * invoked exactly once per operation with every occurrence's - * inputs at once, instead of once per object instance. The + * data object at once, instead of once per object instance. The * `__invoke` signature must then be * `__invoke(array $inputs): iterable`. */ public function __construct( public string $name, + public string $requires, public bool $batched = false, ) {} } diff --git a/src/Config/Config.php b/src/Config/Config.php index bb9a4b0..e5b23d3 100644 --- a/src/Config/Config.php +++ b/src/Config/Config.php @@ -5,8 +5,12 @@ namespace Ruudk\GraphQLCodeGenerator\Config; use Closure; +use GraphQL\Error\SyntaxError; +use GraphQL\Language\AST\FragmentDefinitionNode; +use GraphQL\Language\Parser; use GraphQL\Type\Schema; use InvalidArgumentException; +use JsonException; use ReflectionClass; use ReflectionException; use ReflectionMethod; @@ -284,19 +288,22 @@ public function withTwigProcessingDirectory(string $directory, string ...$direct /** * Register a hook. The class must be invokable (`__invoke`) and must carry - * `#[Hook(name: '...')]` naming the hook for use in `@hook(name: ...)` directives. - * The return type is inferred from the `__invoke` signature. + * `#[Hook(name: '...', requires: self::REQUIRES)]`. * - * A legacy hook is invoked once per object instance with positional arguments. - * A batched hook (`#[Hook(name: '...', batched: true)]`) is invoked exactly once - * per operation: it receives `array` (one input tuple per - * occurrence, integer-keyed by the library) and must return/yield - * `iterable` echoing the same integer keys. + * `requires` is a named GraphQL fragment declaring the data the hook needs; the + * generator injects that selection into queries that use the hook and hands the + * hook a typed object built from it. The return type is inferred from `__invoke`. + * + * A legacy hook is invoked once per object instance: `__invoke(DataClass $input): V`. + * A batched hook (`#[Hook(..., batched: true)]`) is invoked exactly once per + * operation: `__invoke(array $inputs): iterable`, echoing the + * integer keys it was given. * * @param class-string $class * @throws InvalidArgumentException * @throws \Webmozart\Assert\InvalidArgumentException * @throws ReflectionException + * @throws JsonException */ public function withHook(string $class) : self { @@ -320,6 +327,41 @@ public function withHook(string $class) : self $class, )); + try { + $document = Parser::parse($hook->requires); + } catch (SyntaxError $exception) { + throw new InvalidArgumentException( + sprintf('The `requires` of hook "%s" (%s) is not valid GraphQL.', $hookName, $class), + previous: $exception, + ); + } + + Assert::count($document->definitions, 1, sprintf( + 'The `requires` of hook "%s" (%s) must contain exactly one named fragment.', + $hookName, + $class, + )); + + $fragment = $document->definitions[0]; + + Assert::isInstanceOf($fragment, FragmentDefinitionNode::class, sprintf( + 'The `requires` of hook "%s" (%s) must be a named fragment ' + . '(fragment Name on Type { ... }).', + $hookName, + $class, + )); + + $requiresClassName = $fragment->name->value; + + Assert::regex($requiresClassName, '/^[A-Z][a-zA-Z0-9_]*$/', sprintf( + 'The `requires` fragment name "%s" of hook "%s" must be a valid PHP class name.', + $requiresClassName, + $hookName, + )); + + $requiresTypeCondition = $fragment->typeCondition->name->value; + $requiresFqcn = $this->namespace . '\\Hook\\' . $requiresClassName; + $method = new ReflectionMethod($class, '__invoke'); Assert::keyNotExists($this->hooks, $hookName, sprintf( @@ -348,9 +390,10 @@ public function withHook(string $class) : self throw new InvalidArgumentException(sprintf( 'Batched hook "%s" (%s::__invoke) must accept exactly one array argument: ' . 'public function __invoke(array $inputs): iterable. Each entry of $inputs is ' - . "one occurrence's input tuple, keyed by an integer the hook must echo back.", + . 'one occurrence\'s %s, integer-keyed by the library.', $hookName, $class, + $requiresClassName, )); } @@ -367,7 +410,16 @@ public function withHook(string $class) : self } $hooks = $this->hooks; - $hooks[$hookName] = new HookDefinition($hookName, $class, $returnType, $batched); + $hooks[$hookName] = new HookDefinition( + $hookName, + $class, + $returnType, + $batched, + $fragment, + $requiresClassName, + $requiresFqcn, + $requiresTypeCondition, + ); return clone ($this, [ 'hooks' => $hooks, diff --git a/src/Config/HookDefinition.php b/src/Config/HookDefinition.php index 5c28423..35907eb 100644 --- a/src/Config/HookDefinition.php +++ b/src/Config/HookDefinition.php @@ -4,6 +4,7 @@ namespace Ruudk\GraphQLCodeGenerator\Config; +use GraphQL\Language\AST\FragmentDefinitionNode; use Symfony\Component\TypeInfo\Type; final readonly class HookDefinition @@ -13,11 +14,21 @@ * @param Type $returnType For a legacy hook, the `__invoke` return type. For a * batched hook, the value type `V` unwrapped from the * `iterable` return. + * @param FragmentDefinitionNode $requiresFragment The parsed `requires` fragment — + * the data the hook consumes. + * @param string $requiresClassName The fragment name = the generated data class name. + * @param string $requiresFqcn FQCN of the generated data class (`{namespace}\Hook\{name}`). + * @param string $requiresTypeCondition The fragment's `on Type` — the type (object or + * interface) a `@hook(name: ...)` field may be placed on. */ public function __construct( public string $name, public string $class, public Type $returnType, - public bool $batched = false, + public bool $batched, + public FragmentDefinitionNode $requiresFragment, + public string $requiresClassName, + public string $requiresFqcn, + public string $requiresTypeCondition, ) {} } diff --git a/src/DirectiveProcessor.php b/src/DirectiveProcessor.php index 72d7203..4fc1120 100644 --- a/src/DirectiveProcessor.php +++ b/src/DirectiveProcessor.php @@ -5,9 +5,9 @@ namespace Ruudk\GraphQLCodeGenerator; use GraphQL\Language\AST\DirectiveNode; -use GraphQL\Language\AST\ListValueNode; use GraphQL\Language\AST\NodeList; use GraphQL\Language\AST\StringValueNode; +use InvalidArgumentException; final class DirectiveProcessor { @@ -74,12 +74,13 @@ public function hasDirective(NodeList $directives, string $name) : bool } /** - * Extract the @hook directive's `name` and `input` arguments. + * Extract the @hook directive's `name`. The data a hook needs is declared on the + * hook class via `#[Hook(requires: ...)]` — the call site is just `@hook(name: "x")`. * * @param NodeList $directives - * @return null|array{name: string, input: list} + * @throws InvalidArgumentException */ - public function getHookDirective(NodeList $directives) : ?array + public function getHookDirective(NodeList $directives) : ?string { foreach ($directives as $directive) { if ($directive->name->value !== 'hook') { @@ -87,21 +88,18 @@ public function getHookDirective(NodeList $directives) : ?array } $name = null; - $input = []; foreach ($directive->arguments as $argument) { - if ($argument->name->value === 'name' && $argument->value instanceof StringValueNode) { - $name = $argument->value->value; - - continue; + if ($argument->name->value === 'input') { + throw new InvalidArgumentException( + 'The @hook `input:` argument has been removed. A hook now declares ' + . 'the data it needs via #[Hook(requires: ...)]; the call site is ' + . 'just @hook(name: "...").', + ); } - if ($argument->name->value === 'input' && $argument->value instanceof ListValueNode) { - foreach ($argument->value->values as $value) { - if ($value instanceof StringValueNode) { - $input[] = $value->value; - } - } + if ($argument->name->value === 'name' && $argument->value instanceof StringValueNode) { + $name = $argument->value->value; } } @@ -109,10 +107,7 @@ public function getHookDirective(NodeList $directives) : ?array continue; } - return [ - 'name' => $name, - 'input' => $input, - ]; + return $name; } return null; diff --git a/src/Generator/DataClassGenerator.php b/src/Generator/DataClassGenerator.php index 0cfca79..b7a216c 100644 --- a/src/Generator/DataClassGenerator.php +++ b/src/Generator/DataClassGenerator.php @@ -15,8 +15,8 @@ use Ruudk\GraphQLCodeGenerator\GraphQL\AST\Printer; use Ruudk\GraphQLCodeGenerator\Planner\Plan\DataClassPlan; use Ruudk\GraphQLCodeGenerator\Planner\Source\GraphQLFileSource; +use Ruudk\GraphQLCodeGenerator\Planner\Source\HookInputSource; use Ruudk\GraphQLCodeGenerator\Planner\Source\TwigFileSource; -use Ruudk\GraphQLCodeGenerator\Type\ArrayTupleType; use Ruudk\GraphQLCodeGenerator\Type\FragmentObjectType; use Ruudk\GraphQLCodeGenerator\Type\HookPropertyType; use Ruudk\GraphQLCodeGenerator\Type\IndexByCollectionType; @@ -53,67 +53,6 @@ private function initializeFragmentObject(FragmentObjectType $type, CodeGenerato return $result; } - /** - * Walks a dotted `@hook` input path through the current class's field shape - * (and the nested classes referenced along the way), emitting a property- - * chain accessor like `$this->creator->id`. Nullable intermediates get - * promoted to `?->` so the chain's static type stays accurate. `$base` is - * the root expression the chain hangs off — `$this` for a getter, or a - * loop/instance variable inside an inlined collect walk. - * - * @param array $plansByFqcn - * @throws InvalidArgumentException - */ - private function buildHookInputAccessor(string $path, SymfonyType $fields, array $plansByFqcn, string $base = '$this') : string - { - $segments = explode('.', $path); - $accessor = $base; - $chainNullable = false; - $shape = $this->unwrapShape($fields); - - $last = count($segments) - 1; - - foreach ($segments as $i => $segment) { - $shapeArray = $shape->getShape(); - - Assert::keyExists($shapeArray, $segment, sprintf( - 'Hook input path "%s" references unknown field "%s".', - $path, - $segment, - )); - - $segmentType = $shapeArray[$segment]['type']; - - $accessor .= ($chainNullable ? '?->' : '->') . $segment; - - if ($i === $last) { - break; - } - - $isNullable = $segmentType instanceof SymfonyType\NullableType; - $naked = $isNullable ? $segmentType->getWrappedType() : $segmentType; - - Assert::isInstanceOf($naked, SymfonyType\ObjectType::class, sprintf( - 'Hook input path "%s" cannot descend through non-object segment "%s".', - $path, - $segment, - )); - - $nextFqcn = $naked->getClassName(); - - Assert::keyExists($plansByFqcn, $nextFqcn, sprintf( - 'Hook input path "%s" references class "%s" that has no generated plan.', - $path, - $nextFqcn, - )); - - $shape = $this->unwrapShape($plansByFqcn[$nextFqcn]->fields); - $chainNullable = $chainNullable || $isNullable; - } - - return $accessor; - } - /** * @param array $plansByFqcn * @throws InvalidArgumentException @@ -132,50 +71,44 @@ private function dumpPossibleTypesList(array $plansByFqcn, string $fqcn) : strin } /** - * @throws InvalidArgumentException + * Name of the generated method that walks the typed object graph collecting + * a batched hook's data objects. Hook names are validated as PHP identifiers + * by `Config::withHook()`. */ - private function unwrapShape(SymfonyType $type) : ArrayShapeType + private function collectMethodName(string $hookName) : string { - if ($type instanceof SymfonyType\NullableType) { - $type = $type->getWrappedType(); - } - - Assert::isInstanceOf($type, ArrayShapeType::class); - - return $type; + return 'collectHook' . ucfirst($hookName) . 'Inputs'; } /** - * Name of the generated method that walks the typed object graph collecting - * a batched hook's input tuples. Hook names are validated as PHP identifiers - * by `Config::withHook()`. + * Name of the generated method that builds a hook's `requires` data object + * from `$this->data`. */ - private function collectMethodName(string $hookName) : string + private function buildMethodName(HookPropertyType $hook) : string { - return 'collectHook' . ucfirst($hookName) . 'Inputs'; + return 'build' . $hook->requiresClassName; } /** - * PHPDoc `array{hookName: HookLoader, ...}` shape for the - * `$loaders` argument/property. `TInput` is the hook's input tuple shape - * and `V` its (unwrapped) return value type. + * PHPDoc `array{hookName: HookLoader, ...}` shape for the + * `$loaders` argument/property. `DataClass` is the hook's `requires` data + * class and `V` its (unwrapped) return value type. * * @param array $batchedHooks - * @param array $plansByFqcn - * @throws InvalidArgumentException */ - private function dumpLoadersShape(array $batchedHooks, array $plansByFqcn, CodeGenerator $generator) : string + private function dumpLoadersShape(array $batchedHooks, CodeGenerator $generator) : string { $hookLoader = $generator->import($this->fullyQualified('HookLoader')); $entries = []; foreach (array_keys($batchedHooks) as $name) { + $hook = $this->config->hooks[$name]; $entries[] = sprintf( '%s: %s<%s, %s>', $name, $hookLoader, - TypeDumper::dump($this->resolveHookInputTuple($name, $plansByFqcn), $generator->import(...), 1), - TypeDumper::dump($this->config->hooks[$name]->returnType, $generator->import(...), 1), + $generator->import($hook->requiresFqcn), + TypeDumper::dump($hook->returnType, $generator->import(...), 1), ); } @@ -188,103 +121,6 @@ private function dumpLoadersShape(array $batchedHooks, array $plansByFqcn, CodeG return sprintf("array{\n %s,\n}", implode(",\n ", $entries)); } - /** - * Build the input tuple type `array{T1, T2, ...}` for a batched hook from - * the leaf types of its `@hook(input: [...])` paths, located at the first - * site that uses the hook. Always resolves: the caller only asks for hooks - * that are in a class's `usedHooks`, which means a `@hook` field exists. - * - * @param array $plansByFqcn - * @throws InvalidArgumentException - */ - private function resolveHookInputTuple(string $hookName, array $plansByFqcn) : ArrayTupleType - { - foreach ($plansByFqcn as $plan) { - $fields = $plan->fields; - - if ($fields instanceof SymfonyType\NullableType) { - $fields = $fields->getWrappedType(); - } - - if ( ! $fields instanceof ArrayShapeType) { - continue; - } - - foreach ($fields->getShape() as $fieldValue) { - $fieldType = $fieldValue['type']; - - if ( ! $fieldType instanceof HookPropertyType || $fieldType->hookName !== $hookName) { - continue; - } - - $leaves = []; - - foreach ($fieldType->inputPaths as $path) { - $leaves[] = $this->resolveHookInputLeafType($path, $plan->fields, $plansByFqcn); - } - - return new ArrayTupleType($leaves); - } - } - - throw new InvalidArgumentException(sprintf( - 'No @hook field found for hook "%s"; cannot resolve its input tuple.', - $hookName, - )); - } - - /** - * Resolve the PHP type a dotted `@hook` input path ultimately reads — the - * companion of `buildHookInputAccessor`, which builds the accessor string. - * - * @param array $plansByFqcn - * @throws InvalidArgumentException - */ - private function resolveHookInputLeafType(string $path, SymfonyType $fields, array $plansByFqcn) : SymfonyType - { - $segments = explode('.', $path); - $shape = $this->unwrapShape($fields); - $last = count($segments) - 1; - - foreach ($segments as $i => $segment) { - $shapeArray = $shape->getShape(); - - Assert::keyExists($shapeArray, $segment, sprintf( - 'Hook input path "%s" references unknown field "%s".', - $path, - $segment, - )); - - $segmentType = $shapeArray[$segment]['type']; - - if ($i === $last) { - return $segmentType; - } - - $naked = $segmentType instanceof SymfonyType\NullableType - ? $segmentType->getWrappedType() - : $segmentType; - - Assert::isInstanceOf($naked, SymfonyType\ObjectType::class, sprintf( - 'Hook input path "%s" cannot descend through non-object segment "%s".', - $path, - $segment, - )); - - $nextFqcn = $naked->getClassName(); - - Assert::keyExists($plansByFqcn, $nextFqcn, sprintf( - 'Hook input path "%s" references class "%s" that has no generated plan.', - $path, - $nextFqcn, - )); - - $shape = $this->unwrapShape($plansByFqcn[$nextFqcn]->fields); - } - - throw new InvalidArgumentException(sprintf('Hook input path "%s" is empty.', $path)); - } - /** * Replicates the getter's property-type computation: a field's PHP property * type is its field type made nullable when the payload is nullable or the @@ -427,13 +263,12 @@ private function emitCollectClassBody(string $accessor, DataClassPlan $plan, str if ($fieldType instanceof HookPropertyType) { if ($fieldType->hookName === $hookName) { - $args = []; - - foreach ($fieldType->inputPaths as $path) { - $args[] = $this->buildHookInputAccessor($path, $fields, $plansByFqcn, $accessor); - } - - yield sprintf('yield [%s, [%s]];', $accessor, implode(', ', $args)); + yield sprintf( + 'yield [%s, %s->%s()];', + $accessor, + $accessor, + $this->buildMethodName($fieldType), + ); } continue; @@ -474,7 +309,7 @@ private function emitCollectClassBody(string $accessor, DataClassPlan $plan, str /** * Emit one `private collectHookInputs()` method per batched hook, * only on the operation's `Data` class. The method inlines the full walk of - * the typed object graph, yielding `[$owner, $inputTuple]` pairs for the + * the typed object graph, yielding `[$owner, $dataObject]` pairs for the * `HookLoader` to batch. Keeping the walk on `Data` lets it stay private * and leaves nested classes free of generated traversal methods. * @@ -496,7 +331,7 @@ private function dumpCollectMethods(DataClassPlan $plan, array $plansByFqcn, Cod yield ''; yield from $generator->docComment(sprintf( '@return iterable', - TypeDumper::dump($this->resolveHookInputTuple($hookName, $plansByFqcn), $generator->import(...)), + $generator->import($this->config->hooks[$hookName]->requiresFqcn), )); yield sprintf('private function %s() : iterable', $this->collectMethodName($hookName)); yield '{'; @@ -507,6 +342,46 @@ private function dumpCollectMethods(DataClassPlan $plan, array $plansByFqcn, Cod } } + /** + * Emit a `build()` method for each `@hook` field on this class. + * It constructs the hook's typed data object from `$this->data`. Public because + * the `Data` class's collect walk calls it on nested instances. + * + * @return iterable + */ + private function dumpHookBuildMethods(DataClassPlan $plan, CodeGenerator $generator) : iterable + { + $fields = $plan->fields; + + if ($fields instanceof SymfonyType\NullableType) { + $fields = $fields->getWrappedType(); + } + + if ( ! $fields instanceof ArrayShapeType) { + return; + } + + $emitted = []; + + foreach ($fields->getShape() as $fieldValue) { + $fieldType = $fieldValue['type']; + + if ( ! $fieldType instanceof HookPropertyType || isset($emitted[$fieldType->requiresClassName])) { + continue; + } + + $emitted[$fieldType->requiresClassName] = true; + $dataClass = $generator->import($fieldType->requiresFqcn); + + yield ''; + yield from $generator->docComment('@internal'); + yield sprintf('public function %s() : %s', $this->buildMethodName($fieldType), $dataClass); + yield '{'; + yield $generator->indent(sprintf('return new %s($this->data);', $dataClass)); + yield '}'; + } + } + /** * @param array $plansByFqcn */ @@ -590,6 +465,12 @@ public function generate(DataClassPlan $plan, array $plansByFqcn = []) : string return; } + if ($plan->source instanceof HookInputSource) { + yield sprintf('source: %s', var_export('hook:' . $plan->source->hookName, true)); + + return; + } + yield sprintf('source: %s', $generator->dumpClassReference($plan->source->class)); yield 'restricted: true'; yield 'restrictInstantiation: true'; @@ -636,9 +517,8 @@ function () use ($plan, $parentType, $nodesType, $fields, $isData, $isMutationDa yield ''; // Hook-backed synthetic property: not stored in $this->data, resolved - // at access time by invoking the user-supplied hook (an invokable - // class) with positional arguments in the order declared by the - // directive. + // at access time by invoking the user-supplied hook with the typed + // data object built from the hook's `requires` fragment. if ($fieldType instanceof HookPropertyType) { $wrappedReturnType = $fieldType->getWrappedType(); @@ -659,7 +539,7 @@ function () use ($plan, $parentType, $nodesType, $fields, $isData, $isMutationDa $this->dumpPHPType($fieldType, $generator->import(...)), $fieldName, ); - yield $generator->indent(function () use ($fieldType, $fieldName, $fields, $plansByFqcn, $generator) { + yield $generator->indent(function () use ($fieldType, $fieldName) { // Batched hook: delegate to the per-operation HookLoader, // which resolves the whole batch once and looks this // instance up by object identity. @@ -673,20 +553,11 @@ function () use ($plan, $parentType, $nodesType, $fields, $isData, $isMutationDa return; } - $args = []; - - foreach ($fieldType->inputPaths as $path) { - $args[] = $this->buildHookInputAccessor($path, $fields, $plansByFqcn); - } - - yield from $generator->wrap( - sprintf('get => $this->%s ??= ', $fieldName), - $generator->dumpCall( - sprintf('$this->hooks[%s]', var_export($fieldType->hookName, true)), - '__invoke', - $args, - ), - ';', + yield sprintf( + 'get => $this->%s ??= $this->hooks[%s]->__invoke($this->%s());', + $fieldName, + var_export($fieldType->hookName, true), + $this->buildMethodName($fieldType), ); }); yield '}'; @@ -1099,13 +970,13 @@ function () use ($plan, $parentType, $nodesType, $fields, $isData, $isMutationDa yield ''; yield from $generator->docComment(sprintf( '@var %s', - $this->dumpLoadersShape($batchedHooks, $plansByFqcn, $generator), + $this->dumpLoadersShape($batchedHooks, $generator), )); yield 'private readonly array $loaders;'; } yield ''; - yield from $generator->docComment(function () use ($isData, $generator, $payloadShape, $hooksParam, $needsHooksParam, $needsLoadersParam, $batchedHooks, $plansByFqcn) { + yield from $generator->docComment(function () use ($isData, $generator, $payloadShape, $hooksParam, $needsHooksParam, $needsLoadersParam, $batchedHooks) { yield sprintf( '@param %s $data', TypeDumper::dump($payloadShape, $generator->import(...)), @@ -1135,7 +1006,7 @@ function () use ($plan, $parentType, $nodesType, $fields, $isData, $isMutationDa if ($needsLoadersParam) { yield sprintf( '@param %s $loaders', - $this->dumpLoadersShape($batchedHooks, $plansByFqcn, $generator), + $this->dumpLoadersShape($batchedHooks, $generator), ); } }); @@ -1189,6 +1060,7 @@ function () use ($plan, $parentType, $nodesType, $fields, $isData, $isMutationDa yield ') {}'; } + yield from $this->dumpHookBuildMethods($plan, $generator); yield from $this->dumpCollectMethods($plan, $plansByFqcn, $generator); }, ); diff --git a/src/GraphQL/FragmentDefinitionNodeWithSource.php b/src/GraphQL/FragmentDefinitionNodeWithSource.php index 2cd23f8..cb5c5fa 100644 --- a/src/GraphQL/FragmentDefinitionNodeWithSource.php +++ b/src/GraphQL/FragmentDefinitionNodeWithSource.php @@ -6,15 +6,16 @@ use GraphQL\Language\AST\FragmentDefinitionNode; use Ruudk\GraphQLCodeGenerator\Planner\Source\GraphQLFileSource; +use Ruudk\GraphQLCodeGenerator\Planner\Source\HookInputSource; use Ruudk\GraphQLCodeGenerator\Planner\Source\InlineFragmentSource; use Ruudk\GraphQLCodeGenerator\Planner\Source\InlineSource; use Ruudk\GraphQLCodeGenerator\Planner\Source\TwigFileSource; final class FragmentDefinitionNodeWithSource extends FragmentDefinitionNode { - public GraphQLFileSource | InlineFragmentSource | InlineSource | TwigFileSource $source; + public GraphQLFileSource | HookInputSource | InlineFragmentSource | InlineSource | TwigFileSource $source; - public static function create(FragmentDefinitionNode $fragmentNode, GraphQLFileSource | InlineFragmentSource | InlineSource | TwigFileSource $source) : self + public static function create(FragmentDefinitionNode $fragmentNode, GraphQLFileSource | HookInputSource | InlineFragmentSource | InlineSource | TwigFileSource $source) : self { return new self([ 'name' => $fragmentNode->name, diff --git a/src/GraphQL/HookDirectiveSchemaExtender.php b/src/GraphQL/HookDirectiveSchemaExtender.php index 99c186b..1c240f8 100644 --- a/src/GraphQL/HookDirectiveSchemaExtender.php +++ b/src/GraphQL/HookDirectiveSchemaExtender.php @@ -31,13 +31,11 @@ public static function extend(Schema $schema) : Schema if ($existing !== null) { Assert::eq($existing->locations, ['FIELD'], 'Expected @hook to be on FIELD'); - Assert::count($existing->args, 2, 'Expected @hook to have 2 arguments'); + Assert::count($existing->args, 1, 'Expected @hook to have 1 argument'); - [$name, $input] = $existing->args; + [$name] = $existing->args; Assert::eq($name->name, 'name', 'Expected @hook first argument to be named "name"'); Assert::eq(Type::nonNull(Type::string()), $name->getType(), 'Expected @hook "name" argument to be a non-null string'); - Assert::eq($input->name, 'input', 'Expected @hook second argument to be named "input"'); - Assert::eq(Type::nonNull(Type::listOf(Type::nonNull(Type::string()))), $input->getType(), 'Expected @hook "input" argument to be a non-null list of non-null strings'); return $schema; } @@ -46,7 +44,7 @@ public static function extend(Schema $schema) : Schema $schema, Parser::parse( <<<'GRAPHQL' - directive @hook(name: String!, input: [String!]!) on FIELD + directive @hook(name: String!) on FIELD GRAPHQL, ), ); diff --git a/src/Planner.php b/src/Planner.php index 358185a..683e9eb 100644 --- a/src/Planner.php +++ b/src/Planner.php @@ -14,7 +14,9 @@ use GraphQL\Language\Parser; use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\InputObjectType; +use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\NamedType; +use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type; use GraphQL\Type\Schema; use GraphQL\Validator\DocumentValidator; @@ -80,6 +82,7 @@ use Ruudk\GraphQLCodeGenerator\Planner\PlannerResult; use Ruudk\GraphQLCodeGenerator\Planner\SelectionSetPlanner; use Ruudk\GraphQLCodeGenerator\Planner\Source\GraphQLFileSource; +use Ruudk\GraphQLCodeGenerator\Planner\Source\HookInputSource; use Ruudk\GraphQLCodeGenerator\Planner\Source\InlineFragmentSource; use Ruudk\GraphQLCodeGenerator\Planner\Source\InlineSource; use Ruudk\GraphQLCodeGenerator\Planner\Source\TwigFileSource; @@ -90,7 +93,7 @@ use Ruudk\GraphQLCodeGenerator\Type\TypeHelper; use Ruudk\GraphQLCodeGenerator\Validator\IndexByValidator; use Ruudk\GraphQLCodeGenerator\Visitor\DefinedFragmentsVisitor; -use Ruudk\GraphQLCodeGenerator\Visitor\HookFieldRemover; +use Ruudk\GraphQLCodeGenerator\Visitor\HookFieldInjector; use Ruudk\GraphQLCodeGenerator\Visitor\IndexByRemover; use Ruudk\GraphQLCodeGenerator\Visitor\OperationArgumentDirectiveRemover; use Ruudk\GraphQLCodeGenerator\Visitor\ThrowWhenNullRemover; @@ -587,7 +590,7 @@ public function plan() : PlannerResult ]); if ($this->config->hooks !== []) { - $validationDocument = new HookFieldRemover()->__invoke($validationDocument); + $validationDocument = new HookFieldInjector($this->config->hooks)->__invoke($validationDocument); } $errors = DocumentValidator::validate($this->schema, $validationDocument, $this->validatorRules); @@ -668,6 +671,76 @@ public function plan() : PlannerResult )); } + // Plan the data classes hooks receive (built from each hook's `requires` + // fragment). Runs after user fragments are registered so a `requires` + // fragment may itself spread a user fragment. + $hookDataClassNames = []; + + foreach ($this->config->hooks as $hook) { + Assert::keyNotExists($hookDataClassNames, $hook->requiresClassName, sprintf( + 'Hooks "%s" and "%s" both generate a data class named "%s".', + $hookDataClassNames[$hook->requiresClassName] ?? '?', + $hook->name, + $hook->requiresClassName, + )); + $hookDataClassNames[$hook->requiresClassName] = $hook->name; + + $type = Type::getNamedType($this->schema->getType($hook->requiresTypeCondition)); + + Assert::notNull($type, sprintf( + 'Hook "%s" requires unknown type "%s".', + $hook->name, + $hook->requiresTypeCondition, + )); + Assert::isInstanceOfAny($type, [ObjectType::class, InterfaceType::class], sprintf( + 'Hook "%s" requires type "%s", which must be an object or interface type.', + $hook->name, + $hook->requiresTypeCondition, + )); + + $errors = DocumentValidator::validate( + $this->schema, + new DocumentNode([ + 'definitions' => new NodeList([$hook->requiresFragment]), + ]), + $this->validatorRules, + ); + + if ($errors !== []) { + throw new Exception(sprintf( + 'The `requires` fragment of hook "%s" is invalid: %s', + $hook->name, + implode(PHP_EOL, array_map(fn($error) => $error->getMessage(), $errors)), + )); + } + + $source = new HookInputSource($hook->class, $hook->name); + + $planResult = $planner->plan( + $source, + $hook->requiresFragment->selectionSet, + $type, + $this->config->outputDir . '/Hook/' . $hook->requiresClassName, + $hook->requiresFqcn, + 'fragment', + ); + + $result->addClass(new DataClassPlan( + $source, + $this->config->outputDir . '/Hook/' . $hook->requiresClassName . '.php', + $hook->requiresFqcn, + $type, + $planResult->fields, + $planResult->payloadShape, + $this->possibleTypesFinder->find($type), + $hook->requiresFragment, + null, + $planResult->inlineFragmentRequiredFields, + false, + true, + )); + } + // Plan operations foreach ($operations as $path => $documents) { foreach ($documents as $document) { @@ -966,7 +1039,7 @@ private function planOperation(DocumentNodeWithSource $document, string $path, P $validationDocument = $document; if ($this->config->hooks !== []) { - $validationDocument = new HookFieldRemover()->__invoke($validationDocument); + $validationDocument = new HookFieldInjector($this->config->hooks)->__invoke($validationDocument); } $errors = DocumentValidator::validate($this->schema, $validationDocument, $this->validatorRules); @@ -1036,7 +1109,7 @@ private function planOperation(DocumentNodeWithSource $document, string $path, P } if ($this->config->hooks !== []) { - $document = new HookFieldRemover()->__invoke($document); + $document = new HookFieldInjector($this->config->hooks)->__invoke($document); } if ($this->config->throwWhenNullDirective) { diff --git a/src/Planner/PayloadShapeBuilder.php b/src/Planner/PayloadShapeBuilder.php index f565442..f145d85 100644 --- a/src/Planner/PayloadShapeBuilder.php +++ b/src/Planner/PayloadShapeBuilder.php @@ -10,6 +10,7 @@ use GraphQL\Language\AST\InlineFragmentNode; use GraphQL\Language\AST\NodeList; use GraphQL\Language\AST\SelectionSetNode; +use GraphQL\Language\AST\StringValueNode; use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\HasFieldsType; use GraphQL\Type\Definition\InterfaceType; @@ -22,6 +23,7 @@ use GraphQL\Type\Definition\UnionType; use GraphQL\Type\Definition\WrappingType; use GraphQL\Type\Schema; +use Ruudk\GraphQLCodeGenerator\Config\HookDefinition; use Ruudk\GraphQLCodeGenerator\TypeMapper; use Symfony\Component\TypeInfo\Type as SymfonyType; use Webmozart\Assert\Assert; @@ -37,12 +39,14 @@ /** * @param array}> $fragmentDefinitions * @param array $fragmentTypes + * @param array $hooks */ public function __construct( private Schema $schema, private TypeMapper $typeMapper, private array $fragmentDefinitions = [], private array $fragmentTypes = [], + private array $hooks = [], ) {} /** @@ -93,6 +97,28 @@ private function processSelections( } } + // Hook fields are synthetic, but the data the hook needs is injected + // into the operation — so its `requires` selection must be part of the + // payload the server returns. + foreach ($selectionSet->selections as $selection) { + if ( ! $selection instanceof FieldNode) { + continue; + } + + $hookName = $this->hookName($selection); + + if ($hookName === null || ! isset($this->hooks[$hookName])) { + continue; + } + + $this->processSelections( + $this->hooks[$hookName]->requiresFragment->selectionSet, + $parentType, + $shape, + $visitedFragments, + ); + } + // Process inline fragments and type-specific/conditional fragment spreads foreach ($selectionSet->selections as $selection) { if ($selection instanceof InlineFragmentNode) { @@ -466,13 +492,27 @@ private function hasConditionalDirectives(FieldNode | FragmentSpreadNode $node) } private function hasHookDirective(FieldNode $node) : bool + { + return $this->hookName($node) !== null; + } + + /** + * The `name` of a field's `@hook` directive, or null when it has none. + */ + private function hookName(FieldNode $node) : ?string { foreach ($node->directives as $directive) { - if ($directive->name->value === 'hook') { - return true; + if ($directive->name->value !== 'hook') { + continue; + } + + foreach ($directive->arguments as $argument) { + if ($argument->name->value === 'name' && $argument->value instanceof StringValueNode) { + return $argument->value->value; + } } } - return false; + return null; } } diff --git a/src/Planner/Plan/DataClassPlan.php b/src/Planner/Plan/DataClassPlan.php index c2801e5..cf8b207 100644 --- a/src/Planner/Plan/DataClassPlan.php +++ b/src/Planner/Plan/DataClassPlan.php @@ -10,6 +10,7 @@ use GraphQL\Type\Definition\NamedType; use GraphQL\Type\Definition\Type; use Ruudk\GraphQLCodeGenerator\Planner\Source\GraphQLFileSource; +use Ruudk\GraphQLCodeGenerator\Planner\Source\HookInputSource; use Ruudk\GraphQLCodeGenerator\Planner\Source\InlineFragmentSource; use Ruudk\GraphQLCodeGenerator\Planner\Source\InlineSource; use Ruudk\GraphQLCodeGenerator\Planner\Source\TwigFileSource; @@ -30,7 +31,7 @@ final class DataClassPlan * @param array> $inlineFragmentRequiredFields */ public function __construct( - public readonly GraphQLFileSource | InlineFragmentSource | InlineSource | TwigFileSource $source, + public readonly GraphQLFileSource | HookInputSource | InlineFragmentSource | InlineSource | TwigFileSource $source, public readonly string $path, public readonly string $fqcn, public readonly NamedType & Type $parentType, diff --git a/src/Planner/SelectionSetPlanner.php b/src/Planner/SelectionSetPlanner.php index ae80931..2fa718c 100644 --- a/src/Planner/SelectionSetPlanner.php +++ b/src/Planner/SelectionSetPlanner.php @@ -26,12 +26,14 @@ use GraphQL\Type\Schema; use LogicException; use Ruudk\GraphQLCodeGenerator\Config\Config; +use Ruudk\GraphQLCodeGenerator\Config\HookDefinition; use Ruudk\GraphQLCodeGenerator\DirectiveProcessor; use Ruudk\GraphQLCodeGenerator\GraphQL\AST\InjectedTypenameFieldNode; use Ruudk\GraphQLCodeGenerator\GraphQL\FragmentDefinitionNodeWithSource; use Ruudk\GraphQLCodeGenerator\GraphQL\PossibleTypesFinder; use Ruudk\GraphQLCodeGenerator\Planner\Plan\DataClassPlan; use Ruudk\GraphQLCodeGenerator\Planner\Source\GraphQLFileSource; +use Ruudk\GraphQLCodeGenerator\Planner\Source\HookInputSource; use Ruudk\GraphQLCodeGenerator\Planner\Source\InlineFragmentSource; use Ruudk\GraphQLCodeGenerator\Planner\Source\InlineSource; use Ruudk\GraphQLCodeGenerator\Planner\Source\TwigFileSource; @@ -98,7 +100,7 @@ public function __construct( * @throws LogicException */ public function plan( - GraphQLFileSource | InlineFragmentSource | InlineSource | TwigFileSource $source, + GraphQLFileSource | HookInputSource | InlineFragmentSource | InlineSource | TwigFileSource $source, SelectionSetNode $selectionSet, Type $parent, string $outputDirectory, @@ -141,7 +143,7 @@ public function plan( * @throws LogicException */ public function planSelectionSet( - GraphQLFileSource | InlineFragmentSource | InlineSource | TwigFileSource $source, + GraphQLFileSource | HookInputSource | InlineFragmentSource | InlineSource | TwigFileSource $source, SelectionSetNode $selectionSet, Type $type, PlanningContext $context, @@ -219,7 +221,7 @@ public function planSelectionSet( * @throws LogicException */ private function planNamedTypeSelectionSet( - GraphQLFileSource | InlineFragmentSource | InlineSource | TwigFileSource $source, + GraphQLFileSource | HookInputSource | InlineFragmentSource | InlineSource | TwigFileSource $source, SelectionSetNode $selectionSet, NamedType & Type $type, PlanningContext $context, @@ -233,6 +235,7 @@ private function planNamedTypeSelectionSet( $this->typeMapper, $this->fragmentDefinitions, $this->fragmentTypes, + $this->config->hooks, ); $payloadShape = $payloadShapeBuilder->buildPayloadShape($selectionSet, $type); @@ -313,7 +316,7 @@ private function planNamedTypeSelectionSet( * @throws LogicException */ private function processFieldSelection( - GraphQLFileSource | InlineFragmentSource | InlineSource | TwigFileSource $source, + GraphQLFileSource | HookInputSource | InlineFragmentSource | InlineSource | TwigFileSource $source, FieldNode $selection, Type $parent, PlanningContext $context, @@ -325,20 +328,27 @@ private function processFieldSelection( // Hook fields are synthetic — they don't exist in the schema and their value comes from // a user-supplied callable at runtime rather than from the server response. - $hookDirective = $this->directiveProcessor->getHookDirective($selection->directives); + $hookName = $this->directiveProcessor->getHookDirective($selection->directives); - if ($hookDirective !== null) { + if ($hookName !== null) { Assert::keyExists( $this->config->hooks, - $hookDirective['name'], - sprintf('Hook "%s" used in selection is not registered via Config::withHook().', $hookDirective['name']), + $hookName, + sprintf('Hook "%s" used in selection is not registered via Config::withHook().', $hookName), ); - $hook = $this->config->hooks[$hookDirective['name']]; + $hook = $this->config->hooks[$hookName]; + + // The @hook field may only sit on a type the hook declares it supports. + // (The hook's required fields are merged into $payloadShape by + // PayloadShapeBuilder; they are deliberately kept out of $fields so the + // caller's typed API is not polluted by the hook's internals.) + $this->assertHookSiteType($parent, $hook); $fields->add($fieldName, new HookPropertyType( $hook->name, - $hookDirective['input'], + $hook->requiresFqcn, + $hook->requiresClassName, $hook->returnType, $hook->batched, )); @@ -442,13 +452,44 @@ private function processFieldSelection( $payloadShape->addRequired($fieldName, $mappedPayloadType); } + /** + * A `@hook` field may only be placed on a selection whose type is the one the + * hook declares in its `requires` fragment — that exact object type, or (when + * the hook's condition is an interface) any object type implementing it. + * + * @throws InvalidArgumentException + */ + private function assertHookSiteType(Type $parent, HookDefinition $hook) : void + { + $parentName = $parent instanceof NamedType ? $parent->name() : (string) $parent; + $matches = $parentName === $hook->requiresTypeCondition; + + if ( ! $matches && $parent instanceof ObjectType) { + foreach ($parent->getInterfaces() as $interface) { + if ($interface->name() === $hook->requiresTypeCondition) { + $matches = true; + + break; + } + } + } + + Assert::true($matches, sprintf( + 'Hook "%s" requires type "%s" but @hook(name: "%s") is used on a "%s" selection.', + $hook->name, + $hook->requiresTypeCondition, + $hook->name, + $parentName, + )); + } + /** * @throws InvalidArgumentException * @throws InvariantViolation * @throws LogicException */ private function processNestedSelection( - GraphQLFileSource | InlineFragmentSource | InlineSource | TwigFileSource $source, + GraphQLFileSource | HookInputSource | InlineFragmentSource | InlineSource | TwigFileSource $source, FieldNode $selection, string $fieldName, Type $fieldType, @@ -521,7 +562,7 @@ private function processNestedSelection( * @throws LogicException */ private function processInlineFragment( - GraphQLFileSource | InlineFragmentSource | InlineSource | TwigFileSource $source, + GraphQLFileSource | HookInputSource | InlineFragmentSource | InlineSource | TwigFileSource $source, InlineFragmentNode $selection, Type $parent, PlanningContext $context, @@ -559,6 +600,7 @@ private function processInlineFragment( $this->typeMapper, $this->fragmentDefinitions, $this->fragmentTypes, + $this->config->hooks, ); // Using fragmentType as parent ensures fields won't be marked optional. @@ -603,6 +645,7 @@ private function processInlineFragment( /** * @throws InvalidArgumentException + * @throws \InvalidArgumentException */ private function processFragmentSpread( FragmentSpreadNode $selection, @@ -956,7 +999,7 @@ private function extractNodesType( * @throws InvariantViolation */ private function createDataClassPlan( - GraphQLFileSource | InlineFragmentSource | InlineSource | TwigFileSource $source, + GraphQLFileSource | HookInputSource | InlineFragmentSource | InlineSource | TwigFileSource $source, NamedType & Type $parentType, SelectionSetResult $result, PlanningContext $context, @@ -1038,7 +1081,7 @@ private function selectionSetIsSoleTypename(?SelectionSetNode $selectionSet) : b } private function createInlineFragmentClassPlan( - GraphQLFileSource | InlineFragmentSource | InlineSource | TwigFileSource $source, + GraphQLFileSource | HookInputSource | InlineFragmentSource | InlineSource | TwigFileSource $source, NamedType & Type $fragmentType, FieldCollection $fields, PayloadShape $payloadShape, @@ -1064,6 +1107,9 @@ private function createInlineFragmentClassPlan( $this->result->addClass($dataClass); } + /** + * @throws \InvalidArgumentException + */ private function storeInlineFragmentRequiredFields( InlineFragmentNode $selection, string $inlineFragmentKey, @@ -1076,6 +1122,7 @@ private function storeInlineFragmentRequiredFields( /** * Recursively collect all field names from a selection set * @param list $requiredFields + * @throws \InvalidArgumentException */ private function collectRequiredFieldsFromSelectionSet( SelectionSetNode $selectionSet, diff --git a/src/Planner/Source/HookInputSource.php b/src/Planner/Source/HookInputSource.php new file mode 100644 index 0000000..20c4768 --- /dev/null +++ b/src/Planner/Source/HookInputSource.php @@ -0,0 +1,22 @@ + $elements - */ - public function __construct( - public readonly array $elements, - ) {} - - #[Override] - public function __toString() : string - { - return sprintf('array{%s}', implode(', ', array_map( - fn(Type $element) => (string) $element, - $this->elements, - ))); - } -} diff --git a/src/Type/HookPropertyType.php b/src/Type/HookPropertyType.php index ba6578b..dc6ebcd 100644 --- a/src/Type/HookPropertyType.php +++ b/src/Type/HookPropertyType.php @@ -14,23 +14,24 @@ * * The wrapped type is the hook's declared return type (used both for the * property's PHP type hint and for dumping any PHPDoc). The extra state - * (hook name + input paths) drives code emission in DataClassGenerator. - * - * This is a simple wrapper so that `instanceof HookPropertyType` is the only - * branch the generator has to add to iterate field types as usual. + * (hook name + the hook's `requires` data class) drives code emission in + * DataClassGenerator. * * @implements WrappingTypeInterface */ final class HookPropertyType extends SymfonyType implements WrappingTypeInterface { /** - * @param list $inputPaths + * @param string $requiresFqcn FQCN of the generated data class the hook receives + * (built from the hook's `requires` fragment). + * @param string $requiresClassName Short name of that data class. * @param bool $batched When true, the hook is resolved by a batched `HookLoader` * instead of a per-instance `__invoke` call. */ public function __construct( public readonly string $hookName, - public readonly array $inputPaths, + public readonly string $requiresFqcn, + public readonly string $requiresClassName, private readonly SymfonyType $returnType, public readonly bool $batched = false, ) {} diff --git a/src/Type/TypeDumper.php b/src/Type/TypeDumper.php index 6f9cfcb..adc91a1 100644 --- a/src/Type/TypeDumper.php +++ b/src/Type/TypeDumper.php @@ -20,13 +20,6 @@ public static function dump(Type $type, ?callable $importer = null, int $indenta return sprintf('null|%s', self::dump($type->getWrappedType(), $importer, $indentation)); } - if ($type instanceof ArrayTupleType) { - return sprintf('array{%s}', implode(', ', array_map( - fn(Type $element) => self::dump($element, $importer, $indentation), - $type->elements, - ))); - } - if ($type instanceof Type\ArrayShapeType) { $items = []; diff --git a/src/Visitor/HookFieldInjector.php b/src/Visitor/HookFieldInjector.php new file mode 100644 index 0000000..66f66f2 --- /dev/null +++ b/src/Visitor/HookFieldInjector.php @@ -0,0 +1,96 @@ + $hooks + */ + public function __construct( + private array $hooks, + ) {} + + /** + * @template T of Node + * @param T $node + * + * @throws InvalidArgumentException + * @throws Exception + * @return T + */ + public function __invoke(Node $node) : Node + { + $new = Visitor::visit($node, [ + NodeKind::FIELD => function (Node $node) : ?InlineFragmentNode { + Assert::isInstanceOf($node, FieldNode::class); + + foreach ($node->directives as $directive) { + if ($directive->name->value !== 'hook') { + continue; + } + + $hookName = $this->readHookName($directive); + + Assert::keyExists($this->hooks, $hookName, sprintf( + 'Hook "%s" used in a @hook directive is not registered via Config::withHook().', + $hookName, + )); + + $fragment = $this->hooks[$hookName]->requiresFragment; + + return new InlineFragmentNode([ + 'typeCondition' => $fragment->typeCondition->cloneDeep(), + 'directives' => new NodeList([]), + 'selectionSet' => $fragment->selectionSet->cloneDeep(), + ]); + } + + return null; + }, + ]); + + Assert::isInstanceOf($new, Node::class); + Assert::isAOf($new, $node::class); + + return $new; + } + + /** + * @throws InvalidArgumentException + */ + private function readHookName(DirectiveNode $directive) : string + { + foreach ($directive->arguments as $argument) { + if ($argument->name->value === 'name' && $argument->value instanceof StringValueNode) { + return $argument->value->value; + } + } + + throw new InvalidArgumentException('A @hook directive is missing its "name" argument.'); + } +} diff --git a/src/Visitor/HookFieldRemover.php b/src/Visitor/HookFieldRemover.php deleted file mode 100644 index dd2c644..0000000 --- a/src/Visitor/HookFieldRemover.php +++ /dev/null @@ -1,53 +0,0 @@ - function (Node $node) : ?VisitorRemoveNode { - Assert::isInstanceOf($node, FieldNode::class); - - foreach ($node->directives as $directive) { - if ($directive->name->value === 'hook') { - return Visitor::removeNode(); - } - } - - return null; - }, - ]); - - Assert::isInstanceOf($new, Node::class); - Assert::isAOf($new, $node::class); - - return $new; - } -} diff --git a/tests/Console/UnusedHookForTesting.php b/tests/Console/UnusedHookForTesting.php index b65a1f5..ce1d87d 100644 --- a/tests/Console/UnusedHookForTesting.php +++ b/tests/Console/UnusedHookForTesting.php @@ -6,11 +6,24 @@ use Ruudk\GraphQLCodeGenerator\Attribute\Hook; -#[Hook(name: 'unusedHookForTesting')] +/** + * A hook that is registered but never referenced by an `@hook` directive — the + * fixture for `EnsureSyncCommandTest`'s unused-hook detection. It is never + * invoked, so `__invoke` takes a loose `object` rather than its generated + * `requires` data class (which is not committed anywhere). + */ +#[Hook( + name: 'unusedHookForTesting', + requires: <<<'GRAPHQL' + fragment UnusedHookProbe on Project { + id + } + GRAPHQL +)] final readonly class UnusedHookForTesting { - public function __invoke(string $id) : string + public function __invoke(object $project) : string { - return $id; + return 'unused'; } } diff --git a/tests/Hooks/FindUserByIdHook.php b/tests/Hooks/FindUserByIdHook.php index acb61df..40a16e6 100644 --- a/tests/Hooks/FindUserByIdHook.php +++ b/tests/Hooks/FindUserByIdHook.php @@ -5,8 +5,18 @@ namespace Ruudk\GraphQLCodeGenerator\Hooks; use Ruudk\GraphQLCodeGenerator\Attribute\Hook; +use Ruudk\GraphQLCodeGenerator\Hooks\Generated\Hook\ProjectCreatorId; -#[Hook(name: 'findUserById')] +#[Hook( + name: 'findUserById', + requires: <<<'GRAPHQL' + fragment ProjectCreatorId on Project { + creator { + id + } + } + GRAPHQL +)] final readonly class FindUserByIdHook { /** @@ -16,8 +26,8 @@ public function __construct( private array $users = [], ) {} - public function __invoke(string $id) : ?User + public function __invoke(ProjectCreatorId $project) : ?User { - return $this->users[$id] ?? null; + return $this->users[$project->creator->id] ?? null; } } diff --git a/tests/Hooks/Generated/Hook/ProjectCreatorId.php b/tests/Hooks/Generated/Hook/ProjectCreatorId.php new file mode 100644 index 0000000..cbc1a25 --- /dev/null +++ b/tests/Hooks/Generated/Hook/ProjectCreatorId.php @@ -0,0 +1,29 @@ + $this->creator ??= new Creator($this->data['creator']); + } + + /** + * @param array{ + * 'creator': array{ + * 'id': string, + * ..., + * }, + * ..., + * } $data + */ + public function __construct( + private readonly array $data, + ) {} +} diff --git a/tests/Hooks/Generated/Hook/ProjectCreatorId/Creator.php b/tests/Hooks/Generated/Hook/ProjectCreatorId/Creator.php new file mode 100644 index 0000000..68b1a5b --- /dev/null +++ b/tests/Hooks/Generated/Hook/ProjectCreatorId/Creator.php @@ -0,0 +1,24 @@ + $this->id ??= $this->data['id']; + } + + /** + * @param array{ + * 'id': string, + * ..., + * } $data + */ + public function __construct( + private readonly array $data, + ) {} +} diff --git a/tests/Hooks/Generated/Query/Test/Data/Viewer/Project.php b/tests/Hooks/Generated/Query/Test/Data/Viewer/Project.php index ba1db57..86b0602 100644 --- a/tests/Hooks/Generated/Query/Test/Data/Viewer/Project.php +++ b/tests/Hooks/Generated/Query/Test/Data/Viewer/Project.php @@ -5,6 +5,7 @@ namespace Ruudk\GraphQLCodeGenerator\Hooks\Generated\Query\Test\Data\Viewer; use Ruudk\GraphQLCodeGenerator\Hooks\FindUserByIdHook; +use Ruudk\GraphQLCodeGenerator\Hooks\Generated\Hook\ProjectCreatorId; use Ruudk\GraphQLCodeGenerator\Hooks\Generated\Query\Test\Data\Viewer\Project\Creator; use Ruudk\GraphQLCodeGenerator\Hooks\User; @@ -25,7 +26,7 @@ final class Project } public ?User $user { - get => $this->user ??= $this->hooks['findUserById']->__invoke($this->creator->id); + get => $this->user ??= $this->hooks['findUserById']->__invoke($this->buildProjectCreatorId()); } /** @@ -47,4 +48,12 @@ public function __construct( private readonly array $data, private readonly array $hooks, ) {} + + /** + * @internal + */ + public function buildProjectCreatorId() : ProjectCreatorId + { + return new ProjectCreatorId($this->data); + } } diff --git a/tests/Hooks/Generated/Query/Test/TestQuery.php b/tests/Hooks/Generated/Query/Test/TestQuery.php index 9672117..32d1e76 100644 --- a/tests/Hooks/Generated/Query/Test/TestQuery.php +++ b/tests/Hooks/Generated/Query/Test/TestQuery.php @@ -21,6 +21,11 @@ creator { id } + ... on Project { + creator { + id + } + } } } } diff --git a/tests/Hooks/Test.graphql b/tests/Hooks/Test.graphql index 23aecb0..9a3bf0b 100644 --- a/tests/Hooks/Test.graphql +++ b/tests/Hooks/Test.graphql @@ -8,7 +8,7 @@ query Test { id } - user @hook(name: "findUserById", input: ["creator.id"]) + user @hook(name: "findUserById") } } } diff --git a/tests/HooksBatched/ComputeAccessHook.php b/tests/HooksBatched/ComputeAccessHook.php index e4b2aa3..8872f3b 100644 --- a/tests/HooksBatched/ComputeAccessHook.php +++ b/tests/HooksBatched/ComputeAccessHook.php @@ -5,31 +5,41 @@ namespace Ruudk\GraphQLCodeGenerator\HooksBatched; use Ruudk\GraphQLCodeGenerator\Attribute\Hook; +use Ruudk\GraphQLCodeGenerator\HooksBatched\Generated\Hook\RepositoryAccessFields; /** * Batched hook taking two fields. Records every batch it is invoked with so the * test can measure its invocation count. */ -#[Hook(name: 'computeAccess', batched: true)] +#[Hook(name: 'computeAccess', requires: <<<'GRAPHQL' + fragment RepositoryAccessFields on Repository { + ownerId + reviewerId + } + GRAPHQL, batched: true)] final class ComputeAccessHook { /** * The `$inputs` array of every `__invoke` call, in order. * - * @var list> + * @var list> */ public array $batches = []; /** - * @param array $inputs + * @param array $inputs * @return iterable */ public function __invoke(array $inputs) : iterable { $this->batches[] = $inputs; - foreach ($inputs as $key => [$ownerId, $reviewerId]) { - yield $key => new Access($ownerId, $reviewerId, $ownerId === $reviewerId); + foreach ($inputs as $key => $repository) { + yield $key => new Access( + $repository->ownerId, + $repository->reviewerId, + $repository->ownerId === $repository->reviewerId, + ); } } } diff --git a/tests/HooksBatched/FindOrgPlanHook.php b/tests/HooksBatched/FindOrgPlanHook.php index e32e9b7..28f51ce 100644 --- a/tests/HooksBatched/FindOrgPlanHook.php +++ b/tests/HooksBatched/FindOrgPlanHook.php @@ -5,18 +5,23 @@ namespace Ruudk\GraphQLCodeGenerator\HooksBatched; use Ruudk\GraphQLCodeGenerator\Attribute\Hook; +use Ruudk\GraphQLCodeGenerator\HooksBatched\Generated\Hook\OrganizationId; /** - * Batched org-level hook taking a single field. Records every batch it is - * invoked with so the test can measure its invocation count. + * Batched org-level hook. Records every batch it is invoked with so the test + * can measure its invocation count. */ -#[Hook(name: 'findOrgPlan', batched: true)] +#[Hook(name: 'findOrgPlan', requires: <<<'GRAPHQL' + fragment OrganizationId on Organization { + id + } + GRAPHQL, batched: true)] final class FindOrgPlanHook { /** * The `$inputs` array of every `__invoke` call, in order. * - * @var list> + * @var list> */ public array $batches = []; @@ -28,15 +33,15 @@ public function __construct( ) {} /** - * @param array $inputs + * @param array $inputs * @return iterable */ public function __invoke(array $inputs) : iterable { $this->batches[] = $inputs; - foreach ($inputs as $key => [$id]) { - yield $key => $this->plans[$id] ?? new OrgPlan($id, 'free'); + foreach ($inputs as $key => $organization) { + yield $key => $this->plans[$organization->id] ?? new OrgPlan($organization->id, 'free'); } } } diff --git a/tests/HooksBatched/FindUserByIdHook.php b/tests/HooksBatched/FindUserByIdHook.php index 5d9d0a2..21b8c29 100644 --- a/tests/HooksBatched/FindUserByIdHook.php +++ b/tests/HooksBatched/FindUserByIdHook.php @@ -5,19 +5,23 @@ namespace Ruudk\GraphQLCodeGenerator\HooksBatched; use Ruudk\GraphQLCodeGenerator\Attribute\Hook; +use Ruudk\GraphQLCodeGenerator\HooksBatched\Generated\Hook\RepositoryOwnerId; /** - * Batched hook taking a single field. Records every batch it is invoked with so - * the test can measure that it runs exactly once per operation, with every - * occurrence's input in that single batch. + * Batched hook. Records every batch it is invoked with so the test can measure + * that it runs exactly once per operation, with every occurrence in that batch. */ -#[Hook(name: 'findUserById', batched: true)] +#[Hook(name: 'findUserById', requires: <<<'GRAPHQL' + fragment RepositoryOwnerId on Repository { + ownerId + } + GRAPHQL, batched: true)] final class FindUserByIdHook { /** * The `$inputs` array of every `__invoke` call, in order. * - * @var list> + * @var list> */ public array $batches = []; @@ -29,15 +33,15 @@ public function __construct( ) {} /** - * @param array $inputs + * @param array $inputs * @return iterable */ public function __invoke(array $inputs) : iterable { $this->batches[] = $inputs; - foreach ($inputs as $key => [$id]) { - yield $key => $this->users[$id] ?? null; + foreach ($inputs as $key => $repository) { + yield $key => $this->users[$repository->ownerId] ?? null; } } } diff --git a/tests/HooksBatched/Generated/Hook/OrganizationId.php b/tests/HooksBatched/Generated/Hook/OrganizationId.php new file mode 100644 index 0000000..c32de75 --- /dev/null +++ b/tests/HooksBatched/Generated/Hook/OrganizationId.php @@ -0,0 +1,24 @@ + $this->id ??= $this->data['id']; + } + + /** + * @param array{ + * 'id': string, + * ..., + * } $data + */ + public function __construct( + private readonly array $data, + ) {} +} diff --git a/tests/HooksBatched/Generated/Hook/RepositoryAccessFields.php b/tests/HooksBatched/Generated/Hook/RepositoryAccessFields.php new file mode 100644 index 0000000..a748fac --- /dev/null +++ b/tests/HooksBatched/Generated/Hook/RepositoryAccessFields.php @@ -0,0 +1,29 @@ + $this->ownerId ??= $this->data['ownerId']; + } + + public string $reviewerId { + get => $this->reviewerId ??= $this->data['reviewerId']; + } + + /** + * @param array{ + * 'ownerId': string, + * 'reviewerId': string, + * ..., + * } $data + */ + public function __construct( + private readonly array $data, + ) {} +} diff --git a/tests/HooksBatched/Generated/Hook/RepositoryOwnerId.php b/tests/HooksBatched/Generated/Hook/RepositoryOwnerId.php new file mode 100644 index 0000000..0b707bc --- /dev/null +++ b/tests/HooksBatched/Generated/Hook/RepositoryOwnerId.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/HooksBatched/Generated/Query/Test/Data.php b/tests/HooksBatched/Generated/Query/Test/Data.php index 9d42387..9101533 100644 --- a/tests/HooksBatched/Generated/Query/Test/Data.php +++ b/tests/HooksBatched/Generated/Query/Test/Data.php @@ -8,6 +8,9 @@ use Ruudk\GraphQLCodeGenerator\HooksBatched\ComputeAccessHook; use Ruudk\GraphQLCodeGenerator\HooksBatched\FindOrgPlanHook; use Ruudk\GraphQLCodeGenerator\HooksBatched\FindUserByIdHook; +use Ruudk\GraphQLCodeGenerator\HooksBatched\Generated\Hook\OrganizationId; +use Ruudk\GraphQLCodeGenerator\HooksBatched\Generated\Hook\RepositoryAccessFields; +use Ruudk\GraphQLCodeGenerator\HooksBatched\Generated\Hook\RepositoryOwnerId; use Ruudk\GraphQLCodeGenerator\HooksBatched\Generated\HookLoader; use Ruudk\GraphQLCodeGenerator\HooksBatched\Generated\Query\Test\Data\Organization; use Ruudk\GraphQLCodeGenerator\HooksBatched\OrgPlan; @@ -31,9 +34,9 @@ final class Data /** * @var array{ - * findOrgPlan: HookLoader, - * computeAccess: HookLoader, - * findUserById: HookLoader, + * findOrgPlan: HookLoader, + * computeAccess: HookLoader, + * findUserById: HookLoader, * ...>, * } */ @@ -92,35 +95,35 @@ public function __construct( } /** - * @return iterable + * @return iterable */ private function collectHookFindOrgPlanInputs() : iterable { foreach ($this->organizations as $item) { - yield [$item, [$item->id]]; + yield [$item, $item->buildOrganizationId()]; } } /** - * @return iterable + * @return iterable */ private function collectHookComputeAccessInputs() : iterable { foreach ($this->organizations as $item) { foreach ($item->repositories as $item1) { - yield [$item1, [$item1->ownerId, $item1->reviewerId]]; + yield [$item1, $item1->buildRepositoryAccessFields()]; } } } /** - * @return iterable + * @return iterable */ private function collectHookFindUserByIdInputs() : iterable { foreach ($this->organizations as $item) { foreach ($item->repositories as $item1) { - yield [$item1, [$item1->ownerId]]; + yield [$item1, $item1->buildRepositoryOwnerId()]; } } } diff --git a/tests/HooksBatched/Generated/Query/Test/Data/Organization.php b/tests/HooksBatched/Generated/Query/Test/Data/Organization.php index aae02fa..ef47526 100644 --- a/tests/HooksBatched/Generated/Query/Test/Data/Organization.php +++ b/tests/HooksBatched/Generated/Query/Test/Data/Organization.php @@ -5,6 +5,9 @@ namespace Ruudk\GraphQLCodeGenerator\HooksBatched\Generated\Query\Test\Data; use Ruudk\GraphQLCodeGenerator\HooksBatched\Access; +use Ruudk\GraphQLCodeGenerator\HooksBatched\Generated\Hook\OrganizationId; +use Ruudk\GraphQLCodeGenerator\HooksBatched\Generated\Hook\RepositoryAccessFields; +use Ruudk\GraphQLCodeGenerator\HooksBatched\Generated\Hook\RepositoryOwnerId; use Ruudk\GraphQLCodeGenerator\HooksBatched\Generated\HookLoader; use Ruudk\GraphQLCodeGenerator\HooksBatched\Generated\Query\Test\Data\Organization\Repository; use Ruudk\GraphQLCodeGenerator\HooksBatched\OrgPlan; @@ -47,9 +50,9 @@ final class Organization * ..., * } $data * @param array{ - * findOrgPlan: HookLoader, - * computeAccess: HookLoader, - * findUserById: HookLoader, + * findOrgPlan: HookLoader, + * computeAccess: HookLoader, + * findUserById: HookLoader, * ...>, * } $loaders */ @@ -57,4 +60,12 @@ public function __construct( private readonly array $data, private readonly array $loaders, ) {} + + /** + * @internal + */ + public function buildOrganizationId() : OrganizationId + { + return new OrganizationId($this->data); + } } diff --git a/tests/HooksBatched/Generated/Query/Test/Data/Organization/Repository.php b/tests/HooksBatched/Generated/Query/Test/Data/Organization/Repository.php index efc889a..2dc2613 100644 --- a/tests/HooksBatched/Generated/Query/Test/Data/Organization/Repository.php +++ b/tests/HooksBatched/Generated/Query/Test/Data/Organization/Repository.php @@ -5,6 +5,8 @@ namespace Ruudk\GraphQLCodeGenerator\HooksBatched\Generated\Query\Test\Data\Organization; use Ruudk\GraphQLCodeGenerator\HooksBatched\Access; +use Ruudk\GraphQLCodeGenerator\HooksBatched\Generated\Hook\RepositoryAccessFields; +use Ruudk\GraphQLCodeGenerator\HooksBatched\Generated\Hook\RepositoryOwnerId; use Ruudk\GraphQLCodeGenerator\HooksBatched\Generated\HookLoader; use Ruudk\GraphQLCodeGenerator\HooksBatched\User; @@ -45,8 +47,8 @@ final class Repository * ..., * } $data * @param array{ - * computeAccess: HookLoader, - * findUserById: HookLoader, + * computeAccess: HookLoader, + * findUserById: HookLoader, * ...>, * } $loaders */ @@ -54,4 +56,20 @@ public function __construct( private readonly array $data, private readonly array $loaders, ) {} + + /** + * @internal + */ + public function buildRepositoryAccessFields() : RepositoryAccessFields + { + return new RepositoryAccessFields($this->data); + } + + /** + * @internal + */ + public function buildRepositoryOwnerId() : RepositoryOwnerId + { + return new RepositoryOwnerId($this->data); + } } diff --git a/tests/HooksBatched/Generated/Query/Test/TestQuery.php b/tests/HooksBatched/Generated/Query/Test/TestQuery.php index 49710ee..1d2cbe2 100644 --- a/tests/HooksBatched/Generated/Query/Test/TestQuery.php +++ b/tests/HooksBatched/Generated/Query/Test/TestQuery.php @@ -18,11 +18,21 @@ organizations { id name + ... on Organization { + id + } repositories { id name ownerId reviewerId + ... on Repository { + ownerId + } + ... on Repository { + ownerId + reviewerId + } } } } diff --git a/tests/HooksBatched/HooksBatchedTest.php b/tests/HooksBatched/HooksBatchedTest.php index 3e98d38..3d129ab 100644 --- a/tests/HooksBatched/HooksBatchedTest.php +++ b/tests/HooksBatched/HooksBatchedTest.php @@ -104,33 +104,39 @@ public function testHooksAreBatchedAndInvokedOncePerOperation() : void // --- The batching evidence ------------------------------------------- // Each hook ran exactly once (the outer array has a single entry) and - // that single batch carried the distinct inputs, de-duplicated by - // value and in first-seen graph order. + // that single batch carried every occurrence, in graph order, as a + // typed data object built from the hook's `requires` fragment. - // Org-level hook: one batch, all 3 organizations distinct. + // Org-level hook: one batch holding all 3 organizations. + self::assertCount(1, $findOrgPlan->batches); self::assertSame( - [[['org-1'], ['org-2'], ['org-3']]], - $findOrgPlan->batches, + ['org-1', 'org-2', 'org-3'], + array_map(fn($organization) => $organization->id, $findOrgPlan->batches[0]), ); - // Repository-level single-field hook: 6 repositories, but only 2 - // distinct owner ids — what used to be 6 separate invocations is now - // one call with 2 inputs. + // Repository-level single-field hook: one batch holding all 6 + // repositories — what used to be 6 separate invocations. + self::assertCount(1, $findUserById->batches); self::assertSame( - [[['user-1'], ['user-2']]], - $findUserById->batches, + ['user-1', 'user-1', 'user-2', 'user-1', 'user-2', 'user-1'], + array_map(fn($repository) => $repository->ownerId, $findUserById->batches[0]), ); - // Repository-level two-field hook: 6 repositories collapse to 4 - // distinct (ownerId, reviewerId) tuples. + // Repository-level two-field hook: one batch of 6 repositories. + self::assertCount(1, $computeAccess->batches); self::assertSame( - [[ + [ ['user-1', 'user-2'], ['user-1', 'user-1'], ['user-2', 'user-1'], + ['user-1', 'user-2'], ['user-2', 'user-2'], - ]], - $computeAccess->batches, + ['user-1', 'user-1'], + ], + array_map( + fn($repository) => [$repository->ownerId, $repository->reviewerId], + $computeAccess->batches[0], + ), ); } @@ -202,10 +208,10 @@ public function testRepeatedAccessTriggersTheBatchOnlyOnce() : void self::assertSame($owner, $first->owner); self::assertSame('Alice', $second->owner?->name); - // Both repositories share owner "user-1", so the de-duplicated batch - // holds a single input and both resolve to the same User instance. + // One invocation, holding both repositories; both share owner "user-1" + // so they resolve to the same User instance. self::assertCount(1, $findUserById->batches); - self::assertCount(1, $findUserById->batches[0]); + self::assertCount(2, $findUserById->batches[0]); self::assertSame($owner, $second->owner); } diff --git a/tests/HooksBatched/Test.graphql b/tests/HooksBatched/Test.graphql index 0164d8d..0f5b1b0 100644 --- a/tests/HooksBatched/Test.graphql +++ b/tests/HooksBatched/Test.graphql @@ -5,7 +5,7 @@ query Test { # Org-level hook (one field). Runs once per organization: an N+1 # over the single `organizations` list. - plan @hook(name: "findOrgPlan", input: ["id"]) + plan @hook(name: "findOrgPlan") repositories { id @@ -15,10 +15,10 @@ query Test { # Simple hook (one field), nested inside two lists. Runs once # per repository: organizations x repositories invocations. - owner @hook(name: "findUserById", input: ["ownerId"]) + owner @hook(name: "findUserById") # Hook with two fields. Also runs once per repository. - access @hook(name: "computeAccess", input: ["ownerId", "reviewerId"]) + access @hook(name: "computeAccess") } } } diff --git a/tests/HooksInUnionVariant/FindUserByIdHook.php b/tests/HooksInUnionVariant/FindUserByIdHook.php index f3709c8..1f31cef 100644 --- a/tests/HooksInUnionVariant/FindUserByIdHook.php +++ b/tests/HooksInUnionVariant/FindUserByIdHook.php @@ -5,8 +5,16 @@ namespace Ruudk\GraphQLCodeGenerator\HooksInUnionVariant; use Ruudk\GraphQLCodeGenerator\Attribute\Hook; +use Ruudk\GraphQLCodeGenerator\HooksInUnionVariant\Generated\Hook\VariantAId; -#[Hook(name: 'findUserById')] +#[Hook( + name: 'findUserById', + requires: <<<'GRAPHQL' + fragment VariantAId on VariantA { + id + } + GRAPHQL +)] final readonly class FindUserByIdHook { /** @@ -16,8 +24,8 @@ public function __construct( private array $users = [], ) {} - public function __invoke(string $id) : ?User + public function __invoke(VariantAId $variant) : ?User { - return $this->users[$id] ?? null; + return $this->users[$variant->id] ?? null; } } diff --git a/tests/HooksInUnionVariant/Generated/Hook/VariantAId.php b/tests/HooksInUnionVariant/Generated/Hook/VariantAId.php new file mode 100644 index 0000000..dbc3c85 --- /dev/null +++ b/tests/HooksInUnionVariant/Generated/Hook/VariantAId.php @@ -0,0 +1,24 @@ + $this->id ??= $this->data['id']; + } + + /** + * @param array{ + * 'id': string, + * ..., + * } $data + */ + public function __construct( + private readonly array $data, + ) {} +} diff --git a/tests/HooksInUnionVariant/Generated/Query/Test/Data/Thing/AsVariantA.php b/tests/HooksInUnionVariant/Generated/Query/Test/Data/Thing/AsVariantA.php index 488dd6a..0e3ff9e 100644 --- a/tests/HooksInUnionVariant/Generated/Query/Test/Data/Thing/AsVariantA.php +++ b/tests/HooksInUnionVariant/Generated/Query/Test/Data/Thing/AsVariantA.php @@ -5,6 +5,7 @@ namespace Ruudk\GraphQLCodeGenerator\HooksInUnionVariant\Generated\Query\Test\Data\Thing; use Ruudk\GraphQLCodeGenerator\HooksInUnionVariant\FindUserByIdHook; +use Ruudk\GraphQLCodeGenerator\HooksInUnionVariant\Generated\Hook\VariantAId; use Ruudk\GraphQLCodeGenerator\HooksInUnionVariant\User; // This file was automatically generated and should not be edited. @@ -20,7 +21,7 @@ final class AsVariantA } public ?User $user { - get => $this->user ??= $this->hooks['findUserById']->__invoke($this->id); + get => $this->user ??= $this->hooks['findUserById']->__invoke($this->buildVariantAId()); } /** @@ -39,4 +40,12 @@ public function __construct( private readonly array $data, private readonly array $hooks, ) {} + + /** + * @internal + */ + public function buildVariantAId() : VariantAId + { + return new VariantAId($this->data); + } } diff --git a/tests/HooksInUnionVariant/Generated/Query/Test/TestQuery.php b/tests/HooksInUnionVariant/Generated/Query/Test/TestQuery.php index 0972236..45469e1 100644 --- a/tests/HooksInUnionVariant/Generated/Query/Test/TestQuery.php +++ b/tests/HooksInUnionVariant/Generated/Query/Test/TestQuery.php @@ -19,6 +19,9 @@ ... on VariantA { id realFieldA + ... on VariantA { + id + } } ... on VariantB { realFieldB diff --git a/tests/HooksInUnionVariant/Test.graphql b/tests/HooksInUnionVariant/Test.graphql index f161542..a44b1a3 100644 --- a/tests/HooksInUnionVariant/Test.graphql +++ b/tests/HooksInUnionVariant/Test.graphql @@ -5,7 +5,7 @@ query Test { ... on VariantA { id realFieldA - user @hook(name: "findUserById", input: ["id"]) + user @hook(name: "findUserById") } ... on VariantB { realFieldB diff --git a/tests/HooksThroughSoleFragmentSpread/FindDiscountCodeByIdHook.php b/tests/HooksThroughSoleFragmentSpread/FindDiscountCodeByIdHook.php index b18039a..e9bd1ec 100644 --- a/tests/HooksThroughSoleFragmentSpread/FindDiscountCodeByIdHook.php +++ b/tests/HooksThroughSoleFragmentSpread/FindDiscountCodeByIdHook.php @@ -5,8 +5,16 @@ namespace Ruudk\GraphQLCodeGenerator\HooksThroughSoleFragmentSpread; use Ruudk\GraphQLCodeGenerator\Attribute\Hook; +use Ruudk\GraphQLCodeGenerator\HooksThroughSoleFragmentSpread\Generated\Hook\OrderDiscountId; -#[Hook(name: 'findDiscountCodeById')] +#[Hook( + name: 'findDiscountCodeById', + requires: <<<'GRAPHQL' + fragment OrderDiscountId on Order { + discountId + } + GRAPHQL +)] final readonly class FindDiscountCodeByIdHook { /** @@ -16,8 +24,8 @@ public function __construct( private array $discountCodes = [], ) {} - public function __invoke(string $id) : ?DiscountCode + public function __invoke(OrderDiscountId $order) : ?DiscountCode { - return $this->discountCodes[$id] ?? null; + return $this->discountCodes[$order->discountId] ?? null; } } diff --git a/tests/HooksThroughSoleFragmentSpread/Generated/Fragment/ShowPaymentFlow/Order.php b/tests/HooksThroughSoleFragmentSpread/Generated/Fragment/ShowPaymentFlow/Order.php index 36551a9..099d802 100644 --- a/tests/HooksThroughSoleFragmentSpread/Generated/Fragment/ShowPaymentFlow/Order.php +++ b/tests/HooksThroughSoleFragmentSpread/Generated/Fragment/ShowPaymentFlow/Order.php @@ -6,13 +6,14 @@ use Ruudk\GraphQLCodeGenerator\HooksThroughSoleFragmentSpread\DiscountCode; use Ruudk\GraphQLCodeGenerator\HooksThroughSoleFragmentSpread\FindDiscountCodeByIdHook; +use Ruudk\GraphQLCodeGenerator\HooksThroughSoleFragmentSpread\Generated\Hook\OrderDiscountId; // This file was automatically generated and should not be edited. final class Order { public ?DiscountCode $discountCode { - get => $this->discountCode ??= $this->hooks['findDiscountCodeById']->__invoke($this->discountId); + get => $this->discountCode ??= $this->hooks['findDiscountCodeById']->__invoke($this->buildOrderDiscountId()); } public string $discountId { @@ -38,4 +39,12 @@ public function __construct( private readonly array $data, private readonly array $hooks, ) {} + + /** + * @internal + */ + public function buildOrderDiscountId() : OrderDiscountId + { + return new OrderDiscountId($this->data); + } } diff --git a/tests/HooksThroughSoleFragmentSpread/Generated/Hook/OrderDiscountId.php b/tests/HooksThroughSoleFragmentSpread/Generated/Hook/OrderDiscountId.php new file mode 100644 index 0000000..077d3ba --- /dev/null +++ b/tests/HooksThroughSoleFragmentSpread/Generated/Hook/OrderDiscountId.php @@ -0,0 +1,24 @@ + $this->discountId ??= $this->data['discountId']; + } + + /** + * @param array{ + * 'discountId': string, + * ..., + * } $data + */ + public function __construct( + private readonly array $data, + ) {} +} diff --git a/tests/HooksThroughSoleFragmentSpread/Generated/Query/Test/TestQuery.php b/tests/HooksThroughSoleFragmentSpread/Generated/Query/Test/TestQuery.php index 50eb66e..5de50d1 100644 --- a/tests/HooksThroughSoleFragmentSpread/Generated/Query/Test/TestQuery.php +++ b/tests/HooksThroughSoleFragmentSpread/Generated/Query/Test/TestQuery.php @@ -24,6 +24,9 @@ order { id discountId + ... on Order { + discountId + } } } diff --git a/tests/HooksThroughSoleFragmentSpread/ShowPaymentFlow.graphql b/tests/HooksThroughSoleFragmentSpread/ShowPaymentFlow.graphql index a8ba3da..31f18da 100644 --- a/tests/HooksThroughSoleFragmentSpread/ShowPaymentFlow.graphql +++ b/tests/HooksThroughSoleFragmentSpread/ShowPaymentFlow.graphql @@ -3,6 +3,6 @@ fragment ShowPaymentFlow on PaymentFlow { order { id discountId - discountCode @hook(name: "findDiscountCodeById", input: ["discountId"]) + discountCode @hook(name: "findDiscountCodeById") } } diff --git a/tests/HooksWithCustomTypeInitializer/FindUserByIdHook.php b/tests/HooksWithCustomTypeInitializer/FindUserByIdHook.php index 6a911ce..0ae0d75 100644 --- a/tests/HooksWithCustomTypeInitializer/FindUserByIdHook.php +++ b/tests/HooksWithCustomTypeInitializer/FindUserByIdHook.php @@ -5,8 +5,18 @@ namespace Ruudk\GraphQLCodeGenerator\HooksWithCustomTypeInitializer; use Ruudk\GraphQLCodeGenerator\Attribute\Hook; +use Ruudk\GraphQLCodeGenerator\HooksWithCustomTypeInitializer\Generated\Hook\ProjectCreatorId; -#[Hook(name: 'findUserById')] +#[Hook( + name: 'findUserById', + requires: <<<'GRAPHQL' + fragment ProjectCreatorId on Project { + creator { + id + } + } + GRAPHQL +)] final readonly class FindUserByIdHook { /** @@ -16,8 +26,8 @@ public function __construct( private array $users = [], ) {} - public function __invoke(string $id) : ?User + public function __invoke(ProjectCreatorId $project) : ?User { - return $this->users[$id] ?? null; + return $this->users[$project->creator->id] ?? null; } } diff --git a/tests/HooksWithCustomTypeInitializer/Generated/Hook/ProjectCreatorId.php b/tests/HooksWithCustomTypeInitializer/Generated/Hook/ProjectCreatorId.php new file mode 100644 index 0000000..bdeae09 --- /dev/null +++ b/tests/HooksWithCustomTypeInitializer/Generated/Hook/ProjectCreatorId.php @@ -0,0 +1,29 @@ + $this->creator ??= new Creator($this->data['creator']); + } + + /** + * @param array{ + * 'creator': array{ + * 'id': string, + * ..., + * }, + * ..., + * } $data + */ + public function __construct( + private readonly array $data, + ) {} +} diff --git a/tests/HooksWithCustomTypeInitializer/Generated/Hook/ProjectCreatorId/Creator.php b/tests/HooksWithCustomTypeInitializer/Generated/Hook/ProjectCreatorId/Creator.php new file mode 100644 index 0000000..291fddf --- /dev/null +++ b/tests/HooksWithCustomTypeInitializer/Generated/Hook/ProjectCreatorId/Creator.php @@ -0,0 +1,24 @@ + $this->id ??= $this->data['id']; + } + + /** + * @param array{ + * 'id': string, + * ..., + * } $data + */ + public function __construct( + private readonly array $data, + ) {} +} diff --git a/tests/HooksWithCustomTypeInitializer/Generated/Query/Test/Data/Viewer/Project.php b/tests/HooksWithCustomTypeInitializer/Generated/Query/Test/Data/Viewer/Project.php index 2dc12f5..30da52e 100644 --- a/tests/HooksWithCustomTypeInitializer/Generated/Query/Test/Data/Viewer/Project.php +++ b/tests/HooksWithCustomTypeInitializer/Generated/Query/Test/Data/Viewer/Project.php @@ -5,6 +5,7 @@ namespace Ruudk\GraphQLCodeGenerator\HooksWithCustomTypeInitializer\Generated\Query\Test\Data\Viewer; use Ruudk\GraphQLCodeGenerator\HooksWithCustomTypeInitializer\FindUserByIdHook; +use Ruudk\GraphQLCodeGenerator\HooksWithCustomTypeInitializer\Generated\Hook\ProjectCreatorId; use Ruudk\GraphQLCodeGenerator\HooksWithCustomTypeInitializer\Generated\Query\Test\Data\Viewer\Project\Creator; use Ruudk\GraphQLCodeGenerator\HooksWithCustomTypeInitializer\User; @@ -21,7 +22,7 @@ final class Project } public ?User $user { - get => $this->user ??= $this->hooks['findUserById']->__invoke($this->creator->id); + get => $this->user ??= $this->hooks['findUserById']->__invoke($this->buildProjectCreatorId()); } /** @@ -42,4 +43,12 @@ public function __construct( private readonly array $data, private readonly array $hooks, ) {} + + /** + * @internal + */ + public function buildProjectCreatorId() : ProjectCreatorId + { + return new ProjectCreatorId($this->data); + } } diff --git a/tests/HooksWithCustomTypeInitializer/Generated/Query/Test/TestQuery.php b/tests/HooksWithCustomTypeInitializer/Generated/Query/Test/TestQuery.php index 9306581..cc70f16 100644 --- a/tests/HooksWithCustomTypeInitializer/Generated/Query/Test/TestQuery.php +++ b/tests/HooksWithCustomTypeInitializer/Generated/Query/Test/TestQuery.php @@ -23,6 +23,11 @@ creator { id } + ... on Project { + creator { + id + } + } } } } diff --git a/tests/HooksWithCustomTypeInitializer/Test.graphql b/tests/HooksWithCustomTypeInitializer/Test.graphql index 59e5103..b73a64e 100644 --- a/tests/HooksWithCustomTypeInitializer/Test.graphql +++ b/tests/HooksWithCustomTypeInitializer/Test.graphql @@ -10,7 +10,7 @@ query Test { id } - user @hook(name: "findUserById", input: ["creator.id"]) + user @hook(name: "findUserById") } } } diff --git a/tests/HooksWithFragmentSpread/FindUserByIdHook.php b/tests/HooksWithFragmentSpread/FindUserByIdHook.php index 7ec5b2a..6a4cd05 100644 --- a/tests/HooksWithFragmentSpread/FindUserByIdHook.php +++ b/tests/HooksWithFragmentSpread/FindUserByIdHook.php @@ -5,8 +5,18 @@ namespace Ruudk\GraphQLCodeGenerator\HooksWithFragmentSpread; use Ruudk\GraphQLCodeGenerator\Attribute\Hook; +use Ruudk\GraphQLCodeGenerator\HooksWithFragmentSpread\Generated\Hook\ProjectCreatorId; -#[Hook(name: 'findUserById')] +#[Hook( + name: 'findUserById', + requires: <<<'GRAPHQL' + fragment ProjectCreatorId on Project { + creator { + id + } + } + GRAPHQL +)] final readonly class FindUserByIdHook { /** @@ -16,8 +26,8 @@ public function __construct( private array $users = [], ) {} - public function __invoke(string $id) : ?User + public function __invoke(ProjectCreatorId $project) : ?User { - return $this->users[$id] ?? null; + return $this->users[$project->creator->id] ?? null; } } diff --git a/tests/HooksWithFragmentSpread/Generated/Fragment/ProjectSummary.php b/tests/HooksWithFragmentSpread/Generated/Fragment/ProjectSummary.php index 9c6ac67..8360227 100644 --- a/tests/HooksWithFragmentSpread/Generated/Fragment/ProjectSummary.php +++ b/tests/HooksWithFragmentSpread/Generated/Fragment/ProjectSummary.php @@ -6,6 +6,7 @@ use Ruudk\GraphQLCodeGenerator\HooksWithFragmentSpread\FindUserByIdHook; use Ruudk\GraphQLCodeGenerator\HooksWithFragmentSpread\Generated\Fragment\ProjectSummary\Creator; +use Ruudk\GraphQLCodeGenerator\HooksWithFragmentSpread\Generated\Hook\ProjectCreatorId; use Ruudk\GraphQLCodeGenerator\HooksWithFragmentSpread\User; // This file was automatically generated and should not be edited. @@ -21,7 +22,7 @@ final class ProjectSummary } public ?User $user { - get => $this->user ??= $this->hooks['findUserById']->__invoke($this->creator->id); + get => $this->user ??= $this->hooks['findUserById']->__invoke($this->buildProjectCreatorId()); } /** @@ -42,4 +43,12 @@ public function __construct( private readonly array $data, private readonly array $hooks, ) {} + + /** + * @internal + */ + public function buildProjectCreatorId() : ProjectCreatorId + { + return new ProjectCreatorId($this->data); + } } diff --git a/tests/HooksWithFragmentSpread/Generated/Hook/ProjectCreatorId.php b/tests/HooksWithFragmentSpread/Generated/Hook/ProjectCreatorId.php new file mode 100644 index 0000000..127bc1c --- /dev/null +++ b/tests/HooksWithFragmentSpread/Generated/Hook/ProjectCreatorId.php @@ -0,0 +1,29 @@ + $this->creator ??= new Creator($this->data['creator']); + } + + /** + * @param array{ + * 'creator': array{ + * 'id': string, + * ..., + * }, + * ..., + * } $data + */ + public function __construct( + private readonly array $data, + ) {} +} diff --git a/tests/HooksWithFragmentSpread/Generated/Hook/ProjectCreatorId/Creator.php b/tests/HooksWithFragmentSpread/Generated/Hook/ProjectCreatorId/Creator.php new file mode 100644 index 0000000..8f7306c --- /dev/null +++ b/tests/HooksWithFragmentSpread/Generated/Hook/ProjectCreatorId/Creator.php @@ -0,0 +1,24 @@ + $this->id ??= $this->data['id']; + } + + /** + * @param array{ + * 'id': string, + * ..., + * } $data + */ + public function __construct( + private readonly array $data, + ) {} +} diff --git a/tests/HooksWithFragmentSpread/Generated/Query/Test/TestQuery.php b/tests/HooksWithFragmentSpread/Generated/Query/Test/TestQuery.php index adff677..0993098 100644 --- a/tests/HooksWithFragmentSpread/Generated/Query/Test/TestQuery.php +++ b/tests/HooksWithFragmentSpread/Generated/Query/Test/TestQuery.php @@ -32,6 +32,11 @@ creator { id } + ... on Project { + creator { + id + } + } } GRAPHQL; diff --git a/tests/HooksWithFragmentSpread/ProjectSummary.graphql b/tests/HooksWithFragmentSpread/ProjectSummary.graphql index feffb00..915f7b5 100644 --- a/tests/HooksWithFragmentSpread/ProjectSummary.graphql +++ b/tests/HooksWithFragmentSpread/ProjectSummary.graphql @@ -4,5 +4,5 @@ fragment ProjectSummary on Project { id } - user @hook(name: "findUserById", input: ["creator.id"]) + user @hook(name: "findUserById") } diff --git a/tests/HooksWithInterfaceRequires/FindOwnerHook.php b/tests/HooksWithInterfaceRequires/FindOwnerHook.php new file mode 100644 index 0000000..ea575d4 --- /dev/null +++ b/tests/HooksWithInterfaceRequires/FindOwnerHook.php @@ -0,0 +1,36 @@ + $owners + */ + public function __construct( + private array $owners = [], + ) {} + + public function __invoke(NodeId $node) : ?Owner + { + return $this->owners[$node->id] ?? null; + } +} diff --git a/tests/HooksWithInterfaceRequires/Generated/Hook/NodeId.php b/tests/HooksWithInterfaceRequires/Generated/Hook/NodeId.php new file mode 100644 index 0000000..dbda589 --- /dev/null +++ b/tests/HooksWithInterfaceRequires/Generated/Hook/NodeId.php @@ -0,0 +1,24 @@ + $this->id ??= $this->data['id']; + } + + /** + * @param array{ + * 'id': string, + * ..., + * } $data + */ + public function __construct( + private readonly array $data, + ) {} +} diff --git a/tests/HooksWithInterfaceRequires/Generated/Query/Test/Data.php b/tests/HooksWithInterfaceRequires/Generated/Query/Test/Data.php new file mode 100644 index 0000000..161d026 --- /dev/null +++ b/tests/HooksWithInterfaceRequires/Generated/Query/Test/Data.php @@ -0,0 +1,66 @@ + + */ + public array $articles { + get => $this->articles ??= array_map(fn($item) => new Article($item, $this->hooks), $this->data['articles']); + } + + /** + * @var list