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