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
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,55 @@ 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
list — or a list nested in a list — the hook fires once per element: a classic N+1.

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.

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:

```php
use Ruudk\GraphQLCodeGenerator\Attribute\Hook;

#[Hook(name: 'findUserById', 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
*/
public function __invoke(array $inputs): iterable
{
foreach ($inputs as $key => [$id]) {
yield $key => $this->users->find($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.

## Requirements

- **PHP 8.4+** (uses property hooks, readonly classes, and other modern features)
Expand Down
8 changes: 8 additions & 0 deletions src/Attribute/Hook.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,15 @@
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class Hook
{
/**
* @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
* `__invoke` signature must then be
* `__invoke(array $inputs): iterable<int, ...>`.
*/
public function __construct(
public string $name,
public bool $batched = false,
) {}
}
62 changes: 60 additions & 2 deletions src/Config/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionParameter;
use Ruudk\GraphQLCodeGenerator\Attribute\Hook;
use Ruudk\GraphQLCodeGenerator\TypeInitializer;
use Symfony\Component\TypeInfo\Exception\UnsupportedException;
Expand Down Expand Up @@ -283,6 +285,12 @@ public function withTwigProcessingDirectory(string $directory, string ...$direct
* `#[Hook(name: '...')]` naming the hook for use in `@hook(name: ...)` directives.
* The return type is inferred from the `__invoke` signature.
*
* 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.
*
* @param class-string $class
* @throws InvalidArgumentException
* @throws \Webmozart\Assert\InvalidArgumentException
Expand All @@ -300,7 +308,16 @@ public function withHook(string $class) : self
$class,
));

$hookName = $attributes[0]->newInstance()->name;
$hook = $attributes[0]->newInstance();
$hookName = $hook->name;
$batched = $hook->batched;

Assert::regex($hookName, '/^[a-zA-Z_][a-zA-Z0-9_]*$/', sprintf(
'Hook name "%s" (on %s) must be a valid PHP identifier.',
$hookName,
$class,
));

$method = new ReflectionMethod($class, '__invoke');

Assert::keyNotExists($this->hooks, $hookName, sprintf(
Expand All @@ -322,11 +339,52 @@ public function withHook(string $class) : self
);
}

if ($batched) {
$parameters = $method->getParameters();

if (count($parameters) !== 1 || ! $this->isArrayHookParameter($parameters[0])) {
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.",
$hookName,
$class,
));
}

if ( ! $returnType instanceof Type\CollectionType) {
throw new InvalidArgumentException(sprintf(
'Batched hook "%s" (%s::__invoke) must return an iterable; declare '
. '@return iterable<int, V> so the value type can be inferred.',
$hookName,
$class,
));
}

$returnType = $returnType->getCollectionValueType();
}

$hooks = $this->hooks;
$hooks[$hookName] = new HookDefinition($hookName, $class, $returnType);
$hooks[$hookName] = new HookDefinition($hookName, $class, $returnType, $batched);

return clone ($this, [
'hooks' => $hooks,
]);
}

/**
* A batched hook's single parameter must be `array` (or `iterable`/untyped) — it
* receives the whole batch of input tuples.
*/
private function isArrayHookParameter(ReflectionParameter $parameter) : bool
{
$type = $parameter->getType();

if ($type === null) {
return true;
}

return $type instanceof ReflectionNamedType
&& in_array($type->getName(), ['array', 'iterable'], true);
}
}
4 changes: 4 additions & 0 deletions src/Config/HookDefinition.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@
{
/**
* @param class-string $class
* @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.
*/
public function __construct(
public string $name,
public string $class,
public Type $returnType,
public bool $batched = false,
) {}
}
8 changes: 7 additions & 1 deletion src/Executor/PlanExecutor.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use Ruudk\GraphQLCodeGenerator\Generator\EnumTypeGenerator;
use Ruudk\GraphQLCodeGenerator\Generator\ErrorClassGenerator;
use Ruudk\GraphQLCodeGenerator\Generator\ExceptionClassGenerator;
use Ruudk\GraphQLCodeGenerator\Generator\HookLoaderGenerator;
use Ruudk\GraphQLCodeGenerator\Generator\InputTypeGenerator;
use Ruudk\GraphQLCodeGenerator\Generator\NodeNotFoundExceptionGenerator;
use Ruudk\GraphQLCodeGenerator\Generator\OperationClassGenerator;
Expand All @@ -30,6 +31,7 @@
use Ruudk\GraphQLCodeGenerator\Planner\OperationPlan;
use Ruudk\GraphQLCodeGenerator\Planner\Plan\DataClassPlan;
use Ruudk\GraphQLCodeGenerator\Planner\Plan\EnumClassPlan;
use Ruudk\GraphQLCodeGenerator\Planner\Plan\HookLoaderPlan;
use Ruudk\GraphQLCodeGenerator\Planner\Plan\InputClassPlan;
use Ruudk\GraphQLCodeGenerator\Planner\Plan\NodeNotFoundExceptionPlan;
use Ruudk\GraphQLCodeGenerator\Planner\PlannerResult;
Expand All @@ -54,14 +56,15 @@ final class PlanExecutor
private readonly ExceptionClassGenerator $exceptionClassGenerator;
private readonly InputTypeGenerator $inputTypeGenerator;
private readonly NodeNotFoundExceptionGenerator $nodeNotFoundExceptionGenerator;
private readonly HookLoaderGenerator $hookLoaderGenerator;
public readonly ClassHookUsageRegistry $hookUsageRegistry;
private Parser $phpParser;
private Filesystem $filesystem;

public function __construct(
private Config $config,
) {
$this->hookUsageRegistry = new ClassHookUsageRegistry();
$this->hookUsageRegistry = new ClassHookUsageRegistry($config->hooks);

// User-registered initializers run before the catch-all `ObjectTypeInitializer`
// so type-specific handlers (e.g. Money) match ahead of the generic fallback.
Expand All @@ -84,6 +87,7 @@ public function __construct(
$this->exceptionClassGenerator = new ExceptionClassGenerator($config);
$this->inputTypeGenerator = new InputTypeGenerator($config);
$this->nodeNotFoundExceptionGenerator = new NodeNotFoundExceptionGenerator($config);
$this->hookLoaderGenerator = new HookLoaderGenerator($config);
$this->phpParser = new ParserFactory()->createForNewestSupportedVersion();
$this->filesystem = new Filesystem();
}
Expand Down Expand Up @@ -208,6 +212,8 @@ private function generateClass(object $class, array $plansByFqcn) : string

NodeNotFoundExceptionPlan::class => $this->nodeNotFoundExceptionGenerator->generate(),

HookLoaderPlan::class => $this->hookLoaderGenerator->generate(),

default => throw new LogicException('Unknown class type: ' . $class::class),
};
}
Expand Down
Loading
Loading