Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 55 additions & 47 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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**:

Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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<int, array{string}> $inputs one [id] tuple per occurrence
* @return iterable<int, ?User> echo the same integer keys
* @param array<int, ProjectCreator> $inputs one per occurrence
* @return iterable<int, ?User> 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<int, V>` it yields; declare `@return iterable<int, ...>` 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<int, V>` it yields; declare
`@return iterable<int, ...>` 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()`

Expand Down
10 changes: 9 additions & 1 deletion src/Attribute/Hook.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, ...>`.
*/
public function __construct(
public string $name,
public string $requires,
public bool $batched = false,
) {}
}
70 changes: 61 additions & 9 deletions src/Config/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<int, array{...}>` (one input tuple per
* occurrence, integer-keyed by the library) and must return/yield
* `iterable<int, V>` 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<int, DataClass> $inputs): iterable<int, V>`, 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
{
Expand All @@ -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(
Expand Down Expand Up @@ -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,
));
}

Expand All @@ -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,
Expand Down
13 changes: 12 additions & 1 deletion src/Config/HookDefinition.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Ruudk\GraphQLCodeGenerator\Config;

use GraphQL\Language\AST\FragmentDefinitionNode;
use Symfony\Component\TypeInfo\Type;

final readonly class HookDefinition
Expand All @@ -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<int, V>` 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,
) {}
}
Loading
Loading