diff --git a/README.md b/README.md index dba32b0..b8ffc69 100644 --- a/README.md +++ b/README.md @@ -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 $inputs one [id] tuple 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); + } + } +} +``` + +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. + ## Requirements - **PHP 8.4+** (uses property hooks, readonly classes, and other modern features) diff --git a/src/Attribute/Hook.php b/src/Attribute/Hook.php index 9c87335..b2e00de 100644 --- a/src/Attribute/Hook.php +++ b/src/Attribute/Hook.php @@ -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`. + */ public function __construct( public string $name, + public bool $batched = false, ) {} } diff --git a/src/Config/Config.php b/src/Config/Config.php index ecf1a39..b6d116f 100644 --- a/src/Config/Config.php +++ b/src/Config/Config.php @@ -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; @@ -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` (one input tuple per + * occurrence, integer-keyed by the library) and must return/yield + * `iterable` echoing the same integer keys. + * * @param class-string $class * @throws InvalidArgumentException * @throws \Webmozart\Assert\InvalidArgumentException @@ -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( @@ -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 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); + } } diff --git a/src/Config/HookDefinition.php b/src/Config/HookDefinition.php index 5f67400..5c28423 100644 --- a/src/Config/HookDefinition.php +++ b/src/Config/HookDefinition.php @@ -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` return. */ public function __construct( public string $name, public string $class, public Type $returnType, + public bool $batched = false, ) {} } diff --git a/src/Executor/PlanExecutor.php b/src/Executor/PlanExecutor.php index b89d04e..906fbf8 100644 --- a/src/Executor/PlanExecutor.php +++ b/src/Executor/PlanExecutor.php @@ -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; @@ -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; @@ -54,6 +56,7 @@ 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; @@ -61,7 +64,7 @@ final class PlanExecutor 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. @@ -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(); } @@ -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), }; } diff --git a/src/Generator/DataClassGenerator.php b/src/Generator/DataClassGenerator.php index 02bc76c..04aa21a 100644 --- a/src/Generator/DataClassGenerator.php +++ b/src/Generator/DataClassGenerator.php @@ -9,12 +9,14 @@ use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\UnionType; use Ruudk\CodeGenerator\CodeGenerator; +use Ruudk\CodeGenerator\Group; use Ruudk\GraphQLCodeGenerator\Attribute\Generated; use Ruudk\GraphQLCodeGenerator\Config\Config; use Ruudk\GraphQLCodeGenerator\GraphQL\AST\Printer; use Ruudk\GraphQLCodeGenerator\Planner\Plan\DataClassPlan; use Ruudk\GraphQLCodeGenerator\Planner\Source\GraphQLFileSource; 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; @@ -55,15 +57,17 @@ private function initializeFragmentObject(FragmentObjectType $type, CodeGenerato * 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. + * 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 + private function buildHookInputAccessor(string $path, SymfonyType $fields, array $plansByFqcn, string $base = '$this') : string { $segments = explode('.', $path); - $accessor = '$this'; + $accessor = $base; $chainNullable = false; $shape = $this->unwrapShape($fields); @@ -141,6 +145,362 @@ private function unwrapShape(SymfonyType $type) : ArrayShapeType return $type; } + /** + * 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()`. + */ + private function collectMethodName(string $hookName) : string + { + return 'collectHook' . ucfirst($hookName) . 'Inputs'; + } + + /** + * 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. + * + * @param array $batchedHooks + * @param array $plansByFqcn + * @throws InvalidArgumentException + */ + private function dumpLoadersShape(array $batchedHooks, array $plansByFqcn, CodeGenerator $generator) : string + { + $hookLoader = $generator->import($this->fullyQualified('HookLoader')); + $entries = []; + + foreach (array_keys($batchedHooks) as $name) { + $entries[] = sprintf( + '%s: %s<%s, %s>', + $name, + $hookLoader, + TypeDumper::dump($this->resolveHookInputTuple($name, $plansByFqcn), $generator->import(...)), + TypeDumper::dump($this->config->hooks[$name]->returnType, $generator->import(...)), + ); + } + + return sprintf('array{%s}', implode(', ', $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 + * field is optional. Used to emit correctly null-guarded collect traversals. + */ + private function effectivePropertyType(SymfonyType $fieldType, ?SymfonyType $payloadType, bool $optional) : SymfonyType + { + $propertyType = $fieldType; + + if ($payloadType instanceof SymfonyType\NullableType && ! ($fieldType instanceof SymfonyType\NullableType)) { + $propertyType = SymfonyType::nullable($fieldType); + } + + if ($optional && ! ($propertyType instanceof SymfonyType\NullableType)) { + $propertyType = SymfonyType::nullable($propertyType); + } + + return $propertyType; + } + + /** + * Peel nullable/collection/throw-when-null wrappers; return the FQCN of the + * nested ObjectType if that is what remains. Mirrors `Planner::unwrapToObjectType` + * so the collect traversal reaches exactly the classes hook propagation marked. + */ + private function unwrapToObjectClassName(SymfonyType $type) : ?string + { + while (true) { + if ($type instanceof ThrowWhenNullPropertyType) { + $type = $type->getWrappedType(); + + continue; + } + + if ($type instanceof SymfonyType\NullableType) { + $type = $type->getWrappedType(); + + continue; + } + + if ($type instanceof SymfonyType\CollectionType) { + $type = $type->getCollectionValueType(); + + continue; + } + + break; + } + + return $type instanceof SymfonyType\ObjectType ? $type->getClassName() : null; + } + + /** + * Emit the structural descent into one field: null guards for nullable + * values, `foreach` for collections, recursing into the child class body + * once a generated object is reached. + * + * @param array $plansByFqcn + * @throws InvalidArgumentException + * @return iterable + */ + private function emitCollectDescent(string $accessor, SymfonyType $type, DataClassPlan $childPlan, string $hookName, array $plansByFqcn, CodeGenerator $generator, int $depth) : iterable + { + if ($type instanceof ThrowWhenNullPropertyType) { + yield from $this->emitCollectDescent($accessor, $type->getWrappedType(), $childPlan, $hookName, $plansByFqcn, $generator, $depth); + + return; + } + + if ($type instanceof SymfonyType\NullableType) { + yield sprintf('if (%s !== null) {', $accessor); + yield $generator->indent(function () use ($accessor, $type, $childPlan, $hookName, $plansByFqcn, $generator, $depth) { + yield from $this->emitCollectDescent($accessor, $type->getWrappedType(), $childPlan, $hookName, $plansByFqcn, $generator, $depth); + }); + yield '}'; + + return; + } + + if ($type instanceof SymfonyType\CollectionType) { + $item = '$item' . ($depth > 0 ? (string) $depth : ''); + yield sprintf('foreach (%s as %s) {', $accessor, $item); + yield $generator->indent(function () use ($item, $type, $childPlan, $hookName, $plansByFqcn, $generator, $depth) { + yield from $this->emitCollectDescent($item, $type->getCollectionValueType(), $childPlan, $hookName, $plansByFqcn, $generator, $depth + 1); + }); + yield '}'; + + return; + } + + if ($type instanceof SymfonyType\ObjectType) { + yield from $this->emitCollectClassBody($accessor, $childPlan, $hookName, $plansByFqcn, $generator, $depth); + } + } + + /** + * Emit the body that walks one class's typed properties collecting a + * batched hook's `[$owner, $inputTuple]` pairs — its own hook field first, + * then a recursive descent into each child that reaches the hook. `$accessor` + * is the expression holding the current class instance. + * + * @param array $plansByFqcn + * @throws InvalidArgumentException + * @return iterable + */ + private function emitCollectClassBody(string $accessor, DataClassPlan $plan, string $hookName, array $plansByFqcn, CodeGenerator $generator, int $depth) : iterable + { + $fields = $plan->fields; + + if ($fields instanceof SymfonyType\NullableType) { + $fields = $fields->getWrappedType(); + } + + if ( ! $fields instanceof ArrayShapeType) { + return; + } + + $payloadShape = $plan->payloadShape; + + if ($payloadShape instanceof SymfonyType\NullableType) { + $payloadShape = $payloadShape->getWrappedType(); + } + + $payloadFieldTypes = []; + $optionalFields = []; + + if ($payloadShape instanceof ArrayShapeType) { + foreach ($payloadShape->getShape() as $payloadName => $payloadValue) { + if ($payloadValue['optional']) { + $optionalFields[$payloadName] = true; + } + + $payloadFieldTypes[$payloadName] = $payloadValue['type']; + } + } + + foreach ($fields->getShape() as $fieldName => $fieldValue) { + Assert::string($fieldName); + $fieldType = $fieldValue['type']; + + 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)); + } + + continue; + } + + if ($fieldType instanceof ThrowWhenNullPropertyType) { + $propertyType = $fieldType->getWrappedType(); + } else { + $optional = $fieldValue['optional'] || isset($optionalFields[$fieldName]); + $propertyType = $this->effectivePropertyType( + $fieldType, + $payloadFieldTypes[$fieldName] ?? null, + $optional, + ); + } + + $leafFqcn = $this->unwrapToObjectClassName($propertyType); + + if ($leafFqcn === null + || ! isset($plansByFqcn[$leafFqcn]) + || ! isset($plansByFqcn[$leafFqcn]->usedHooks[$hookName]) + ) { + continue; + } + + yield from $this->emitCollectDescent( + $accessor . '->' . $fieldName, + $propertyType, + $plansByFqcn[$leafFqcn], + $hookName, + $plansByFqcn, + $generator, + $depth, + ); + } + } + + /** + * 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 + * `HookLoader` to batch. Keeping the walk on `Data` lets it stay private + * and leaves nested classes free of generated traversal methods. + * + * @param array $plansByFqcn + * @throws InvalidArgumentException + * @return iterable + */ + private function dumpCollectMethods(DataClassPlan $plan, array $plansByFqcn, CodeGenerator $generator) : iterable + { + if ( ! $plan->isData) { + return; + } + + foreach (array_keys($plan->usedHooks) as $hookName) { + if ( ! $this->config->hooks[$hookName]->batched) { + continue; + } + + yield ''; + yield from $generator->docComment(sprintf( + '@return iterable', + TypeDumper::dump($this->resolveHookInputTuple($hookName, $plansByFqcn), $generator->import(...)), + )); + yield sprintf('private function %s() : iterable', $this->collectMethodName($hookName)); + yield '{'; + yield $generator->indent(function () use ($plan, $hookName, $plansByFqcn, $generator) { + yield from $this->emitCollectClassBody('$this', $plan, $hookName, $plansByFqcn, $generator, 0); + }); + yield '}'; + } + } + /** * @param array $plansByFqcn */ @@ -294,6 +654,19 @@ function () use ($plan, $parentType, $nodesType, $fields, $isData, $isMutationDa $fieldName, ); yield $generator->indent(function () use ($fieldType, $fieldName, $fields, $plansByFqcn, $generator) { + // Batched hook: delegate to the per-operation HookLoader, + // which resolves the whole batch once and looks this + // instance up by object identity. + if ($fieldType->batched) { + yield sprintf( + 'get => $this->%s ??= $this->loaders[%s]->resolve($this);', + $fieldName, + var_export($fieldType->hookName, true), + ); + + return; + } + $args = []; foreach ($fieldType->inputPaths as $path) { @@ -693,10 +1066,40 @@ function () use ($plan, $parentType, $nodesType, $fields, $isData, $isMutationDa yield 'public readonly array $errors;'; } - $usesHooks = $plan->usedHooks !== []; + // Split the hooks this class touches into legacy (per-instance + // __invoke) and batched (resolved once via a HookLoader). + $legacyHooks = []; + $batchedHooks = []; + + foreach (array_keys($plan->usedHooks) as $hookName) { + if ($this->config->hooks[$hookName]->batched) { + $batchedHooks[$hookName] = true; + } else { + $legacyHooks[$hookName] = true; + } + } + + $usesBatched = $batchedHooks !== []; + // The Data class always receives every hook instance (it needs + // them to build the loaders); a nested class only needs $hooks + // for legacy hooks. The Data class builds $loaders itself; + // nested classes receive it. + $hooksParam = $isData ? $plan->usedHooks : $legacyHooks; + $needsHooksParam = $hooksParam !== []; + $needsLoadersParam = ! $isData && $usesBatched; + $buildsLoaders = $isData && $usesBatched; + + if ($buildsLoaders) { + yield ''; + yield from $generator->docComment(sprintf( + '@var %s', + $this->dumpLoadersShape($batchedHooks, $plansByFqcn, $generator), + )); + yield 'private readonly array $loaders;'; + } yield ''; - yield from $generator->docComment(function () use ($plan, $isData, $usesHooks, $generator, $payloadShape) { + yield from $generator->docComment(function () use ($isData, $generator, $payloadShape, $hooksParam, $needsHooksParam, $needsLoadersParam, $batchedHooks, $plansByFqcn) { yield sprintf( '@param %s $data', TypeDumper::dump($payloadShape, $generator->import(...)), @@ -716,15 +1119,22 @@ function () use ($plan, $parentType, $nodesType, $fields, $isData, $isMutationDa ); } - if ($usesHooks) { + if ($needsHooksParam) { yield sprintf( '@param %s $hooks', - TypeDumper::dump($this->buildHooksShape($plan->usedHooks), $generator->import(...)), + TypeDumper::dump($this->buildHooksShape($hooksParam), $generator->import(...)), + ); + } + + if ($needsLoadersParam) { + yield sprintf( + '@param %s $loaders', + $this->dumpLoadersShape($batchedHooks, $plansByFqcn, $generator), ); } }); yield 'public function __construct('; - yield $generator->indent(function () use ($generator, $payloadShape, $isData, $usesHooks) { + yield $generator->indent(function () use ($generator, $payloadShape, $isData, $needsHooksParam, $needsLoadersParam) { yield sprintf( 'private readonly %s $data,', $this->dumpPHPType($payloadShape, $generator->import(...)), @@ -734,20 +1144,46 @@ function () use ($plan, $parentType, $nodesType, $fields, $isData, $isMutationDa yield 'array $errors,'; } - if ($usesHooks) { + if ($needsHooksParam) { yield 'private readonly array $hooks,'; } + + if ($needsLoadersParam) { + yield 'private readonly array $loaders,'; + } }); if ($isData) { yield ') {'; - yield $generator->indent(function () { + yield $generator->indent(function () use ($generator, $buildsLoaders, $batchedHooks) { yield '$this->errors = array_map(fn(array $error) => new Error($error), $errors);'; + + if ($buildsLoaders) { + yield ''; + yield '$this->loaders = ['; + yield $generator->indent(function () use ($generator, $batchedHooks) { + foreach (array_keys($batchedHooks) as $hookName) { + yield sprintf( + '%s => new %s(', + var_export($hookName, true), + $generator->import($this->fullyQualified('HookLoader')), + ); + yield $generator->indent(function () use ($hookName) { + yield sprintf('$this->%s(...),', $this->collectMethodName($hookName)); + yield sprintf('$this->hooks[%s]->__invoke(...),', var_export($hookName, true)); + }); + yield '),'; + } + }); + yield '];'; + } }); yield '}'; } else { yield ') {}'; } + + yield from $this->dumpCollectMethods($plan, $plansByFqcn, $generator); }, ); yield '}'; diff --git a/src/Generator/HookLoaderGenerator.php b/src/Generator/HookLoaderGenerator.php new file mode 100644 index 0000000..50b3209 --- /dev/null +++ b/src/Generator/HookLoaderGenerator.php @@ -0,0 +1,112 @@ +config->namespace); + + return $generator->dumpFile(function () use ($generator) { + yield $this->dumpHeader(); + yield ''; + + yield from $generator->docComment(function () { + yield 'Batches a `@hook` so the user-supplied hook runs exactly once per'; + yield 'operation. The first access of any hooked property triggers a single'; + yield 'walk of the typed object graph (via the generated collect* methods).'; + yield 'Inputs are de-duplicated by value, the hook is invoked once with the'; + yield 'distinct set, and results are distributed back by owning object'; + yield 'instance.'; + yield ''; + yield '@internal'; + yield '@template TInput'; + yield '@template TResult'; + }); + yield 'final class HookLoader'; + yield '{'; + yield $generator->indent(function () use ($generator) { + yield 'private bool $loaded = false;'; + yield ''; + yield from $generator->docComment('@var array'); + yield 'private array $results = [];'; + yield ''; + yield from $generator->docComment(sprintf('@var %s', $generator->import(WeakMap::class))); + yield sprintf('private %s $index;', $generator->import(WeakMap::class)); + yield ''; + yield from $generator->docComment(function () use ($generator) { + yield sprintf( + '@param %s(): iterable $collect', + $generator->import(Closure::class), + ); + yield sprintf( + '@param %s(array): iterable $hook', + $generator->import(Closure::class), + ); + }); + yield 'public function __construct('; + yield $generator->indent(function () use ($generator) { + yield sprintf('private readonly %s $collect,', $generator->import(Closure::class)); + yield sprintf('private readonly %s $hook,', $generator->import(Closure::class)); + }); + yield ') {'; + yield $generator->indent(sprintf('$this->index = new %s();', $generator->import(WeakMap::class))); + yield '}'; + yield ''; + yield from $generator->docComment('@return TResult'); + yield 'public function resolve(object $owner) : mixed'; + yield '{'; + yield $generator->indent(function () use ($generator) { + yield 'if ( ! $this->loaded) {'; + yield $generator->indent('$this->load();'); + yield '}'; + yield ''; + yield 'return $this->results[$this->index[$owner]];'; + }); + yield '}'; + yield ''; + yield 'private function load() : void'; + yield '{'; + yield $generator->indent(function () use ($generator) { + yield '$this->loaded = true;'; + yield ''; + yield '$inputs = [];'; + yield '$keys = [];'; + yield ''; + yield 'foreach (($this->collect)() as [$owner, $input]) {'; + yield $generator->indent(function () use ($generator) { + yield '$hash = serialize($input);'; + yield ''; + yield 'if ( ! isset($keys[$hash])) {'; + yield $generator->indent(function () { + yield '$keys[$hash] = count($inputs);'; + yield '$inputs[$keys[$hash]] = $input;'; + }); + yield '}'; + yield ''; + yield '$this->index[$owner] = $keys[$hash];'; + }); + yield '}'; + yield ''; + yield 'foreach (($this->hook)($inputs) as $key => $result) {'; + yield $generator->indent('$this->results[$key] = $result;'); + yield '}'; + }); + yield '}'; + }); + yield '}'; + }); + } +} diff --git a/src/Planner.php b/src/Planner.php index 8830a4b..d79f36c 100644 --- a/src/Planner.php +++ b/src/Planner.php @@ -72,6 +72,7 @@ use Ruudk\GraphQLCodeGenerator\Planner\Plan\EnumClassPlan; use Ruudk\GraphQLCodeGenerator\Planner\Plan\ErrorClassPlan; use Ruudk\GraphQLCodeGenerator\Planner\Plan\ExceptionClassPlan; +use Ruudk\GraphQLCodeGenerator\Planner\Plan\HookLoaderPlan; use Ruudk\GraphQLCodeGenerator\Planner\Plan\InputClassPlan; use Ruudk\GraphQLCodeGenerator\Planner\Plan\NodeNotFoundExceptionPlan; use Ruudk\GraphQLCodeGenerator\Planner\Plan\OperationClassPlan; @@ -675,6 +676,12 @@ public function plan() : PlannerResult $this->propagateUsedHooks($result); } + if ($this->anyClassUsesBatchedHook($result)) { + $result->addClass(new HookLoaderPlan( + path: $this->config->outputDir . '/HookLoader.php', + )); + } + if ($this->config->dumpOrThrowProperties || $this->anyClassUsesThrowWhenNull($result)) { $result->addClass(new NodeNotFoundExceptionPlan( path: $this->config->outputDir . '/NodeNotFoundException.php', @@ -773,6 +780,27 @@ private function findUnusedFragments(array $operations, array $ordered) : array return $unused; } + /** + * True when any generated class resolves a hook registered with + * `#[Hook(..., batched: true)]` — the trigger for emitting `HookLoader.php`. + */ + private function anyClassUsesBatchedHook(PlannerResult $result) : bool + { + foreach ($result->classes as $plan) { + if ( ! $plan instanceof DataClassPlan) { + continue; + } + + foreach (array_keys($plan->usedHooks) as $hookName) { + if (($this->config->hooks[$hookName] ?? null)?->batched === true) { + return true; + } + } + } + + return false; + } + private function anyClassUsesThrowWhenNull(PlannerResult $result) : bool { foreach ($result->classes as $plan) { diff --git a/src/Planner/Plan/HookLoaderPlan.php b/src/Planner/Plan/HookLoaderPlan.php new file mode 100644 index 0000000..3a6237d --- /dev/null +++ b/src/Planner/Plan/HookLoaderPlan.php @@ -0,0 +1,12 @@ +name, $hookDirective['input'], $hook->returnType, + $hook->batched, )); return; diff --git a/src/Type/ArrayTupleType.php b/src/Type/ArrayTupleType.php new file mode 100644 index 0000000..12ff6f4 --- /dev/null +++ b/src/Type/ArrayTupleType.php @@ -0,0 +1,27 @@ + $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 e5e5f4f..ba6578b 100644 --- a/src/Type/HookPropertyType.php +++ b/src/Type/HookPropertyType.php @@ -25,11 +25,14 @@ final class HookPropertyType extends SymfonyType implements WrappingTypeInterfac { /** * @param list $inputPaths + * @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, private readonly SymfonyType $returnType, + public readonly bool $batched = false, ) {} #[Override] diff --git a/src/Type/TypeDumper.php b/src/Type/TypeDumper.php index 9da5e0e..d8f4dfd 100644 --- a/src/Type/TypeDumper.php +++ b/src/Type/TypeDumper.php @@ -19,6 +19,13 @@ 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/TypeInitializer/ClassHookUsageRegistry.php b/src/TypeInitializer/ClassHookUsageRegistry.php index 50192ea..7b5c362 100644 --- a/src/TypeInitializer/ClassHookUsageRegistry.php +++ b/src/TypeInitializer/ClassHookUsageRegistry.php @@ -4,16 +4,27 @@ namespace Ruudk\GraphQLCodeGenerator\TypeInitializer; +use Ruudk\GraphQLCodeGenerator\Config\HookDefinition; + /** - * Knows which generated data classes accept a `$hooks` constructor argument - * and which hook names each one carries. + * Knows which generated data classes accept a `$hooks` and/or `$loaders` + * constructor argument and which hook names each one carries. * - * Consulted by `ObjectTypeInitializer` (to forward `$this->hooks` into child - * constructors) and `OperationClassGenerator` (to emit the root query's - * hook parameter and shape). Populated once by `PlanExecutor` after planning. + * Consulted by `ObjectTypeInitializer` (to forward `$this->hooks` / + * `$this->loaders` into child constructors) and `OperationClassGenerator` (to + * emit the root query's hook parameter and shape). Populated once by + * `PlanExecutor` after planning. */ final class ClassHookUsageRegistry { + /** + * @param array $hookDefinitions The registered hooks, + * used to tell legacy hooks from batched ones. + */ + public function __construct( + private readonly array $hookDefinitions = [], + ) {} + /** * Keys are generated class FQCNs (stored as plain strings because they come * from DataClassPlan::$fqcn, which is typed string). @@ -22,9 +33,34 @@ final class ClassHookUsageRegistry */ public array $classHooks = []; - public function usesHooks(string $fqcn) : bool + /** + * True when the class carries at least one legacy (non-batched) hook and + * therefore needs the `$hooks` constructor argument. + */ + public function usesLegacyHooks(string $fqcn) : bool { - return isset($this->classHooks[$fqcn]); + foreach (array_keys($this->getHooksForClass($fqcn)) as $name) { + if ( ! ($this->hookDefinitions[$name]->batched ?? false)) { + return true; + } + } + + return false; + } + + /** + * True when the class carries at least one batched hook and therefore needs + * the `$loaders` constructor argument. + */ + public function usesBatchedHooks(string $fqcn) : bool + { + foreach (array_keys($this->getHooksForClass($fqcn)) as $name) { + if ($this->hookDefinitions[$name]->batched ?? false) { + return true; + } + } + + return false; } /** diff --git a/src/TypeInitializer/ObjectTypeInitializer.php b/src/TypeInitializer/ObjectTypeInitializer.php index d282f57..090fcaa 100644 --- a/src/TypeInitializer/ObjectTypeInitializer.php +++ b/src/TypeInitializer/ObjectTypeInitializer.php @@ -35,10 +35,17 @@ public function initialize( string $variable, DelegatingTypeInitializer $delegator, ) : string { - $arguments = $this->hookUsageRegistry->usesHooks($type->getClassName()) - ? sprintf('%s, $this->hooks', $variable) - : $variable; + $className = $type->getClassName(); + $arguments = $variable; - return sprintf('new %s(%s)', $generator->import($type->getClassName()), $arguments); + if ($this->hookUsageRegistry->usesLegacyHooks($className)) { + $arguments .= ', $this->hooks'; + } + + if ($this->hookUsageRegistry->usesBatchedHooks($className)) { + $arguments .= ', $this->loaders'; + } + + return sprintf('new %s(%s)', $generator->import($className), $arguments); } } diff --git a/tests/HooksBatched/Access.php b/tests/HooksBatched/Access.php new file mode 100644 index 0000000..fbd9e9a --- /dev/null +++ b/tests/HooksBatched/Access.php @@ -0,0 +1,14 @@ +> + */ + public array $batches = []; + + /** + * @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); + } + } +} diff --git a/tests/HooksBatched/FindOrgPlanHook.php b/tests/HooksBatched/FindOrgPlanHook.php new file mode 100644 index 0000000..e32e9b7 --- /dev/null +++ b/tests/HooksBatched/FindOrgPlanHook.php @@ -0,0 +1,42 @@ +> + */ + public array $batches = []; + + /** + * @param array $plans + */ + public function __construct( + private readonly array $plans = [], + ) {} + + /** + * @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'); + } + } +} diff --git a/tests/HooksBatched/FindUserByIdHook.php b/tests/HooksBatched/FindUserByIdHook.php new file mode 100644 index 0000000..5d9d0a2 --- /dev/null +++ b/tests/HooksBatched/FindUserByIdHook.php @@ -0,0 +1,43 @@ +> + */ + public array $batches = []; + + /** + * @param array $users + */ + public function __construct( + private readonly array $users = [], + ) {} + + /** + * @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; + } + } +} diff --git a/tests/HooksBatched/Generated/HookLoader.php b/tests/HooksBatched/Generated/HookLoader.php new file mode 100644 index 0000000..b6f54d6 --- /dev/null +++ b/tests/HooksBatched/Generated/HookLoader.php @@ -0,0 +1,83 @@ + + */ + private array $results = []; + + /** + * @var WeakMap + */ + private WeakMap $index; + + /** + * @param Closure(): iterable $collect + * @param Closure(array): iterable $hook + */ + public function __construct( + private readonly Closure $collect, + private readonly Closure $hook, + ) { + $this->index = new WeakMap(); + } + + /** + * @return TResult + */ + public function resolve(object $owner) : mixed + { + if ( ! $this->loaded) { + $this->load(); + } + + return $this->results[$this->index[$owner]]; + } + + private function load() : void + { + $this->loaded = true; + + $inputs = []; + $keys = []; + + foreach (($this->collect)() as [$owner, $input]) { + $hash = serialize($input); + + if ( ! isset($keys[$hash])) { + $keys[$hash] = count($inputs); + $inputs[$keys[$hash]] = $input; + } + + $this->index[$owner] = $keys[$hash]; + } + + foreach (($this->hook)($inputs) as $key => $result) { + $this->results[$key] = $result; + } + } +} diff --git a/tests/HooksBatched/Generated/Query/Test/Data.php b/tests/HooksBatched/Generated/Query/Test/Data.php new file mode 100644 index 0000000..1d81991 --- /dev/null +++ b/tests/HooksBatched/Generated/Query/Test/Data.php @@ -0,0 +1,117 @@ + + */ + public array $organizations { + get => $this->organizations ??= array_map(fn($item) => new Organization($item, $this->loaders), $this->data['organizations']); + } + + /** + * @var list + */ + public readonly array $errors; + + /** + * @var array{findOrgPlan: HookLoader, computeAccess: HookLoader, findUserById: HookLoader} + */ + private readonly array $loaders; + + /** + * @param array{ + * 'organizations': list, + * }>, + * } $data + * @param list $errors + * @param array{ + * 'computeAccess': ComputeAccessHook, + * 'findOrgPlan': FindOrgPlanHook, + * 'findUserById': FindUserByIdHook, + * } $hooks + */ + public function __construct( + private readonly array $data, + array $errors, + private readonly array $hooks, + ) { + $this->errors = array_map(fn(array $error) => new Error($error), $errors); + + $this->loaders = [ + 'findOrgPlan' => new HookLoader( + $this->collectHookFindOrgPlanInputs(...), + $this->hooks['findOrgPlan']->__invoke(...), + ), + 'computeAccess' => new HookLoader( + $this->collectHookComputeAccessInputs(...), + $this->hooks['computeAccess']->__invoke(...), + ), + 'findUserById' => new HookLoader( + $this->collectHookFindUserByIdInputs(...), + $this->hooks['findUserById']->__invoke(...), + ), + ]; + } + + /** + * @return iterable + */ + private function collectHookFindOrgPlanInputs() : iterable + { + foreach ($this->organizations as $item) { + yield [$item, [$item->id]]; + } + } + + /** + * @return iterable + */ + private function collectHookComputeAccessInputs() : iterable + { + foreach ($this->organizations as $item) { + foreach ($item->repositories as $item1) { + yield [$item1, [$item1->ownerId, $item1->reviewerId]]; + } + } + } + + /** + * @return iterable + */ + private function collectHookFindUserByIdInputs() : iterable + { + foreach ($this->organizations as $item) { + foreach ($item->repositories as $item1) { + yield [$item1, [$item1->ownerId]]; + } + } + } +} diff --git a/tests/HooksBatched/Generated/Query/Test/Data/Organization.php b/tests/HooksBatched/Generated/Query/Test/Data/Organization.php new file mode 100644 index 0000000..f5062fb --- /dev/null +++ b/tests/HooksBatched/Generated/Query/Test/Data/Organization.php @@ -0,0 +1,53 @@ + $this->id ??= $this->data['id']; + } + + public string $name { + get => $this->name ??= $this->data['name']; + } + + public OrgPlan $plan { + get => $this->plan ??= $this->loaders['findOrgPlan']->resolve($this); + } + + /** + * @var list + */ + public array $repositories { + get => $this->repositories ??= array_map(fn($item) => new Repository($item, $this->loaders), $this->data['repositories']); + } + + /** + * @param array{ + * 'id': string, + * 'name': string, + * 'repositories': list, + * } $data + * @param array{findOrgPlan: HookLoader, computeAccess: HookLoader, findUserById: HookLoader} $loaders + */ + public function __construct( + private readonly array $data, + private readonly array $loaders, + ) {} +} diff --git a/tests/HooksBatched/Generated/Query/Test/Data/Organization/Repository.php b/tests/HooksBatched/Generated/Query/Test/Data/Organization/Repository.php new file mode 100644 index 0000000..a496c17 --- /dev/null +++ b/tests/HooksBatched/Generated/Query/Test/Data/Organization/Repository.php @@ -0,0 +1,52 @@ + $this->access ??= $this->loaders['computeAccess']->resolve($this); + } + + public string $id { + get => $this->id ??= $this->data['id']; + } + + public string $name { + get => $this->name ??= $this->data['name']; + } + + public ?User $owner { + get => $this->owner ??= $this->loaders['findUserById']->resolve($this); + } + + public string $ownerId { + get => $this->ownerId ??= $this->data['ownerId']; + } + + public string $reviewerId { + get => $this->reviewerId ??= $this->data['reviewerId']; + } + + /** + * @param array{ + * 'id': string, + * 'name': string, + * 'ownerId': string, + * 'reviewerId': string, + * } $data + * @param array{computeAccess: HookLoader, findUserById: HookLoader} $loaders + */ + public function __construct( + private readonly array $data, + private readonly array $loaders, + ) {} +} diff --git a/tests/HooksBatched/Generated/Query/Test/Error.php b/tests/HooksBatched/Generated/Query/Test/Error.php new file mode 100644 index 0000000..fa0b1d3 --- /dev/null +++ b/tests/HooksBatched/Generated/Query/Test/Error.php @@ -0,0 +1,23 @@ +message = $error['debugMessage'] ?? $error['message']; + } +} diff --git a/tests/HooksBatched/Generated/Query/Test/TestQuery.php b/tests/HooksBatched/Generated/Query/Test/TestQuery.php new file mode 100644 index 0000000..7eba3ee --- /dev/null +++ b/tests/HooksBatched/Generated/Query/Test/TestQuery.php @@ -0,0 +1,62 @@ +client->graphql( + self::OPERATION_DEFINITION, + [ + ], + self::OPERATION_NAME, + ); + + return new Data( + $data['data'] ?? [], // @phpstan-ignore argument.type + $data['errors'] ?? [], // @phpstan-ignore argument.type + $this->hooks, + ); + } +} diff --git a/tests/HooksBatched/HooksBatchedTest.php b/tests/HooksBatched/HooksBatchedTest.php new file mode 100644 index 0000000..3e98d38 --- /dev/null +++ b/tests/HooksBatched/HooksBatchedTest.php @@ -0,0 +1,237 @@ +withHook(FindUserByIdHook::class) + ->withHook(ComputeAccessHook::class) + ->withHook(FindOrgPlanHook::class); + } + + public function testGenerate() : void + { + $this->assertActualMatchesExpected(); + } + + public function testHooksAreBatchedAndInvokedOncePerOperation() : void + { + $findUserById = new FindUserByIdHook([ + 'user-1' => new User('user-1', 'Alice'), + 'user-2' => new User('user-2', 'Bob'), + ]); + $computeAccess = new ComputeAccessHook(); + $findOrgPlan = new FindOrgPlanHook([ + 'org-1' => new OrgPlan('org-1', 'enterprise'), + 'org-2' => new OrgPlan('org-2', 'team'), + 'org-3' => new OrgPlan('org-3', 'free'), + ]); + + $result = new TestQuery( + $this->getClient([ + 'data' => [ + 'organizations' => [ + $this->organization('org-1', 'Acme', [ + $this->repository('repo-1', 'web', 'user-1', 'user-2'), + $this->repository('repo-2', 'api', 'user-1', 'user-1'), + ]), + $this->organization('org-2', 'Globex', [ + $this->repository('repo-3', 'infra', 'user-2', 'user-1'), + $this->repository('repo-4', 'docs', 'user-1', 'user-2'), + ]), + $this->organization('org-3', 'Initech', [ + $this->repository('repo-5', 'mobile', 'user-2', 'user-2'), + $this->repository('repo-6', 'cli', 'user-1', 'user-1'), + ]), + ], + ], + ]), + [ + 'findUserById' => $findUserById, + 'computeAccess' => $computeAccess, + 'findOrgPlan' => $findOrgPlan, + ], + )->execute(); + + // Walk the whole result, touching every hooked property. + $ownerNames = []; + $accessFlags = []; + $planTiers = []; + + foreach ($result->organizations as $organization) { + $planTiers[] = $organization->plan->tier; + + foreach ($organization->repositories as $repository) { + $ownerNames[] = $repository->owner?->name; + $accessFlags[] = $repository->access->ownerIsReviewer; + } + } + + // The resolved values are correct. + self::assertSame(['enterprise', 'team', 'free'], $planTiers); + self::assertSame(['Alice', 'Alice', 'Bob', 'Alice', 'Bob', 'Alice'], $ownerNames); + self::assertSame([false, true, false, false, true, true], $accessFlags); + + // --- 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. + + // Org-level hook: one batch, all 3 organizations distinct. + self::assertSame( + [[['org-1'], ['org-2'], ['org-3']]], + $findOrgPlan->batches, + ); + + // 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. + self::assertSame( + [[['user-1'], ['user-2']]], + $findUserById->batches, + ); + + // Repository-level two-field hook: 6 repositories collapse to 4 + // distinct (ownerId, reviewerId) tuples. + self::assertSame( + [[ + ['user-1', 'user-2'], + ['user-1', 'user-1'], + ['user-2', 'user-1'], + ['user-2', 'user-2'], + ]], + $computeAccess->batches, + ); + } + + public function testHooksResolveLazilyAndDoNotRunBeforeAccess() : void + { + $findUserById = new FindUserByIdHook([ + 'user-1' => new User('user-1', 'Alice'), + ]); + $computeAccess = new ComputeAccessHook(); + $findOrgPlan = new FindOrgPlanHook(); + + new TestQuery( + $this->getClient([ + 'data' => [ + 'organizations' => [ + $this->organization('org-1', 'Acme', [ + $this->repository('repo-1', 'web', 'user-1', 'user-1'), + ]), + ], + ], + ]), + [ + 'findUserById' => $findUserById, + 'computeAccess' => $computeAccess, + 'findOrgPlan' => $findOrgPlan, + ], + )->execute(); + + // execute() builds the Data graph and the loaders, but no hooked + // property has been read yet, so no hook has run. + self::assertSame([], $findUserById->batches); + self::assertSame([], $computeAccess->batches); + self::assertSame([], $findOrgPlan->batches); + } + + public function testRepeatedAccessTriggersTheBatchOnlyOnce() : void + { + $findUserById = new FindUserByIdHook([ + 'user-1' => new User('user-1', 'Alice'), + ]); + $computeAccess = new ComputeAccessHook(); + $findOrgPlan = new FindOrgPlanHook(); + + $result = new TestQuery( + $this->getClient([ + 'data' => [ + 'organizations' => [ + $this->organization('org-1', 'Acme', [ + $this->repository('repo-1', 'web', 'user-1', 'user-1'), + $this->repository('repo-2', 'api', 'user-1', 'user-1'), + ]), + ], + ], + ]), + [ + 'findUserById' => $findUserById, + 'computeAccess' => $computeAccess, + 'findOrgPlan' => $findOrgPlan, + ], + )->execute(); + + [$first, $second] = $result->organizations[0]->repositories; + + // The first access of any hooked property triggers the one batch; every + // later access — same instance or a sibling — is served from the cache. + $owner = $first->owner; + self::assertNotNull($owner); + self::assertSame('Alice', $owner->name); + 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. + self::assertCount(1, $findUserById->batches); + self::assertCount(1, $findUserById->batches[0]); + self::assertSame($owner, $second->owner); + } + + /** + * @param list> $repositories + * @return array + */ + private function organization(string $id, string $name, array $repositories) : array + { + return [ + 'id' => $id, + 'name' => $name, + 'repositories' => $repositories, + ]; + } + + /** + * @return array + */ + private function repository(string $id, string $name, string $ownerId, string $reviewerId) : array + { + return [ + 'id' => $id, + 'name' => $name, + 'ownerId' => $ownerId, + 'reviewerId' => $reviewerId, + ]; + } +} diff --git a/tests/HooksBatched/OrgPlan.php b/tests/HooksBatched/OrgPlan.php new file mode 100644 index 0000000..04eb76e --- /dev/null +++ b/tests/HooksBatched/OrgPlan.php @@ -0,0 +1,13 @@ +