diff --git a/src/Annotations/Field.php b/src/Annotations/Field.php index b64ed5d3a0..8e4112c535 100644 --- a/src/Annotations/Field.php +++ b/src/Annotations/Field.php @@ -6,6 +6,10 @@ use Attribute; +use function trigger_error; + +use const E_USER_DEPRECATED; + /** * @Annotation * @Target({"PROPERTY", "METHOD"}) @@ -44,6 +48,7 @@ class Field extends AbstractRequest public function __construct(array $attributes = [], string|null $name = null, string|null $outputType = null, string|null $prefetchMethod = null, string|array|null $for = null, string|null $description = null, string|null $inputType = null) { parent::__construct($attributes, $name, $outputType); + $this->prefetchMethod = $prefetchMethod ?? $attributes['prefetchMethod'] ?? null; $this->description = $description ?? $attributes['description'] ?? null; $this->inputType = $inputType ?? $attributes['inputType'] ?? null; @@ -54,6 +59,16 @@ public function __construct(array $attributes = [], string|null $name = null, st } $this->for = (array) $forValue; + + if (! $this->prefetchMethod) { + return; + } + + trigger_error( + "Using #[Field(prefetchMethod='" . $this->prefetchMethod . "')] on fields is deprecated in favor " . + "of #[Prefetch('" . $this->prefetchMethod . "')] \$data attribute on the parameter itself.", + E_USER_DEPRECATED, + ); } /** diff --git a/src/Annotations/Prefetch.php b/src/Annotations/Prefetch.php new file mode 100644 index 0000000000..f4fa3bbeb7 --- /dev/null +++ b/src/Annotations/Prefetch.php @@ -0,0 +1,23 @@ + Returns an array of parameters. */ - public function getParameters(ReflectionMethod $refMethod): array + public function getParameters(ReflectionMethod $refMethod, int $skip = 0): array { $docBlockObj = $this->cachedDocBlockFactory->getDocBlock($refMethod); //$docBlockComment = $docBlockObj->getSummary()."\n".$docBlockObj->getDescription()->render(); - $parameters = $refMethod->getParameters(); + $parameters = array_slice($refMethod->getParameters(), $skip); return $this->mapParameters($parameters, $docBlockObj); } @@ -245,19 +248,8 @@ public function getParameters(ReflectionMethod $refMethod): array */ public function getParametersForDecorator(ReflectionMethod $refMethod): array { - $docBlockObj = $this->cachedDocBlockFactory->getDocBlock($refMethod); - //$docBlockComment = $docBlockObj->getSummary()."\n".$docBlockObj->getDescription()->render(); - - $parameters = $refMethod->getParameters(); - - if (empty($parameters)) { - return []; - } - - // Let's remove the first parameter. - array_shift($parameters); - - return $this->mapParameters($parameters, $docBlockObj); + // First parameter of a decorator is always $source so we're skipping that. + return $this->getParameters($refMethod, 1); } /** @@ -346,7 +338,7 @@ private function getFieldsByMethodAnnotations(string|object $controller, Reflect continue; } $for = $queryAnnotation->getFor(); - if ($typeName && $for && ! in_array($typeName, $for)) { + if ($typeName && $for && !in_array($typeName, $for)) { continue; } @@ -357,7 +349,7 @@ private function getFieldsByMethodAnnotations(string|object $controller, Reflect $name = $queryAnnotation->getName() ?: $this->namingStrategy->getFieldNameFromMethodName($methodName); - if (! $description) { + if (!$description) { $description = $docBlockObj->getSummary() . "\n" . $docBlockObj->getDescription()->render(); } @@ -381,27 +373,26 @@ private function getFieldsByMethodAnnotations(string|object $controller, Reflect refMethod: $refMethod, ); - [$prefetchMethodName, $prefetchArgs, $prefetchRefMethod] = $this->getPrefetchMethodInfo($refClass, $refMethod, $queryAnnotation); - if ($prefetchMethodName) { - $fieldDescriptor = $fieldDescriptor - ->withPrefetchMethodName($prefetchMethodName) - ->withPrefetchParameters($prefetchArgs); - } - $parameters = $refMethod->getParameters(); if ($injectSource === true) { $firstParameter = array_shift($parameters); // TODO: check that $first_parameter type is correct. } - if ($prefetchMethodName !== null && $prefetchRefMethod !== null) { - $secondParameter = array_shift($parameters); - if ($secondParameter === null) { - throw InvalidPrefetchMethodRuntimeException::prefetchDataIgnored($prefetchRefMethod, $injectSource); - } + + // TODO: remove once support for deprecated prefetchMethod on Field is removed. + $prefetchDataParameter = $this->getPrefetchParameter($name, $refClass, $refMethod, $queryAnnotation); + + if ($prefetchDataParameter) { + array_shift($parameters); } $args = $this->mapParameters($parameters, $docBlockObj); + // TODO: remove once support for deprecated prefetchMethod on Field is removed. + if ($prefetchDataParameter) { + $args = ['__graphqlite_prefectData' => $prefetchDataParameter, ...$args]; + } + $fieldDescriptor = $fieldDescriptor->withParameters($args); if (is_string($controller)) { @@ -454,7 +445,7 @@ private function getFieldsByPropertyAnnotations(string|object $controller, Refle if ($queryAnnotation instanceof Field) { $for = $queryAnnotation->getFor(); - if ($typeName && $for && ! in_array($typeName, $for)) { + if ($typeName && $for && !in_array($typeName, $for)) { continue; } @@ -465,7 +456,7 @@ private function getFieldsByPropertyAnnotations(string|object $controller, Refle $name = $queryAnnotation->getName() ?: $refProperty->getName(); - if (! $description) { + if (!$description) { $description = $docBlock->getSummary() . PHP_EOL . $docBlock->getDescription()->render(); /** @var Var_[] $varTags */ @@ -492,13 +483,6 @@ private function getFieldsByPropertyAnnotations(string|object $controller, Refle refProperty: $refProperty, ); - [$prefetchMethodName, $prefetchArgs] = $this->getPrefetchMethodInfo($refClass, $refProperty, $queryAnnotation); - if ($prefetchMethodName) { - $fieldDescriptor = $fieldDescriptor - ->withPrefetchMethodName($prefetchMethodName) - ->withPrefetchParameters($prefetchArgs); - } - if (is_string($controller)) { $fieldDescriptor = $fieldDescriptor->withTargetPropertyOnSource($refProperty->getDeclaringClass()->getName(), $refProperty->getName()); } else { @@ -559,7 +543,7 @@ private function getQueryFieldsFromSourceFields(array $sourceFields, ReflectionC $typeName = $extendTypeField->getName(); assert($typeName !== null); $targetedType = $this->recursiveTypeMapper->mapNameToType($typeName); - if (! $targetedType instanceof MutableObjectType) { + if (!$targetedType instanceof MutableObjectType) { throw CannotMapTypeException::extendTypeWithBadTargetedClass($refClass->getName(), $extendTypeField); } $objectClass = $targetedType->getMappedClassName(); @@ -578,7 +562,7 @@ private function getQueryFieldsFromSourceFields(array $sourceFields, ReflectionC $queryList = []; foreach ($sourceFields as $sourceField) { - if (! $sourceField->shouldFetchFromMagicProperty()) { + if (!$sourceField->shouldFetchFromMagicProperty()) { try { $refMethod = $this->getMethodFromPropertyName($objectRefClass, $sourceField->getSourceName() ?? $sourceField->getName()); } catch (FieldNotFoundException $e) { @@ -592,7 +576,7 @@ private function getQueryFieldsFromSourceFields(array $sourceFields, ReflectionC $deprecated = $docBlockObj->getTagsByName('deprecated'); if (count($deprecated) >= 1) { - $deprecationReason = trim((string) $deprecated[0]); + $deprecationReason = trim((string)$deprecated[0]); } $description = $sourceField->getDescription() ?? $docBlockComment; @@ -679,7 +663,7 @@ private function getMagicGetMethodFromSourceClassOrProxy(ReflectionClass $proxyR $sourceClassName = $typeField->getClass(); $sourceRefClass = new ReflectionClass($sourceClassName); - if (! $sourceRefClass->hasMethod($magicGet)) { + if (!$sourceRefClass->hasMethod($magicGet)) { throw MissingMagicGetException::cannotFindMagicGet($sourceClassName); } @@ -723,7 +707,7 @@ private function getMethodFromPropertyName(ReflectionClass $reflectionClass, str $methodName = $propertyName; } else { $methodName = PropertyAccessor::findGetter($reflectionClass->getName(), $propertyName); - if (! $methodName) { + if (!$methodName) { throw FieldNotFoundException::missingField($reflectionClass->getName(), $propertyName); } } @@ -755,7 +739,7 @@ private function mapParameters(array $refParameters, DocBlock $docBlock, SourceF foreach ($refParameters as $parameter) { $parameterAnnotations = $parameterAnnotationsPerParameter[$parameter->getName()] ?? new ParameterAnnotations([]); //$parameterAnnotations = $this->annotationReader->getParameterAnnotations($parameter); - if (! empty($additionalParameterAnnotations[$parameter->getName()])) { + if (!empty($additionalParameterAnnotations[$parameter->getName()])) { $parameterAnnotations->merge($additionalParameterAnnotations[$parameter->getName()]); unset($additionalParameterAnnotations[$parameter->getName()]); } @@ -766,7 +750,7 @@ private function mapParameters(array $refParameters, DocBlock $docBlock, SourceF } // Sanity check, are the parameters declared in $additionalParameterAnnotations available in $refParameters? - if (! empty($additionalParameterAnnotations)) { + if (!empty($additionalParameterAnnotations)) { $refParameter = reset($refParameters); foreach ($additionalParameterAnnotations as $parameterName => $parameterAnnotations) { foreach ($parameterAnnotations->getAllAnnotations() as $annotation) { @@ -787,7 +771,7 @@ private function getDeprecationReason(DocBlock $docBlockObj): string|null { $deprecated = $docBlockObj->getTagsByName('deprecated'); if (count($deprecated) >= 1) { - return trim((string) $deprecated[0]); + return trim((string)$deprecated[0]); } return null; @@ -796,16 +780,17 @@ private function getDeprecationReason(DocBlock $docBlockObj): string|null /** * Extracts prefetch method info from annotation. * - * @return array{0: string|null, 1: array, 2: ReflectionMethod|null} + * TODO: remove once support for deprecated prefetchMethod on Field is removed. * * @throws InvalidArgumentException */ - private function getPrefetchMethodInfo(ReflectionClass $refClass, ReflectionMethod|ReflectionProperty $reflector, object $annotation): array + private function getPrefetchParameter( + string $fieldName, + ReflectionClass $refClass, + ReflectionMethod|ReflectionProperty $reflector, + object $annotation, + ): PrefetchDataParameter|null { - $prefetchMethodName = null; - $prefetchArgs = []; - $prefetchRefMethod = null; - if ($annotation instanceof Field) { $prefetchMethodName = $annotation->getPrefetchMethod(); if ($prefetchMethodName !== null) { @@ -820,10 +805,20 @@ private function getPrefetchMethodInfo(ReflectionClass $refClass, ReflectionMeth $prefetchDocBlockObj = $this->cachedDocBlockFactory->getDocBlock($prefetchRefMethod); $prefetchArgs = $this->mapParameters($prefetchParameters, $prefetchDocBlockObj); + + return new PrefetchDataParameter( + fieldName: $fieldName, + resolver: static function (array $sources, ...$args) use ($prefetchMethodName) { + $source = $sources[0]; + + return $source->{$prefetchMethodName}($sources, ...$args); + }, + parameters: $prefetchArgs, + ); } } - return [$prefetchMethodName, $prefetchArgs, $prefetchRefMethod]; + return null; } /** @@ -843,23 +838,23 @@ private function getInputFieldsByMethodAnnotations(string|object $controller, Re $annotations = $this->annotationReader->getMethodAnnotations($refMethod, $annotationName); foreach ($annotations as $fieldAnnotations) { $description = null; - if (! ($fieldAnnotations instanceof Field)) { + if (!($fieldAnnotations instanceof Field)) { continue; } $for = $fieldAnnotations->getFor(); - if ($typeName && $for && ! in_array($typeName, $for)) { + if ($typeName && $for && !in_array($typeName, $for)) { continue; } $description = $fieldAnnotations->getDescription(); $docBlockObj = $this->cachedDocBlockFactory->getDocBlock($refMethod); $methodName = $refMethod->getName(); - if (! str_starts_with($methodName, 'set')) { + if (!str_starts_with($methodName, 'set')) { continue; } $name = $fieldAnnotations->getName() ?: $this->namingStrategy->getInputFieldNameFromMethodName($methodName); - if (! $description) { + if (!$description) { $description = $docBlockObj->getSummary() . "\n" . $docBlockObj->getDescription()->render(); } @@ -902,7 +897,7 @@ private function getInputFieldsByMethodAnnotations(string|object $controller, Re ->withHasDefaultValue($isUpdate) ->withDefaultValue($args[$name]->getDefaultValue()); $constructerParameters = $this->getClassConstructParameterNames($refClass); - if (! in_array($name, $constructerParameters)) { + if (!in_array($name, $constructerParameters)) { $inputFieldDescriptor = $inputFieldDescriptor->withTargetMethodOnSource($refMethod->getDeclaringClass()->getName(), $methodName); } @@ -946,12 +941,12 @@ private function getInputFieldsByPropertyAnnotations(string|object $controller, foreach ($annotations as $annotation) { $description = null; - if (! ($annotation instanceof Field)) { + if (!($annotation instanceof Field)) { continue; } $for = $annotation->getFor(); - if ($typeName && $for && ! in_array($typeName, $for)) { + if ($typeName && $for && !in_array($typeName, $for)) { continue; } @@ -961,7 +956,7 @@ private function getInputFieldsByPropertyAnnotations(string|object $controller, $constructerParameters = $this->getClassConstructParameterNames($refClass); $inputProperty = $this->typeMapper->mapInputProperty($refProperty, $docBlock, $name, $inputType, $defaultProperties[$refProperty->getName()] ?? null, $isUpdate ? true : null); - if (! $description) { + if (!$description) { $description = $inputProperty->getDescription(); } @@ -984,7 +979,7 @@ private function getInputFieldsByPropertyAnnotations(string|object $controller, ); } else { $type = $inputProperty->getType(); - if (! $inputType && $isUpdate && $type instanceof NonNull) { + if (!$inputType && $isUpdate && $type instanceof NonNull) { $type = $type->getWrappedType(); } assert($type instanceof InputType); @@ -1028,7 +1023,7 @@ private function getClassConstructParameterNames(ReflectionClass $refClass): arr { $constructor = $refClass->getConstructor(); - if (! $constructor) { + if (!$constructor) { return []; } diff --git a/src/InputTypeUtils.php b/src/InputTypeUtils.php index 3c38de38aa..ec155b2481 100644 --- a/src/InputTypeUtils.php +++ b/src/InputTypeUtils.php @@ -17,10 +17,10 @@ use ReflectionMethod; use ReflectionNamedType; use RuntimeException; +use TheCodingMachine\GraphQLite\Parameters\ExpandsInputTypeParameters; use TheCodingMachine\GraphQLite\Parameters\InputTypeParameterInterface; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; -use function array_filter; use function array_map; use function assert; use function ltrim; @@ -99,6 +99,33 @@ private function resolveSelf(Type $type, ReflectionClass $reflectionClass): Type return $type; } + /** + * @param array $parameters + * + * @return array + */ + public static function toInputParameters(array $parameters): array + { + $result = []; + + foreach ($parameters as $name => $parameter) { + if ($parameter instanceof InputTypeParameterInterface) { + $result[$name] = $parameter; + } + + if (! ($parameter instanceof ExpandsInputTypeParameters)) { + continue; + } + + $result = [ + ...$result, + ...$parameter->toInputTypeParameters(), + ]; + } + + return $result; + } + /** * Maps an array of ParameterInterface to an array of field descriptors as accepted by Webonyx. * @@ -108,9 +135,7 @@ private function resolveSelf(Type $type, ReflectionClass $reflectionClass): Type */ public static function getInputTypeArgs(array $args): array { - $inputTypeArgs = array_filter($args, static function (ParameterInterface $parameter) { - return $parameter instanceof InputTypeParameterInterface; - }); + $inputTypeArgs = self::toInputParameters($args); return array_map(static function (InputTypeParameterInterface $parameter): array { $desc = [ diff --git a/src/InvalidCallableRuntimeException.php b/src/InvalidCallableRuntimeException.php new file mode 100644 index 0000000000..f35006c8c5 --- /dev/null +++ b/src/InvalidCallableRuntimeException.php @@ -0,0 +1,15 @@ +getDeclaringClass()->getName() . '::' . $reflector->getName() . ' specifies a "prefetch method" that could not be found. Unable to find method ' . $reflectionClass->getName() . '::' . $methodName . '.', 0, $previous); } - public static function prefetchDataIgnored(ReflectionMethod $annotationMethod, bool $isSecond): self + public static function fromInvalidCallable( + ReflectionMethod $reflector, + string $parameterName, + InvalidCallableRuntimeException $e, + ): self { - throw new self('The @Field annotation in ' . $annotationMethod->getDeclaringClass()->getName() . '::' . $annotationMethod->getName() . ' specifies a "prefetch method" but the data from the prefetch method is not gathered. The "' . $annotationMethod->getName() . '" method should accept a ' . ($isSecond ? 'second' : 'first') . ' parameter that will contain data returned by the prefetch method.'); + return new self( + '#[Prefetch] attribute on parameter $' . $parameterName . ' in ' . $reflector->getDeclaringClass()->getName() . '::' . $reflector->getName() . + ' specifies a callable that is invalid: ' . $e->getMessage(), + previous: $e, + ); } } diff --git a/src/Mappers/Parameters/PrefetchParameterMiddleware.php b/src/Mappers/Parameters/PrefetchParameterMiddleware.php new file mode 100644 index 0000000000..bb5fef4f70 --- /dev/null +++ b/src/Mappers/Parameters/PrefetchParameterMiddleware.php @@ -0,0 +1,57 @@ +getAnnotationByType(Prefetch::class); + + if ($prefetch === null) { + return $next->mapParameter($parameter, $docBlock, $paramTagType, $parameterAnnotations); + } + + $method = $parameter->getDeclaringFunction(); + + assert($method instanceof ReflectionMethod); + + // Map callable specified by #[Prefetch] into a real callable and parse all of the GraphQL parameters. + try { + [$resolver, $parameters] = $this->parameterizedCallableResolver->resolve($prefetch->callable, $method->getDeclaringClass(), 1); + } catch (InvalidCallableRuntimeException $e) { + throw InvalidPrefetchMethodRuntimeException::fromInvalidCallable($method, $parameter->getName(), $e); + } + + return new PrefetchDataParameter( + fieldName: $method->getName(), + resolver: $resolver, + parameters: $parameters, + ); + } +} diff --git a/src/ParameterizedCallableResolver.php b/src/ParameterizedCallableResolver.php new file mode 100644 index 0000000000..a1abc33d69 --- /dev/null +++ b/src/ParameterizedCallableResolver.php @@ -0,0 +1,60 @@ +} + */ + public function resolve(string|array $callable, string|ReflectionClass $classContext, int $skip = 0): array + { + if ($classContext instanceof ReflectionClass) { + $classContext = $classContext->getName(); + } + + // If string method is given, it's equivalent to [self::class, 'method'] + if (is_string($callable)) { + $callable = [$classContext, $callable]; + } + + try { + $refMethod = new ReflectionMethod($callable[0], $callable[1]); + } catch (ReflectionException $e) { + throw InvalidCallableRuntimeException::methodNotFound($callable[0], $callable[1], $e); + } + + // If method isn't static, then we should try to resolve the class name through the container. + if (! $refMethod->isStatic()) { + $callable = fn (...$args) => $this->container->get($callable[0])->{$callable[1]}(...$args); + } + + assert(is_callable($callable)); + + // Map all parameters of the callable. + $parameters = $this->fieldsBuilder->getParameters($refMethod, $skip); + + return [$callable, $parameters]; + } +} diff --git a/src/Parameters/ExpandsInputTypeParameters.php b/src/Parameters/ExpandsInputTypeParameters.php new file mode 100644 index 0000000000..39c3b0e34c --- /dev/null +++ b/src/Parameters/ExpandsInputTypeParameters.php @@ -0,0 +1,11 @@ + */ + public function toInputTypeParameters(): array; +} diff --git a/src/Parameters/PrefetchDataParameter.php b/src/Parameters/PrefetchDataParameter.php index 76036fb0ab..fe81e4aceb 100644 --- a/src/Parameters/PrefetchDataParameter.php +++ b/src/Parameters/PrefetchDataParameter.php @@ -4,69 +4,83 @@ namespace TheCodingMachine\GraphQLite\Parameters; +use GraphQL\Deferred; use GraphQL\Type\Definition\ResolveInfo; use TheCodingMachine\GraphQLite\Context\ContextInterface; use TheCodingMachine\GraphQLite\GraphQLRuntimeException; -use TheCodingMachine\GraphQLite\Middlewares\ResolverInterface; +use TheCodingMachine\GraphQLite\InputTypeUtils; use TheCodingMachine\GraphQLite\PrefetchBuffer; use TheCodingMachine\GraphQLite\QueryField; -use function array_unshift; use function assert; -use function is_callable; /** * Typically the first parameter of "self" fields or the second parameter of "external" fields that will be filled with the data fetched from the prefetch method. */ -class PrefetchDataParameter implements ParameterInterface +class PrefetchDataParameter implements ParameterInterface, ExpandsInputTypeParameters { - /** @param array $parameters Indexed by argument name. */ + /** + * @param callable $resolver + * @param array $parameters Indexed by argument name. + */ public function __construct( private readonly string $fieldName, - private readonly ResolverInterface $originalResolver, - private readonly string $methodName, - private readonly array $parameters, + private readonly mixed $resolver, + public readonly array $parameters, ) { } /** @param array $args */ - public function resolve(object|null $source, array $args, mixed $context, ResolveInfo $info): mixed + public function resolve(object|null $source, array $args, mixed $context, ResolveInfo $info): Deferred { + assert($source !== null); + // The PrefetchBuffer must be tied to the current request execution. The only object we have for this is $context // $context MUST be a ContextInterface if (! $context instanceof ContextInterface) { - throw new GraphQLRuntimeException('When using "prefetch", you sure ensure that the GraphQL execution "context" (passed to the GraphQL::executeQuery method) is an instance of \TheCodingMachine\GraphQLite\Context\Context'); + throw new GraphQLRuntimeException('When using "prefetch", you should ensure that the GraphQL execution "context" (passed to the GraphQL::executeQuery method) is an instance of \TheCodingMachine\GraphQLite\Context\Context'); } $prefetchBuffer = $context->getPrefetchBuffer($this); + $prefetchBuffer->register($source, $args); - if (! $prefetchBuffer->hasResult($args)) { - $prefetchResult = $this->computePrefetch($source, $args, $context, $info, $prefetchBuffer); + // The way this works is simple: GraphQL first iterates over every requested field and calls ->resolve() + // on it. That, in turn, calls this method. GraphQL doesn't need the actual value just yet; it simply + // calls ->resolve to let developers do complex value fetching. + // + // So we record all of these ->resolve() calls, collect them together and when a value is actually + // needed, GraphQL calls the callback of Deferred below. That's when we call the prefetch method, + // already knowing all the requested fields (source-arguments combinations). + return new Deferred(function () use ($info, $context, $args, $prefetchBuffer) { + if (! $prefetchBuffer->hasResult($args)) { + $prefetchResult = $this->computePrefetch($args, $context, $info, $prefetchBuffer); - $prefetchBuffer->storeResult($prefetchResult, $args); - } + $prefetchBuffer->storeResult($prefetchResult, $args); + } - return $prefetchResult ?? $prefetchBuffer->getResult($args); + return $prefetchResult ?? $prefetchBuffer->getResult($args); + }); } /** @param array $args */ - private function computePrefetch(object|null $source, array $args, mixed $context, ResolveInfo $info, PrefetchBuffer $prefetchBuffer): mixed + private function computePrefetch(array $args, mixed $context, ResolveInfo $info, PrefetchBuffer $prefetchBuffer): mixed { - // TODO: originalPrefetchResolver and prefetchResolver needed!!! - $prefetchCallable = [ - $this->originalResolver->executionSource($source), - $this->methodName, - ]; - $sources = $prefetchBuffer->getObjectsByArguments($args); + $toPassPrefetchArgs = QueryField::paramsToArguments($this->fieldName, $this->parameters, null, $args, $context, $info, $this->resolver); - assert(is_callable($prefetchCallable)); - $toPassPrefetchArgs = QueryField::paramsToArguments($this->fieldName, $this->parameters, $source, $args, $context, $info, $prefetchCallable); - - array_unshift($toPassPrefetchArgs, $sources); - assert(is_callable($prefetchCallable)); + return ($this->resolver)($sources, ...$toPassPrefetchArgs); + } - return $prefetchCallable(...$toPassPrefetchArgs); + /** @inheritDoc */ + public function toInputTypeParameters(): array + { + // Given these signatures: + // function name(#[Prefetch('prefetch1') $data1, string $arg2, #[Prefetch('prefetch2') $data2) + // function prefetch1(iterable $sources, int $arg1) + // function prefetch2(iterable $sources, int $arg3) + // Then field `name` in GraphQL scheme should look like so: name(arg1: Int!, arg2: String!, arg3: Int!) + // That's exactly what we're doing here - adding `arg1` and `arg3` from prefetch methods as input params + return InputTypeUtils::toInputParameters($this->parameters); } } diff --git a/src/QueryField.php b/src/QueryField.php index 69c8f569fb..b95a9152f4 100644 --- a/src/QueryField.php +++ b/src/QueryField.php @@ -6,6 +6,9 @@ use GraphQL\Deferred; use GraphQL\Error\ClientAware; +use GraphQL\Executor\Promise\Adapter\SyncPromise; +use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter; +use GraphQL\Executor\Promise\Promise; use GraphQL\Language\AST\FieldDefinitionNode; use GraphQL\Type\Definition\FieldDefinition; use GraphQL\Type\Definition\ListOfType; @@ -13,15 +16,14 @@ use GraphQL\Type\Definition\OutputType; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; -use TheCodingMachine\GraphQLite\Context\ContextInterface; use TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException; use TheCodingMachine\GraphQLite\Middlewares\ResolverInterface; use TheCodingMachine\GraphQLite\Parameters\MissingArgumentException; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; -use TheCodingMachine\GraphQLite\Parameters\PrefetchDataParameter; use TheCodingMachine\GraphQLite\Parameters\SourceParameter; -use function assert; +use function array_filter; +use function array_map; /** * A GraphQL field that maps to a PHP method automatically. @@ -39,7 +41,6 @@ final class QueryField extends FieldDefinition * @param array $arguments Indexed by argument name. * @param ResolverInterface $originalResolver A pointer to the resolver being called (but not wrapped by any field middleware) * @param callable $resolver The resolver actually called - * @param array $prefetchArgs Indexed by argument name. * @param array{resolve?: FieldResolver|null,args?: ArgumentListConfig|null,description?: string|null,deprecationReason?: string|null,astNode?: FieldDefinitionNode|null,complexity?: ComplexityFn|null} $additionalConfig */ public function __construct( @@ -50,15 +51,13 @@ public function __construct( callable $resolver, string|null $comment, string|null $deprecationReason, - string|null $prefetchMethodName, - array $prefetchArgs, array $additionalConfig = [], ) { $config = [ 'name' => $name, 'type' => $type, - 'args' => InputTypeUtils::getInputTypeArgs($prefetchArgs + $arguments), + 'args' => InputTypeUtils::getInputTypeArgs($arguments), ]; if ($comment) { $config['description'] = $comment; @@ -67,7 +66,7 @@ public function __construct( $config['deprecationReason'] = $deprecationReason; } - $resolveFn = function (object|null $source, array $args, $context, ResolveInfo $info) use ($name, $arguments, $originalResolver, $resolver) { + $config['resolve'] = function (object|null $source, array $args, $context, ResolveInfo $info) use ($name, $arguments, $originalResolver, $resolver) { /*if ($resolve !== null) { $method = $resolve; } elseif ($targetMethodOnSource !== null) { @@ -77,47 +76,39 @@ public function __construct( }*/ $toPassArgs = self::paramsToArguments($name, $arguments, $source, $args, $context, $info, $resolver); - $result = $resolver($source, ...$toPassArgs); + $callResolver = function (...$args) use ($originalResolver, $source, $resolver) { + $result = $resolver($source, ...$args); - try { - $this->assertReturnType($result); - } catch (TypeMismatchRuntimeException $e) { - $e->addInfo($this->name, $originalResolver->toString()); - - throw $e; - } + try { + $this->assertReturnType($result); + } catch (TypeMismatchRuntimeException $e) { + $e->addInfo($this->name, $originalResolver->toString()); - return $result; - }; - - if ($prefetchMethodName === null) { - $config['resolve'] = $resolveFn; - } else { - $config['resolve'] = static function ($source, array $args, $context, ResolveInfo $info) use ($arguments, $resolveFn) { - // The PrefetchBuffer must be tied to the current request execution. The only object we have for this is $context - // $context MUST be a ContextInterface - if (! $context instanceof ContextInterface) { - throw new GraphQLRuntimeException('When using "prefetch", you sure ensure that the GraphQL execution "context" (passed to the GraphQL::executeQuery method) is an instance of \TheCodingMachine\GraphQLite\Context\Context'); + throw $e; } - // TODO: this is to be refactored in a prefetch refactor PR that follows. For now this hack will do. - foreach ($arguments as $argument) { - if ($argument instanceof PrefetchDataParameter) { - $prefetchArgument = $argument; + return $result; + }; - break; - } - } + $deferred = (bool) array_filter($toPassArgs, static fn (mixed $value) => $value instanceof SyncPromise); - assert(($prefetchArgument ?? null) !== null); + // GraphQL allows deferring resolving the field's value using promises, i.e. they call the resolve + // function ahead of time for all of the fields (allowing us to gather all calls and do something + // in batch, like prefetch) and then resolve the promises as needed. To support that for prefetch, + // we're checking if any of the resolved parameters returned a promise. If they did, we know + // that the value should also be resolved using a promise, so we're wrapping it in one. + return $deferred ? new Deferred(static function () use ($toPassArgs, $callResolver) { + $syncPromiseAdapter = new SyncPromiseAdapter(); - $context->getPrefetchBuffer($prefetchArgument)->register($source, $args); + // Wait for every deferred parameter. + $toPassArgs = array_map( + static fn (mixed $value) => $value instanceof SyncPromise ? $syncPromiseAdapter->wait(new Promise($value, $syncPromiseAdapter)) : $value, + $toPassArgs, + ); - return new Deferred(static function () use ($source, $args, $context, $info, $resolveFn) { - return $resolveFn($source, $args, $context, $info); - }); - }; - } + return $callResolver(...$toPassArgs); + }) : $callResolver(...$toPassArgs); + }; $config += $additionalConfig; @@ -174,24 +165,12 @@ private static function fromDescriptor(QueryFieldDescriptor $fieldDescriptor): s $fieldDescriptor->getResolver(), $fieldDescriptor->getComment(), $fieldDescriptor->getDeprecationReason(), - $fieldDescriptor->getPrefetchMethodName(), - $fieldDescriptor->getPrefetchParameters(), ); } public static function fromFieldDescriptor(QueryFieldDescriptor $fieldDescriptor): self { $arguments = $fieldDescriptor->getParameters(); - if ($fieldDescriptor->getPrefetchMethodName() !== null) { - $arguments = [ - '__graphqlite_prefectData' => new PrefetchDataParameter( - fieldName: $fieldDescriptor->getName(), - originalResolver: $fieldDescriptor->getOriginalResolver(), - methodName: $fieldDescriptor->getPrefetchMethodName(), - parameters: $fieldDescriptor->getPrefetchParameters(), - ), - ] + $arguments; - } if ($fieldDescriptor->isInjectSource() === true) { $arguments = ['__graphqlite_source' => new SourceParameter()] + $arguments; } diff --git a/src/QueryFieldDescriptor.php b/src/QueryFieldDescriptor.php index 7698651cba..5efa1de983 100644 --- a/src/QueryFieldDescriptor.php +++ b/src/QueryFieldDescriptor.php @@ -35,7 +35,6 @@ class QueryFieldDescriptor /** * @param array $parameters - * @param array $prefetchParameters * @param callable $callable * @param bool $injectSource Whether we should inject the source as the first parameter or not. */ @@ -43,8 +42,6 @@ public function __construct( private readonly string $name, private readonly OutputType&Type $type, private readonly array $parameters = [], - private readonly array $prefetchParameters = [], - private readonly string|null $prefetchMethodName = null, private readonly mixed $callable = null, private readonly string|null $targetClass = null, private readonly string|null $targetMethodOnSource = null, @@ -92,28 +89,6 @@ public function withParameters(array $parameters): self return $this->with(parameters: $parameters); } - /** @return array */ - public function getPrefetchParameters(): array - { - return $this->prefetchParameters; - } - - /** @param array $prefetchParameters */ - public function withPrefetchParameters(array $prefetchParameters): self - { - return $this->with(prefetchParameters: $prefetchParameters); - } - - public function getPrefetchMethodName(): string|null - { - return $this->prefetchMethodName; - } - - public function withPrefetchMethodName(string|null $prefetchMethodName): self - { - return $this->with(prefetchMethodName: $prefetchMethodName); - } - /** * Sets the callable targeting the resolver function if the resolver function is part of a service. * This should not be used in the context of a field middleware. diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index f25d584f05..b612ee745e 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -5,7 +5,6 @@ namespace TheCodingMachine\GraphQLite; use Doctrine\Common\Annotations\AnnotationReader as DoctrineAnnotationReader; -use Doctrine\Common\Annotations\AnnotationRegistry; use Doctrine\Common\Annotations\PsrCachedReader; use Doctrine\Common\Annotations\Reader; use GraphQL\Type\SchemaConfig; @@ -23,6 +22,7 @@ use TheCodingMachine\GraphQLite\Mappers\Parameters\InjectUserParameterHandler; use TheCodingMachine\GraphQLite\Mappers\Parameters\ParameterMiddlewareInterface; use TheCodingMachine\GraphQLite\Mappers\Parameters\ParameterMiddlewarePipe; +use TheCodingMachine\GraphQLite\Mappers\Parameters\PrefetchParameterMiddleware; use TheCodingMachine\GraphQLite\Mappers\Parameters\ResolveInfoParameterHandler; use TheCodingMachine\GraphQLite\Mappers\PorpaginasTypeMapper; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapper; @@ -337,19 +337,19 @@ public function setExpressionLanguage(ExpressionLanguage $expressionLanguage): s public function createSchema(): Schema { - $symfonyCache = new Psr16Adapter($this->cache, $this->cacheNamespace); - $annotationReader = new AnnotationReader($this->getDoctrineAnnotationReader($symfonyCache), AnnotationReader::LAX_MODE); - $authenticationService = $this->authenticationService ?: new FailAuthenticationService(); - $authorizationService = $this->authorizationService ?: new FailAuthorizationService(); - $typeResolver = new TypeResolver(); - $namespacedCache = new NamespacedCache($this->cache); - $cachedDocBlockFactory = new CachedDocBlockFactory($namespacedCache); - $namingStrategy = $this->namingStrategy ?: new NamingStrategy(); - $typeRegistry = new TypeRegistry(); + $symfonyCache = new Psr16Adapter($this->cache, $this->cacheNamespace); + $annotationReader = new AnnotationReader($this->getDoctrineAnnotationReader($symfonyCache), AnnotationReader::LAX_MODE); + $authenticationService = $this->authenticationService ?: new FailAuthenticationService(); + $authorizationService = $this->authorizationService ?: new FailAuthorizationService(); + $typeResolver = new TypeResolver(); + $namespacedCache = new NamespacedCache($this->cache); + $cachedDocBlockFactory = new CachedDocBlockFactory($namespacedCache); + $namingStrategy = $this->namingStrategy ?: new NamingStrategy(); + $typeRegistry = new TypeRegistry(); $namespaceFactory = new NamespaceFactory($namespacedCache, $this->classNameMapper, $this->globTTL); $nsList = array_map( - static fn (string $namespace) => $namespaceFactory->createNamespace($namespace), + static fn(string $namespace) => $namespaceFactory->createNamespace($namespace), $this->typeNamespaces, ); @@ -390,7 +390,7 @@ public function createSchema(): Schema $rootTypeMapper = new MyCLabsEnumTypeMapper($rootTypeMapper, $annotationReader, $symfonyCache, $nsList); } - if (! empty($this->rootTypeMapperFactories)) { + if (!empty($this->rootTypeMapperFactories)) { $rootSchemaFactoryContext = new RootTypeMapperFactoryContext( $annotationReader, $typeResolver, @@ -415,14 +415,7 @@ public function createSchema(): Schema $lastTopRootTypeMapper->setNext($rootTypeMapper); $argumentResolver = new ArgumentResolver(); - $parameterMiddlewarePipe = new ParameterMiddlewarePipe(); - foreach ($this->parameterMiddlewares as $parameterMapper) { - $parameterMiddlewarePipe->pipe($parameterMapper); - } - $parameterMiddlewarePipe->pipe(new ResolveInfoParameterHandler()); - $parameterMiddlewarePipe->pipe(new ContainerParameterHandler($this->container)); - $parameterMiddlewarePipe->pipe(new InjectUserParameterHandler($authenticationService)); $fieldsBuilder = new FieldsBuilder( $annotationReader, @@ -436,9 +429,18 @@ public function createSchema(): Schema $fieldMiddlewarePipe, $inputFieldMiddlewarePipe, ); + $parameterizedCallableResolver = new ParameterizedCallableResolver($fieldsBuilder, $this->container); + + foreach ($this->parameterMiddlewares as $parameterMapper) { + $parameterMiddlewarePipe->pipe($parameterMapper); + } + $parameterMiddlewarePipe->pipe(new ResolveInfoParameterHandler()); + $parameterMiddlewarePipe->pipe(new PrefetchParameterMiddleware($parameterizedCallableResolver)); + $parameterMiddlewarePipe->pipe(new ContainerParameterHandler($this->container)); + $parameterMiddlewarePipe->pipe(new InjectUserParameterHandler($authenticationService)); - $typeGenerator = new TypeGenerator($annotationReader, $namingStrategy, $typeRegistry, $this->container, $recursiveTypeMapper, $fieldsBuilder); - $inputTypeUtils = new InputTypeUtils($annotationReader, $namingStrategy); + $typeGenerator = new TypeGenerator($annotationReader, $namingStrategy, $typeRegistry, $this->container, $recursiveTypeMapper, $fieldsBuilder); + $inputTypeUtils = new InputTypeUtils($annotationReader, $namingStrategy); $inputTypeGenerator = new InputTypeGenerator($inputTypeUtils, $fieldsBuilder, $this->inputTypeValidator); foreach ($nsList as $ns) { @@ -456,7 +458,7 @@ public function createSchema(): Schema )); } - if (! empty($this->typeMapperFactories) || ! empty($this->queryProviderFactories)) { + if (!empty($this->typeMapperFactories) || !empty($this->queryProviderFactories)) { $context = new FactoryContext( $annotationReader, $typeResolver, diff --git a/tests/AbstractQueryProviderTest.php b/tests/AbstractQueryProviderTest.php index 9446affb95..0c888343f8 100644 --- a/tests/AbstractQueryProviderTest.php +++ b/tests/AbstractQueryProviderTest.php @@ -16,12 +16,14 @@ use Symfony\Component\Cache\Adapter\Psr16Adapter; use Symfony\Component\Cache\Psr16Cache; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use TheCodingMachine\GraphQLite\Containers\EmptyContainer; use TheCodingMachine\GraphQLite\Containers\LazyContainer; use TheCodingMachine\GraphQLite\Fixtures\Mocks\MockResolvableInputObjectType; use TheCodingMachine\GraphQLite\Fixtures\TestObject; use TheCodingMachine\GraphQLite\Fixtures\TestObject2; use TheCodingMachine\GraphQLite\Loggers\ExceptionLogger; use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException; +use TheCodingMachine\GraphQLite\Mappers\Parameters\PrefetchParameterMiddleware; use TheCodingMachine\GraphQLite\Mappers\Parameters\ResolveInfoParameterHandler; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapper; use TheCodingMachine\GraphQLite\Mappers\Root\BaseTypeMapper; @@ -40,6 +42,7 @@ use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewarePipe; use TheCodingMachine\GraphQLite\Mappers\Parameters\ParameterMiddlewarePipe; use TheCodingMachine\GraphQLite\Middlewares\InputFieldMiddlewarePipe; +use TheCodingMachine\GraphQLite\Middlewares\PrefetchFieldMiddleware; use TheCodingMachine\GraphQLite\Middlewares\SecurityFieldMiddleware; use TheCodingMachine\GraphQLite\Reflection\CachedDocBlockFactory; use TheCodingMachine\GraphQLite\Security\SecurityExpressionLanguageProvider; @@ -76,9 +79,9 @@ protected function getTestObjectType(): MutableObjectType { if ($this->testObjectType === null) { $this->testObjectType = new MutableObjectType([ - 'name' => 'TestObject', - 'fields' => [ - 'test' => Type::string(), + 'name' => 'TestObject', + 'fields' => [ + 'test' => Type::string(), ], ]); } @@ -89,9 +92,9 @@ protected function getTestObjectType2(): MutableObjectType { if ($this->testObjectType2 === null) { $this->testObjectType2 = new MutableObjectType([ - 'name' => 'TestObject2', - 'fields' => [ - 'test' => Type::string(), + 'name' => 'TestObject2', + 'fields' => [ + 'test' => Type::string(), ], ]); } @@ -102,11 +105,11 @@ protected function getInputTestObjectType(): MockResolvableInputObjectType { if ($this->inputTestObjectType === null) { $this->inputTestObjectType = new MockResolvableInputObjectType([ - 'name' => 'TestObjectInput', - 'fields' => [ - 'test' => Type::string(), + 'name' => 'TestObjectInput', + 'fields' => [ + 'test' => Type::string(), ], - ], function($source, $args) { + ], function ($source, $args) { return new TestObject($args['test']); }); } @@ -119,7 +122,8 @@ protected function getTypeMapper() $arrayAdapter = new ArrayAdapter(); $arrayAdapter->setLogger(new ExceptionLogger()); - $this->typeMapper = new RecursiveTypeMapper(new class($this->getTestObjectType(), $this->getTestObjectType2(), $this->getInputTestObjectType()/*, $this->getInputTestObjectType2()*/) implements TypeMapperInterface { + $this->typeMapper = new RecursiveTypeMapper(new class($this->getTestObjectType(), $this->getTestObjectType2(), $this->getInputTestObjectType()/*, $this->getInputTestObjectType2()*/ + ) implements TypeMapperInterface { /** * @var ObjectType */ @@ -132,15 +136,17 @@ protected function getTypeMapper() * @var InputObjectType */ private $inputTestObjectType; + /** * @var InputObjectType */ public function __construct( - ObjectType $testObjectType, - ObjectType $testObjectType2, + ObjectType $testObjectType, + ObjectType $testObjectType2, InputObjectType $inputTestObjectType - ) { + ) + { $this->testObjectType = $testObjectType; $this->testObjectType2 = $testObjectType2; $this->inputTestObjectType = $inputTestObjectType; @@ -276,6 +282,7 @@ protected function buildFieldsBuilder(): FieldsBuilder $arrayAdapter = new ArrayAdapter(); $arrayAdapter->setLogger(new ExceptionLogger()); $psr16Cache = new Psr16Cache($arrayAdapter); + $container = new EmptyContainer(); $fieldMiddlewarePipe = new FieldMiddlewarePipe(); $fieldMiddlewarePipe->pipe(new AuthorizationFieldMiddleware( @@ -287,19 +294,16 @@ protected function buildFieldsBuilder(): FieldsBuilder new Psr16Adapter($psr16Cache), [new SecurityExpressionLanguageProvider()] ); - + $fieldMiddlewarePipe->pipe( new SecurityFieldMiddleware($expressionLanguage, - new VoidAuthenticationService(), - new VoidAuthorizationService()) + new VoidAuthenticationService(), + new VoidAuthorizationService()) ); $inputFieldMiddlewarePipe = new InputFieldMiddlewarePipe(); - $parameterMiddlewarePipe = new ParameterMiddlewarePipe(); - $parameterMiddlewarePipe->pipe(new ResolveInfoParameterHandler()); - - return new FieldsBuilder( + $fieldsBuilder = new FieldsBuilder( $this->getAnnotationReader(), $this->getTypeMapper(), $this->getArgumentResolver(), @@ -307,10 +311,16 @@ protected function buildFieldsBuilder(): FieldsBuilder new CachedDocBlockFactory($psr16Cache), new NamingStrategy(), $this->buildRootTypeMapper(), - $this->getParameterMiddlewarePipe(), + $parameterMiddlewarePipe, $fieldMiddlewarePipe, $inputFieldMiddlewarePipe ); + $parameterizedCallableResolver = new ParameterizedCallableResolver($fieldsBuilder, $container); + + $parameterMiddlewarePipe->pipe(new ResolveInfoParameterHandler()); + $parameterMiddlewarePipe->pipe(new PrefetchParameterMiddleware($parameterizedCallableResolver)); + + return $fieldsBuilder; } protected function getRootTypeMapper(): RootTypeMapperInterface diff --git a/tests/FieldsBuilderTest.php b/tests/FieldsBuilderTest.php index 9070a8fb90..e0941a9a18 100644 --- a/tests/FieldsBuilderTest.php +++ b/tests/FieldsBuilderTest.php @@ -48,10 +48,9 @@ use TheCodingMachine\GraphQLite\Fixtures\TestTypeMissingField; use TheCodingMachine\GraphQLite\Fixtures\TestTypeMissingReturnType; use TheCodingMachine\GraphQLite\Fixtures\TestTypeWithFailWith; -use TheCodingMachine\GraphQLite\Fixtures\TestTypeWithInvalidPrefetchParameter; use TheCodingMachine\GraphQLite\Fixtures\TestTypeWithMagicProperty; use TheCodingMachine\GraphQLite\Fixtures\TestTypeWithMagicPropertyType; -use TheCodingMachine\GraphQLite\Fixtures\TestTypeWithPrefetchMethod; +use TheCodingMachine\GraphQLite\Fixtures\TestTypeWithPrefetchMethods; use TheCodingMachine\GraphQLite\Fixtures\TestTypeWithSourceFieldInterface; use TheCodingMachine\GraphQLite\Fixtures\TestTypeWithSourceFieldInvalidParameterAnnotation; use TheCodingMachine\GraphQLite\Fixtures\TestSourceName; @@ -154,8 +153,7 @@ public function testMutations(): void public function testErrors(): void { - $controller = new class - { + $controller = new class { /** * @Query * @return string @@ -174,8 +172,7 @@ public function test($noTypeHint): string public function testTypeInDocBlock(): void { - $controller = new class - { + $controller = new class { /** * @Query * @param int $typeHintInDocBlock @@ -689,24 +686,13 @@ public function testInvalidPrefetchMethod(): void $queryProvider = $this->buildFieldsBuilder(); $this->expectException(InvalidPrefetchMethodRuntimeException::class); - $this->expectExceptionMessage('The @Field annotation in TheCodingMachine\\GraphQLite\\Fixtures\\TestTypeWithInvalidPrefetchMethod::test specifies a "prefetch method" that could not be found. Unable to find method TheCodingMachine\\GraphQLite\\Fixtures\\TestTypeWithInvalidPrefetchMethod::notExists.'); - $queryProvider->getFields($controller); - } - - public function testInvalidPrefetchParameter(): void - { - $controller = new TestTypeWithInvalidPrefetchParameter(); - - $queryProvider = $this->buildFieldsBuilder(); - - $this->expectException(InvalidPrefetchMethodRuntimeException::class); - $this->expectExceptionMessage('The @Field annotation in TheCodingMachine\GraphQLite\Fixtures\TestTypeWithInvalidPrefetchParameter::prefetch specifies a "prefetch method" but the data from the prefetch method is not gathered. The "prefetch" method should accept a second parameter that will contain data returned by the prefetch method.'); + $this->expectExceptionMessage('#[Prefetch] attribute on parameter $data in TheCodingMachine\\GraphQLite\\Fixtures\\TestTypeWithInvalidPrefetchMethod::test specifies a callable that is invalid: Method TheCodingMachine\\GraphQLite\\Fixtures\\TestTypeWithInvalidPrefetchMethod::notExists wasn\'t found or isn\'t accessible.'); $queryProvider->getFields($controller); } public function testPrefetchMethod(): void { - $controller = new TestTypeWithPrefetchMethod(); + $controller = new TestTypeWithPrefetchMethods(); $queryProvider = $this->buildFieldsBuilder(); @@ -715,9 +701,11 @@ public function testPrefetchMethod(): void $this->assertSame('test', $testField->name); - $this->assertCount(2, $testField->args); - $this->assertSame('string', $testField->args[0]->name); - $this->assertSame('int', $testField->args[1]->name); + $this->assertCount(4, $testField->args); + $this->assertSame('arg1', $testField->args[0]->name); + $this->assertSame('arg2', $testField->args[1]->name); + $this->assertSame('arg3', $testField->args[2]->name); + $this->assertSame('arg4', $testField->args[3]->name); } public function testSecurityBadQuery(): void diff --git a/tests/Fixtures/Integration/Models/Contact.php b/tests/Fixtures/Integration/Models/Contact.php index d6877f584c..58d12549dd 100644 --- a/tests/Fixtures/Integration/Models/Contact.php +++ b/tests/Fixtures/Integration/Models/Contact.php @@ -8,6 +8,7 @@ use TheCodingMachine\GraphQLite\Annotations\FailWith; use TheCodingMachine\GraphQLite\Annotations\HideIfUnauthorized; use TheCodingMachine\GraphQLite\Annotations\MagicField; +use TheCodingMachine\GraphQLite\Annotations\Prefetch; use TheCodingMachine\GraphQLite\Annotations\Security; use function array_search; use DateTimeInterface; @@ -212,9 +213,9 @@ public function setCompany(string $company): void } /** - * @Field(prefetchMethod="prefetchTheContacts") + * @Field() */ - public function repeatInnerName($data): string + public function repeatInnerName(#[Prefetch('prefetchTheContacts')] $data): string { $index = array_search($this, $data, true); if ($index === false) { @@ -223,7 +224,7 @@ public function repeatInnerName($data): string return $data[$index]->getName(); } - public function prefetchTheContacts(iterable $contacts) + public static function prefetchTheContacts(iterable $contacts) { return $contacts; } diff --git a/tests/Fixtures/Integration/Types/ContactType.php b/tests/Fixtures/Integration/Types/ContactType.php index aa817b3c5d..a347815879 100644 --- a/tests/Fixtures/Integration/Types/ContactType.php +++ b/tests/Fixtures/Integration/Types/ContactType.php @@ -3,6 +3,7 @@ namespace TheCodingMachine\GraphQLite\Fixtures\Integration\Types; +use TheCodingMachine\GraphQLite\Annotations\Prefetch; use function array_search; use function strtoupper; use TheCodingMachine\GraphQLite\Annotations\ExtendType; @@ -34,9 +35,9 @@ public function customField(Contact $contact, string $prefix): string } /** - * @Field(prefetchMethod="prefetchContacts") + * @Field() */ - public function repeatName(Contact $contact, $data, string $suffix): string + public function repeatName(Contact $contact, #[Prefetch('prefetchContacts')] $data, string $suffix): string { $index = array_search($contact, $data['contacts'], true); if ($index === false) { @@ -45,7 +46,7 @@ public function repeatName(Contact $contact, $data, string $suffix): string return $data['prefix'].$data['contacts'][$index]->getName().$suffix; } - public function prefetchContacts(iterable $contacts, string $prefix) + public static function prefetchContacts(iterable $contacts, string $prefix) { return [ 'contacts' => $contacts, diff --git a/tests/Fixtures/TestTypeWithInvalidPrefetchMethod.php b/tests/Fixtures/TestTypeWithInvalidPrefetchMethod.php index 1832d84f9b..41ffc5052f 100644 --- a/tests/Fixtures/TestTypeWithInvalidPrefetchMethod.php +++ b/tests/Fixtures/TestTypeWithInvalidPrefetchMethod.php @@ -5,6 +5,7 @@ use Exception; use TheCodingMachine\GraphQLite\Annotations\Field; +use TheCodingMachine\GraphQLite\Annotations\Prefetch; use TheCodingMachine\GraphQLite\Annotations\Query; use TheCodingMachine\GraphQLite\Annotations\Type; @@ -14,9 +15,9 @@ class TestTypeWithInvalidPrefetchMethod { /** - * @Field(prefetchMethod="notExists") + * @Field() */ - public function test(): string + public function test(object $source, #[Prefetch('notExists')] $data): string { return 'foo'; } diff --git a/tests/Fixtures/TestTypeWithInvalidPrefetchParameter.php b/tests/Fixtures/TestTypeWithInvalidPrefetchParameter.php deleted file mode 100644 index c2def0ff51..0000000000 --- a/tests/Fixtures/TestTypeWithInvalidPrefetchParameter.php +++ /dev/null @@ -1,28 +0,0 @@ -getInputTypeNameAndClassName(new ReflectionMethod($this, 'factoryNullableReturnType')); } + public function testToInputParameters(): void + { + if (Version::series() === '8.5') { + $this->markTestSkipped('Broken on PHPUnit 8.'); + } + + self::assertSame([], InputTypeUtils::toInputParameters([])); + self::assertSame([ + 'second' => $second = $this->createStub(InputTypeParameterInterface::class), + 'third' => $third = $this->createStub(InputTypeParameterInterface::class), + ], InputTypeUtils::toInputParameters([ + 'first' => new class ($second) implements ExpandsInputTypeParameters { + public function __construct( + private readonly ParameterInterface $second, + ) + { + } + + public function toInputTypeParameters(): array + { + return [ + 'second' => $this->second, + ]; + } + }, + 'third' => $third, + 'fourth' => $this->createStub(ParameterInterface::class), + ])); + } + public function factoryNoReturnType() { - + } public function factoryStringReturnType(): string diff --git a/tests/Integration/EndToEndTest.php b/tests/Integration/EndToEndTest.php index a8d5fb9bad..93d6f7f41f 100644 --- a/tests/Integration/EndToEndTest.php +++ b/tests/Integration/EndToEndTest.php @@ -39,6 +39,7 @@ use TheCodingMachine\GraphQLite\Mappers\Parameters\InjectUserParameterHandler; use TheCodingMachine\GraphQLite\Mappers\Parameters\ParameterMiddlewareInterface; use TheCodingMachine\GraphQLite\Mappers\Parameters\ParameterMiddlewarePipe; +use TheCodingMachine\GraphQLite\Mappers\Parameters\PrefetchParameterMiddleware; use TheCodingMachine\GraphQLite\Mappers\Parameters\ResolveInfoParameterHandler; use TheCodingMachine\GraphQLite\Mappers\PorpaginasTypeMapper; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapper; @@ -61,10 +62,12 @@ use TheCodingMachine\GraphQLite\Middlewares\InputFieldMiddlewareInterface; use TheCodingMachine\GraphQLite\Middlewares\InputFieldMiddlewarePipe; use TheCodingMachine\GraphQLite\Middlewares\MissingAuthorizationException; +use TheCodingMachine\GraphQLite\Middlewares\PrefetchFieldMiddleware; use TheCodingMachine\GraphQLite\Middlewares\SecurityFieldMiddleware; use TheCodingMachine\GraphQLite\Middlewares\SecurityInputFieldMiddleware; use TheCodingMachine\GraphQLite\NamingStrategy; use TheCodingMachine\GraphQLite\NamingStrategyInterface; +use TheCodingMachine\GraphQLite\ParameterizedCallableResolver; use TheCodingMachine\GraphQLite\QueryProviderInterface; use TheCodingMachine\GraphQLite\Reflection\CachedDocBlockFactory; use TheCodingMachine\GraphQLite\Schema; @@ -132,7 +135,8 @@ public function createContainer(array $overloadedServices = []): ContainerInterf return $queryProvider; }, FieldsBuilder::class => static function (ContainerInterface $container) { - return new FieldsBuilder( + $parameterMiddlewarePipe = $container->get(ParameterMiddlewareInterface::class); + $fieldsBuilder = new FieldsBuilder( $container->get(AnnotationReader::class), $container->get(RecursiveTypeMapperInterface::class), $container->get(ArgumentResolver::class), @@ -140,10 +144,15 @@ public function createContainer(array $overloadedServices = []): ContainerInterf $container->get(CachedDocBlockFactory::class), $container->get(NamingStrategyInterface::class), $container->get(RootTypeMapperInterface::class), - $container->get(ParameterMiddlewareInterface::class), + $parameterMiddlewarePipe, $container->get(FieldMiddlewareInterface::class), $container->get(InputFieldMiddlewareInterface::class), ); + $parameterizedCallableResolver = new ParameterizedCallableResolver($fieldsBuilder, $container); + + $parameterMiddlewarePipe->pipe(new PrefetchParameterMiddleware($parameterizedCallableResolver)); + + return $fieldsBuilder; }, FieldMiddlewareInterface::class => static function (ContainerInterface $container) { $pipe = new FieldMiddlewarePipe(); @@ -312,9 +321,9 @@ public function createContainer(array $overloadedServices = []): ContainerInterf // These are in reverse order of execution $errorRootTypeMapper = new FinalRootTypeMapper($container->get(RecursiveTypeMapperInterface::class)); $rootTypeMapper = new BaseTypeMapper($errorRootTypeMapper, $container->get(RecursiveTypeMapperInterface::class), $container->get(RootTypeMapperInterface::class)); - $rootTypeMapper = new MyCLabsEnumTypeMapper($rootTypeMapper, $container->get(AnnotationReader::class), new ArrayAdapter(), [ $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models') ]); + $rootTypeMapper = new MyCLabsEnumTypeMapper($rootTypeMapper, $container->get(AnnotationReader::class), new ArrayAdapter(), [$container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models')]); if (interface_exists(UnitEnum::class)) { - $rootTypeMapper = new EnumTypeMapper($rootTypeMapper, $container->get(AnnotationReader::class), new ArrayAdapter(), [ $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Models') ]); + $rootTypeMapper = new EnumTypeMapper($rootTypeMapper, $container->get(AnnotationReader::class), new ArrayAdapter(), [$container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Models')]); } $rootTypeMapper = new CompoundTypeMapper($rootTypeMapper, $container->get(RootTypeMapperInterface::class), $container->get(NamingStrategyInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class)); $rootTypeMapper = new IteratorTypeMapper($rootTypeMapper, $container->get(RootTypeMapperInterface::class)); @@ -385,7 +394,7 @@ public function createContainer(array $overloadedServices = []): ContainerInterf private function getSuccessResult(ExecutionResult $result, int $debugFlag = DebugFlag::RETHROW_INTERNAL_EXCEPTIONS): mixed { $array = $result->toArray($debugFlag); - if (isset($array['errors']) || ! isset($array['data'])) { + if (isset($array['errors']) || !isset($array['data'])) { $this->fail('Expected a successful answer. Got ' . json_encode($array, JSON_PRETTY_PRINT)); } return $array['data']; @@ -592,7 +601,7 @@ public function testPrefetchException(): void ); $this->expectException(GraphQLRuntimeException::class); - $this->expectExceptionMessage('When using "prefetch", you sure ensure that the GraphQL execution "context" (passed to the GraphQL::executeQuery method) is an instance of \\TheCodingMachine\\GraphQLite\\Context'); + $this->expectExceptionMessage('When using "prefetch", you should ensure that the GraphQL execution "context" (passed to the GraphQL::executeQuery method) is an instance of \\TheCodingMachine\\GraphQLite\\Context'); $result->toArray(DebugFlag::RETHROW_INTERNAL_EXCEPTIONS); } @@ -910,7 +919,7 @@ public function testEndToEndStaticFactories(): void ); $this->assertSame([ - 'echoFilters' => [ 'foo', 'bar', '12', '42', '62' ], + 'echoFilters' => ['foo', 'bar', '12', '42', '62'], ], $this->getSuccessResult($result)); // Call again to test GlobTypeMapper cache @@ -920,7 +929,7 @@ public function testEndToEndStaticFactories(): void ); $this->assertSame([ - 'echoFilters' => [ 'foo', 'bar', '12', '42', '62' ], + 'echoFilters' => ['foo', 'bar', '12', '42', '62'], ], $this->getSuccessResult($result)); } @@ -1309,7 +1318,7 @@ public function testEndToEndNativeEnums(): void ], ); $this->assertSame([ - 'singleEnum' => 'L', + 'singleEnum' => 'L', ], $this->getSuccessResult($result)); } @@ -1749,7 +1758,7 @@ public function getUser(): object|null public function testEndToEndInjectUserUnauthenticated(): void { $container = $this->createContainer([ - AuthenticationServiceInterface::class => static fn () => new VoidAuthenticationService(), + AuthenticationServiceInterface::class => static fn() => new VoidAuthenticationService(), ]); $schema = $container->get(Schema::class); @@ -1803,7 +1812,7 @@ public function testNullableResult(): void $queryString, ); $resultArray = $result->toArray(DebugFlag::RETHROW_UNSAFE_EXCEPTIONS); - if (isset($resultArray['errors']) || ! isset($resultArray['data'])) { + if (isset($resultArray['errors']) || !isset($resultArray['data'])) { $this->fail('Expected a successful answer. Got ' . json_encode($resultArray, JSON_PRETTY_PRINT)); } $this->assertNull($resultArray['data']['nullableResult']); @@ -2321,7 +2330,7 @@ public function isAllowed(string $right, $subject = null): bool $queryString, ); - $this->assertSame('Access denied.', $result->toArray(DebugFlag::RETHROW_UNSAFE_EXCEPTIONS)['errors'][0]['message']); + $this->assertSame('Access denied.', $result->toArray(DebugFlag::RETHROW_UNSAFE_EXCEPTIONS)['errors'][0]['message']); $container = $this->createContainer([ AuthenticationServiceInterface::class => static function () { diff --git a/tests/Mappers/Parameters/HardCodedParameter.php b/tests/Mappers/Parameters/HardCodedParameter.php index 179c258a6a..be2f18ba64 100644 --- a/tests/Mappers/Parameters/HardCodedParameter.php +++ b/tests/Mappers/Parameters/HardCodedParameter.php @@ -9,7 +9,7 @@ class HardCodedParameter implements ParameterInterface { - public function __construct(private mixed $value) + public function __construct(private mixed $value = null) { } diff --git a/tests/Mappers/Parameters/PrefetchParameterMiddlewareTest.php b/tests/Mappers/Parameters/PrefetchParameterMiddlewareTest.php new file mode 100644 index 0000000000..ea749d6de8 --- /dev/null +++ b/tests/Mappers/Parameters/PrefetchParameterMiddlewareTest.php @@ -0,0 +1,110 @@ +createMock(ParameterHandlerInterface::class); + $next->method('mapParameter')->willReturn($expected); + + $refMethod = new ReflectionMethod(__CLASS__, 'dummy'); + $parameter = $refMethod->getParameters()[0]; + + $result = (new PrefetchParameterMiddleware( + $this->createMock(ParameterizedCallableResolver::class), + ))->mapParameter( + $parameter, + new DocBlock(), + null, + new ParameterAnnotations([]), + $next, + ); + + self::assertSame($expected, $result); + } + + public function testMapsToPrefetchDataParameter(): void + { + $parameterizedCallableResolver = $this->createMock(ParameterizedCallableResolver::class); + $parameterizedCallableResolver + ->method('resolve') + ->with('dummy', new IsEqual(new ReflectionClass(self::class)), 1) + ->willReturn([ + fn() => null, + [], + ]); + + $refMethod = new ReflectionMethod(__CLASS__, 'dummy'); + $parameter = $refMethod->getParameters()[0]; + + $result = (new PrefetchParameterMiddleware( + $parameterizedCallableResolver, + ))->mapParameter( + $parameter, + new DocBlock(), + null, + new ParameterAnnotations([ + new Prefetch('dummy'), + ]), + $this->createMock(ParameterHandlerInterface::class), + ); + + self::assertInstanceOf(PrefetchDataParameter::class, $result); + } + + public function testRethrowsInvalidCallableAsInvalidPrefetchException(): void + { + $this->expectException(InvalidPrefetchMethodRuntimeException::class); + $this->expectExceptionMessage('#[Prefetch] attribute on parameter $foo in TheCodingMachine\\GraphQLite\\Mappers\\Parameters\\PrefetchParameterMiddlewareTest::dummy specifies a callable that is invalid: Method TheCodingMachine\\GraphQLite\\Fixtures\\TestType::notExists wasn\'t found or isn\'t accessible.'); + + $parameterizedCallableResolver = $this->createMock(ParameterizedCallableResolver::class); + $parameterizedCallableResolver + ->method('resolve') + ->with([TestType::class, 'notExists'], new IsEqual(new ReflectionClass(self::class)), 1) + ->willThrowException(InvalidCallableRuntimeException::methodNotFound(TestType::class, 'notExists')); + + $refMethod = new ReflectionMethod(__CLASS__, 'dummy'); + $parameter = $refMethod->getParameters()[0]; + + (new PrefetchParameterMiddleware( + $parameterizedCallableResolver, + ))->mapParameter( + $parameter, + new DocBlock(), + null, + new ParameterAnnotations([ + new Prefetch([TestType::class, 'notExists']), + ]), + $this->createMock(ParameterHandlerInterface::class), + ); + } + + private function dummy($foo) + { + + } +} diff --git a/tests/ParameterizedCallableResolverTest.php b/tests/ParameterizedCallableResolverTest.php new file mode 100644 index 0000000000..dbe8eeb086 --- /dev/null +++ b/tests/ParameterizedCallableResolverTest.php @@ -0,0 +1,85 @@ +createStub(ParameterInterface::class)]; + + $fieldsBuilder = $this->createMock(FieldsBuilder::class); + $fieldsBuilder->method('getParameters') + ->with(new IsEqual(new \ReflectionMethod(Contact::class, 'prefetchTheContacts')), 123) + ->willReturn($expectedParameters); + + [$resultingCallable, $resultingParameters] = (new ParameterizedCallableResolver( + $fieldsBuilder, + $this->createMock(ContainerInterface::class), + ))->resolve([Contact::class, 'prefetchTheContacts'], self::class, 123); + + self::assertSame(['test'], $resultingCallable(['test'])); + self::assertSame($expectedParameters, $resultingParameters); + } + + public function testResolveReturnsCallableAndParametersFromStaticMethodOnSelf(): void + { + $expectedParameters = [$this->createStub(ParameterInterface::class)]; + + $fieldsBuilder = $this->createMock(FieldsBuilder::class); + $fieldsBuilder->method('getParameters') + ->with(new IsEqual(new \ReflectionMethod(Contact::class, 'prefetchTheContacts')), 123) + ->willReturn($expectedParameters); + + [$resultingCallable, $resultingParameters] = (new ParameterizedCallableResolver( + $fieldsBuilder, + $this->createMock(ContainerInterface::class), + ))->resolve('prefetchTheContacts', Contact::class, 123); + + self::assertSame(['test'], $resultingCallable(['test'])); + self::assertSame($expectedParameters, $resultingParameters); + } + + public function testResolveReturnsCallableAndParametersFromContainer(): void + { + $expectedParameters = [$this->createStub(ParameterInterface::class)]; + + $fieldsBuilder = $this->createMock(FieldsBuilder::class); + $fieldsBuilder->method('getParameters') + ->with(new IsEqual(new \ReflectionMethod(FooExtendType::class, 'customExtendedField')), 123) + ->willReturn($expectedParameters); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get') + ->with(FooExtendType::class) + ->willReturn(new FooExtendType()); + + [$resultingCallable, $resultingParameters] = (new ParameterizedCallableResolver( + $fieldsBuilder, + $container, + ))->resolve([FooExtendType::class, 'customExtendedField'], self::class, 123); + + self::assertSame('TEST', $resultingCallable(new TestObject('test'))); + self::assertSame($expectedParameters, $resultingParameters); + } + + public function testResolveThrowsInvalidCallableMethodNotFoundException(): void + { + $this->expectException(InvalidCallableRuntimeException::class); + $this->expectExceptionMessage('Method TheCodingMachine\\GraphQLite\\ParameterizedCallableResolverTest::doesntExist wasn\'t found or isn\'t accessible.'); + + (new ParameterizedCallableResolver( + $this->createMock(FieldsBuilder::class), + $this->createMock(ContainerInterface::class), + ))->resolve('doesntExist', self::class); + } +} \ No newline at end of file diff --git a/tests/Parameters/PrefetchDataParameterTest.php b/tests/Parameters/PrefetchDataParameterTest.php new file mode 100644 index 0000000000..9d241b7ccc --- /dev/null +++ b/tests/Parameters/PrefetchDataParameterTest.php @@ -0,0 +1,86 @@ +fail('Should not be called'); + }, []); + + $source = new stdClass(); + $prefetchResult = new stdClass(); + $context = new Context(); + $args = [ + 'first' => 'qwe', + 'second' => 'rty' + ]; + $buffer = $context->getPrefetchBuffer($parameter); + + $buffer->storeResult($prefetchResult, $args); + + $resolvedParameterPromise = $parameter->resolve($source, $args, $context, $this->createStub(ResolveInfo::class)); + + self::assertSame([$source], $buffer->getObjectsByArguments($args)); + self::assertSame($prefetchResult, $this->deferredValue($resolvedParameterPromise)); + } + + public function testResolveWithoutExistingResult(): void + { + $prefetchResult = new stdClass(); + $source = new stdClass(); + $prefetchHandler = function (array $sources, string $second) use ($prefetchResult, $source) { + self::assertSame([$source], $sources); + self::assertSame('rty', $second); + + return $prefetchResult; + }; + + $parameter = new PrefetchDataParameter('field', $prefetchHandler, [ + new InputTypeParameter( + name: 'second', + type: Type::string(), + description: '', + hasDefaultValue: false, + defaultValue: null, + argumentResolver: new ArgumentResolver() + ) + ]); + + $context = new Context(); + $args = [ + 'first' => 'qwe', + 'second' => 'rty', + ]; + $buffer = $context->getPrefetchBuffer($parameter); + + $resolvedParameterPromise = $parameter->resolve($source, $args, $context, $this->createStub(ResolveInfo::class)); + + self::assertFalse($buffer->hasResult($args)); + self::assertSame([$source], $buffer->getObjectsByArguments($args)); + self::assertSame($prefetchResult, $this->deferredValue($resolvedParameterPromise)); + self::assertTrue($buffer->hasResult($args)); + } + + private function deferredValue(Deferred $promise): mixed + { + $syncPromiseAdapter = new SyncPromiseAdapter(); + + return $syncPromiseAdapter->wait(new Promise($promise, $syncPromiseAdapter)); + } +} \ No newline at end of file diff --git a/tests/QueryFieldTest.php b/tests/QueryFieldTest.php index 278c31b586..2503c63351 100644 --- a/tests/QueryFieldTest.php +++ b/tests/QueryFieldTest.php @@ -24,7 +24,7 @@ public function resolve(?object $source, array $args, mixed $context, ResolveInf throw new Error('boum'); } }, - ], $sourceResolver, $sourceResolver, null, null, null, []); + ], $sourceResolver, $sourceResolver, null, null, []); $resolve = $queryField->resolveFn; diff --git a/website/docs/annotations-reference.md b/website/docs/annotations-reference.md index 9dddfe0aff..334b2864d9 100644 --- a/website/docs/annotations-reference.md +++ b/website/docs/annotations-reference.md @@ -119,6 +119,17 @@ annotations | *no* | array\ | A set of annotations that (*) **Note**: `outputType` and `phpType` are mutually exclusive. You MUST provide one of them. +## @Prefetch + +Marks field parameter to be used for [prefetching](prefetch-method.mdx). + +**Applies on**: parameters of methods annotated with `@Query`, `@Mutation` or `@Field`. + +Attribute | Compulsory | Type | Definition +------------------------------|------------|----------|-------- +callable | *no* | callable | Name of the prefetch method (in same class) or a full callable, either a static method or regular service from the container + + ## @Logged The `@Logged` annotation is used to declare a Query/Mutation/Field is only visible to logged users. diff --git a/website/docs/field-middlewares.md b/website/docs/field-middlewares.md index 8532a40688..18eac15b30 100644 --- a/website/docs/field-middlewares.md +++ b/website/docs/field-middlewares.md @@ -43,10 +43,6 @@ class QueryFieldDescriptor public function withType($type): self { /* ... */ } public function getParameters(): array { /* ... */ } public function withParameters(array $parameters): self { /* ... */ } - public function getPrefetchParameters(): array { /* ... */ } - public function withPrefetchParameters(array $prefetchParameters): self { /* ... */ } - public function getPrefetchMethodName(): ?string { /* ... */ } - public function withPrefetchMethodName(?string $prefetchMethodName): self { /* ... */ } public function withCallable(callable $callable): self { /* ... */ } public function withTargetMethodOnSource(?string $targetMethodOnSource): self { /* ... */ } public function isInjectSource(): bool { /* ... */ } diff --git a/website/docs/prefetch-method.mdx b/website/docs/prefetch-method.mdx index 9df8877837..8c3717178b 100644 --- a/website/docs/prefetch-method.mdx +++ b/website/docs/prefetch-method.mdx @@ -59,8 +59,8 @@ class PostType { * @param mixed $prefetchedUsers * @return User */ - #[Field(prefetchMethod: "prefetchUsers")] - public function getUser(Post $post, $prefetchedUsers): User + #[Field] + public function getUser(Post $post, #[Prefetch("prefetchUsers")] $prefetchedUsers): User { // This method will receive the $prefetchedUsers as second argument. This is the return value of the "prefetchUsers" method below. // Using this prefetched list, it should be easy to map it to the post @@ -70,7 +70,7 @@ class PostType { * @param Post[] $posts * @return mixed */ - public function prefetchUsers(iterable $posts) + public static function prefetchUsers(iterable $posts) { // This function is called only once per GraphQL request // with the list of posts. You can fetch the list of users @@ -120,13 +120,16 @@ class PostType { -When the "prefetchMethod" attribute is detected in the "@Field" annotation, the method is called automatically. -The first argument of the method is an array of instances of the main type. -The "prefetchMethod" can return absolutely anything (mixed). The return value will be passed as the second parameter of the "@Field" annotated method. +When a "#[Prefetch]" attribute is detected on a parameter of "@Field" annotation, the method is called automatically. +The prefetch callable must be one of the following: + - a static method in the same class: `#[Prefetch('prefetchMethod')]` + - a static method in a different class: `#[Prefetch([OtherClass::class, 'prefetchMethod')]` + - a non-static method in a different class, resolvable through the container: `#[Prefetch([OtherService::class, 'prefetchMethod'])]` +The first argument of the method is always an array of instances of the main type. It can return absolutely anything (mixed). ## Input arguments -Field arguments can be set either on the @Field annotated method OR/AND on the prefetchMethod. +Field arguments can be set either on the @Field annotated method OR/AND on the prefetch methods. For instance: @@ -146,8 +149,8 @@ class PostType { * @param mixed $prefetchedComments * @return Comment[] */ - #[Field(prefetchMethod: "prefetchComments")] - public function getComments(Post $post, $prefetchedComments): array + #[Field] + public function getComments(Post $post, #[Prefetch("prefetchComments")] $prefetchedComments): array { // ... } @@ -156,7 +159,7 @@ class PostType { * @param Post[] $posts * @return mixed */ - public function prefetchComments(iterable $posts, bool $hideSpam, int $filterByScore) + public static function prefetchComments(iterable $posts, bool $hideSpam, int $filterByScore) { // Parameters passed after the first parameter (hideSpam, filterByScore...) are automatically exposed // as GraphQL arguments for the "comments" field. @@ -197,5 +200,3 @@ class PostType { - -The prefetch method MUST be in the same class as the @Field-annotated method and MUST be public.