From 9563fc543209b534e0f7309f092d409df59d3995 Mon Sep 17 00:00:00 2001 From: Yup Date: Thu, 24 Oct 2024 10:38:37 +0300 Subject: [PATCH] Create field args mapper and cache args resolution --- docs/class-reference.md | 7 ++- docs/type-definitions/object-types.md | 1 + src/Executor/ExecutionContext.php | 10 ++++ src/Executor/Executor.php | 37 ++++++++++++- src/Executor/ReferenceExecutor.php | 38 +++++++++---- src/Type/Definition/FieldDefinition.php | 13 +++++ src/Type/Definition/ObjectType.php | 10 ++++ src/Utils/SchemaExtender.php | 4 +- tests/Executor/ExecutorTest.php | 73 +++++++++++++++++++++++++ tests/Executor/ResolveTest.php | 2 +- 10 files changed, 177 insertions(+), 18 deletions(-) diff --git a/docs/class-reference.md b/docs/class-reference.md index 6d39d1fde..d1ca03913 100644 --- a/docs/class-reference.md +++ b/docs/class-reference.md @@ -1341,8 +1341,9 @@ const CLASS_MAP = [ Implements the "Evaluating requests" section of the GraphQL specification. +@phpstan-type ArgsMapper callable(array, FieldDefinition, FieldNode, mixed): mixed @phpstan-type FieldResolver callable(mixed, array, mixed, ResolveInfo): mixed -@phpstan-type ImplementationFactory callable(PromiseAdapter, Schema, DocumentNode, mixed, mixed, array, ?string, callable): ExecutorImplementation +@phpstan-type ImplementationFactory callable(PromiseAdapter, Schema, DocumentNode, mixed, mixed, array, ?string, callable, callable): ExecutorImplementation @see \GraphQL\Tests\Executor\ExecutorTest @@ -1388,6 +1389,7 @@ static function execute( * @param array|null $variableValues * * @phpstan-param FieldResolver|null $fieldResolver + * @phpstan-param ArgsMapper|null $argsMapper * * @api */ @@ -1399,7 +1401,8 @@ static function promiseToExecute( $contextValue = null, ?array $variableValues = null, ?string $operationName = null, - ?callable $fieldResolver = null + ?callable $fieldResolver = null, + ?callable $argsMapper = null ): GraphQL\Executor\Promise\Promise ``` diff --git a/docs/type-definitions/object-types.md b/docs/type-definitions/object-types.md index 4d7c09aad..aa9e3f7c9 100644 --- a/docs/type-definitions/object-types.md +++ b/docs/type-definitions/object-types.md @@ -66,6 +66,7 @@ This example uses **inline** style for Object Type definitions, but you can also | interfaces | `array` or `callable` | List of interfaces implemented by this type or callable returning such a list. See [Interface Types](interfaces.md) for details. See also the section on [Circular types](#recurring-and-circular-types) for an explanation of when to use callable for this option. | | isTypeOf | `callable` | **function ($value, $context, [ResolveInfo](../class-reference.md#graphqltypedefinitionresolveinfo) $info): bool**
Expected to return **true** if **$value** qualifies for this type (see section about [Abstract Type Resolution](interfaces.md#interface-role-in-data-fetching) for explanation). | | resolveField | `callable` | **function ($value, array $args, $context, [ResolveInfo](../class-reference.md#graphqltypedefinitionresolveinfo) $info): mixed**
Given the **$value** of this type, it is expected to return value for a field defined in **$info->fieldName**. A good place to define a type-specific strategy for field resolution. See section on [Data Fetching](../data-fetching.md) for details. | +| argsMapper | `callable` | **function (array $args, FieldDefinition, FieldNode): mixed**
Called once, when Executor resolves arguments for given field. Could be used to validate args and/or to map them to DTO/Object. | | visible | `bool` or `callable` | Defaults to `true`. The given callable receives no arguments and is expected to return a `bool`, it is called once when the field may be accessed. The field is treated as if it were not defined at all when this is `false`. | ### Field configuration options diff --git a/src/Executor/ExecutionContext.php b/src/Executor/ExecutionContext.php index 75f799184..a46de2753 100644 --- a/src/Executor/ExecutionContext.php +++ b/src/Executor/ExecutionContext.php @@ -15,6 +15,7 @@ * and the fragments defined in the query document. * * @phpstan-import-type FieldResolver from Executor + * @phpstan-import-type ArgsMapper from Executor */ class ExecutionContext { @@ -41,6 +42,13 @@ class ExecutionContext */ public $fieldResolver; + /** + * @var callable + * + * @phpstan-var ArgsMapper + */ + public $argsMapper; + /** @var array */ public array $errors; @@ -64,6 +72,7 @@ public function __construct( array $variableValues, array $errors, callable $fieldResolver, + callable $argsMapper, PromiseAdapter $promiseAdapter ) { $this->schema = $schema; @@ -74,6 +83,7 @@ public function __construct( $this->variableValues = $variableValues; $this->errors = $errors; $this->fieldResolver = $fieldResolver; + $this->argsMapper = $argsMapper; $this->promiseAdapter = $promiseAdapter; } diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index 95a66e001..dad4eaf6e 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -7,6 +7,8 @@ use GraphQL\Executor\Promise\Promise; use GraphQL\Executor\Promise\PromiseAdapter; use GraphQL\Language\AST\DocumentNode; +use GraphQL\Language\AST\FieldNode; +use GraphQL\Type\Definition\FieldDefinition; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Schema; use GraphQL\Utils\Utils; @@ -14,8 +16,9 @@ /** * Implements the "Evaluating requests" section of the GraphQL specification. * + * @phpstan-type ArgsMapper callable(array, FieldDefinition, FieldNode, mixed): mixed * @phpstan-type FieldResolver callable(mixed, array, mixed, ResolveInfo): mixed - * @phpstan-type ImplementationFactory callable(PromiseAdapter, Schema, DocumentNode, mixed, mixed, array, ?string, callable): ExecutorImplementation + * @phpstan-type ImplementationFactory callable(PromiseAdapter, Schema, DocumentNode, mixed, mixed, array, ?string, callable, callable): ExecutorImplementation * * @see \GraphQL\Tests\Executor\ExecutorTest */ @@ -28,6 +31,13 @@ class Executor */ private static $defaultFieldResolver = [self::class, 'defaultFieldResolver']; + /** + * @var callable + * + * @phpstan-var ArgsMapper + */ + private static $defaultArgsMapper = [self::class, 'defaultArgsMapper']; + private static ?PromiseAdapter $defaultPromiseAdapter; /** @@ -53,6 +63,12 @@ public static function setDefaultFieldResolver(callable $fieldResolver): void self::$defaultFieldResolver = $fieldResolver; } + /** @phpstan-param ArgsMapper $argsMapper */ + public static function setDefaultArgsMapper(callable $argsMapper): void + { + self::$defaultArgsMapper = $argsMapper; + } + public static function getPromiseAdapter(): PromiseAdapter { return self::$defaultPromiseAdapter ??= new SyncPromiseAdapter(); @@ -132,6 +148,7 @@ public static function execute( * @param array|null $variableValues * * @phpstan-param FieldResolver|null $fieldResolver + * @phpstan-param ArgsMapper|null $argsMapper * * @api */ @@ -143,7 +160,8 @@ public static function promiseToExecute( $contextValue = null, ?array $variableValues = null, ?string $operationName = null, - ?callable $fieldResolver = null + ?callable $fieldResolver = null, + ?callable $argsMapper = null ): Promise { $executor = (self::$implementationFactory)( $promiseAdapter, @@ -153,7 +171,8 @@ public static function promiseToExecute( $contextValue, $variableValues ?? [], $operationName, - $fieldResolver ?? self::$defaultFieldResolver + $fieldResolver ?? self::$defaultFieldResolver, + $argsMapper ?? self::$defaultArgsMapper, ); return $executor->doExecute(); @@ -179,4 +198,16 @@ public static function defaultFieldResolver($objectLikeValue, array $args, $cont ? $property($objectLikeValue, $args, $contextValue, $info) : $property; } + + /** + * @template T of array + * + * @param T $args + * + * @return T + */ + public static function defaultArgsMapper(array $args): array + { + return $args; + } } diff --git a/src/Executor/ReferenceExecutor.php b/src/Executor/ReferenceExecutor.php index 7554f1337..9cda56ead 100644 --- a/src/Executor/ReferenceExecutor.php +++ b/src/Executor/ReferenceExecutor.php @@ -37,6 +37,7 @@ /** * @phpstan-import-type FieldResolver from Executor * @phpstan-import-type Path from ResolveInfo + * @phpstan-import-type ArgsMapper from Executor * * @phpstan-type Fields \ArrayObject> */ @@ -60,6 +61,9 @@ class ReferenceExecutor implements ExecutorImplementation */ protected \SplObjectStorage $subFieldCache; + /** @var \SplObjectStorage> */ + protected \SplObjectStorage $fieldArgsCache; + protected function __construct(ExecutionContext $context) { if (! isset(static::$UNDEFINED)) { @@ -68,6 +72,7 @@ protected function __construct(ExecutionContext $context) $this->exeContext = $context; $this->subFieldCache = new \SplObjectStorage(); + $this->fieldArgsCache = new \SplObjectStorage(); } /** @@ -76,6 +81,7 @@ protected function __construct(ExecutionContext $context) * @param array $variableValues * * @phpstan-param FieldResolver $fieldResolver + * @phpstan-param ArgsMapper $argsMapper * * @throws \Exception */ @@ -87,7 +93,8 @@ public static function create( $contextValue, array $variableValues, ?string $operationName, - callable $fieldResolver + callable $fieldResolver, + callable $argsMapper ): ExecutorImplementation { $exeContext = static::buildExecutionContext( $schema, @@ -97,7 +104,8 @@ public static function create( $variableValues, $operationName, $fieldResolver, - $promiseAdapter + $argsMapper, + $promiseAdapter, ); if (\is_array($exeContext)) { @@ -141,6 +149,7 @@ protected static function buildExecutionContext( array $rawVariableValues, ?string $operationName, callable $fieldResolver, + callable $argsMapper, PromiseAdapter $promiseAdapter ) { /** @var array $errors */ @@ -217,6 +226,7 @@ protected static function buildExecutionContext( $variableValues, $errors, $fieldResolver, + $argsMapper, $promiseAdapter ); } @@ -640,13 +650,14 @@ protected function resolveField( $exeContext->variableValues, $unaliasedPath ); - if ($fieldDef->resolveFn !== null) { - $resolveFn = $fieldDef->resolveFn; - } elseif ($parentType->resolveFieldFn !== null) { - $resolveFn = $parentType->resolveFieldFn; - } else { - $resolveFn = $this->exeContext->fieldResolver; - } + + $resolveFn = $fieldDef->resolveFn + ?? $parentType->resolveFieldFn + ?? $this->exeContext->fieldResolver; + + $argsMapper = $fieldDef->argsMapper + ?? $parentType->argsMapper + ?? $this->exeContext->argsMapper; // Get the resolve function, regardless of if its result is normal // or abrupt (error). @@ -654,6 +665,7 @@ protected function resolveField( $fieldDef, $fieldNode, $resolveFn, + $argsMapper, $rootValue, $info, $contextValue @@ -721,6 +733,7 @@ protected function resolveFieldValueOrError( FieldDefinition $fieldDef, FieldNode $fieldNode, callable $resolveFn, + callable $argsMapper, $rootValue, ResolveInfo $info, $contextValue @@ -728,11 +741,14 @@ protected function resolveFieldValueOrError( try { // Build a map of arguments from the field.arguments AST, using the // variables scope to fulfill any variable references. - $args = Values::getArgumentValues( + /** @phpstan-ignore-next-line ignored because no way to tell phpstan what are generics of SplObjectStorage without assign it to var first */ + $this->fieldArgsCache[$fieldDef] ??= new \SplObjectStorage(); + + $args = $this->fieldArgsCache[$fieldDef][$fieldNode] ??= $argsMapper(Values::getArgumentValues( $fieldDef, $fieldNode, $this->exeContext->variableValues - ); + ), $fieldDef, $fieldNode, $contextValue); return $resolveFn($rootValue, $args, $contextValue, $info); } catch (\Throwable $error) { diff --git a/src/Type/Definition/FieldDefinition.php b/src/Type/Definition/FieldDefinition.php index c36f76275..03496b738 100644 --- a/src/Type/Definition/FieldDefinition.php +++ b/src/Type/Definition/FieldDefinition.php @@ -12,6 +12,7 @@ * @see Executor * * @phpstan-import-type FieldResolver from Executor + * @phpstan-import-type ArgsMapper from Executor * @phpstan-import-type ArgumentListConfig from Argument * * @phpstan-type FieldType (Type&OutputType)|callable(): (Type&OutputType) @@ -22,6 +23,7 @@ * type: FieldType, * resolve?: FieldResolver|null, * args?: ArgumentListConfig|null, + * argsMapper?: ArgsMapper|null, * description?: string|null, * visible?: VisibilityFn|bool, * deprecationReason?: string|null, @@ -32,6 +34,7 @@ * type: FieldType, * resolve?: FieldResolver|null, * args?: ArgumentListConfig|null, + * argsMapper?: ArgsMapper|null, * description?: string|null, * visible?: VisibilityFn|bool, * deprecationReason?: string|null, @@ -56,6 +59,15 @@ class FieldDefinition /** @var array */ public array $args; + /** + * Callback to transform args to value object. + * + * @var callable|null + * + * @phpstan-var ArgsMapper|null + */ + public $argsMapper; + /** * Callback for resolving field value given parent value. * @@ -103,6 +115,7 @@ public function __construct(array $config) $this->args = isset($config['args']) ? Argument::listFromConfig($config['args']) : []; + $this->argsMapper = $config['argsMapper'] ?? null; $this->description = $config['description'] ?? null; $this->visible = $config['visible'] ?? true; $this->deprecationReason = $config['deprecationReason'] ?? null; diff --git a/src/Type/Definition/ObjectType.php b/src/Type/Definition/ObjectType.php index dc4f3ee4e..f69cbfa30 100644 --- a/src/Type/Definition/ObjectType.php +++ b/src/Type/Definition/ObjectType.php @@ -50,12 +50,14 @@ * ]); * * @phpstan-import-type FieldResolver from Executor + * @phpstan-import-type ArgsMapper from Executor * * @phpstan-type InterfaceTypeReference InterfaceType|callable(): InterfaceType * @phpstan-type ObjectConfig array{ * name?: string|null, * description?: string|null, * resolveField?: FieldResolver|null, + * argsMapper?: ArgsMapper|null, * fields: (callable(): iterable)|iterable, * interfaces?: iterable|callable(): iterable, * isTypeOf?: (callable(mixed $objectValue, mixed $context, ResolveInfo $resolveInfo): (bool|Deferred|null))|null, @@ -81,6 +83,13 @@ class ObjectType extends Type implements OutputType, CompositeType, NullableType */ public $resolveFieldFn; + /** + * @var callable|null + * + * @phpstan-var ArgsMapper|null + */ + public $argsMapper; + /** @phpstan-var ObjectConfig */ public array $config; @@ -94,6 +103,7 @@ public function __construct(array $config) $this->name = $config['name'] ?? $this->inferName(); $this->description = $config['description'] ?? null; $this->resolveFieldFn = $config['resolveField'] ?? null; + $this->argsMapper = $config['argsMapper'] ?? null; $this->astNode = $config['astNode'] ?? null; $this->extensionASTNodes = $config['extensionASTNodes'] ?? []; diff --git a/src/Utils/SchemaExtender.php b/src/Utils/SchemaExtender.php index a766d2e47..9a3da7ed1 100644 --- a/src/Utils/SchemaExtender.php +++ b/src/Utils/SchemaExtender.php @@ -505,6 +505,7 @@ protected function extendFieldMap(Type $type): array 'type' => $this->extendType($field->getType()), 'args' => $this->extendArgs($field->args), 'resolve' => $field->resolveFn, + 'argsMapper' => $field->argsMapper, 'astNode' => $field->astNode, ]; } @@ -537,7 +538,8 @@ protected function extendObjectType(ObjectType $type): ObjectType 'interfaces' => fn (): array => $this->extendImplementedInterfaces($type), 'fields' => fn (): array => $this->extendFieldMap($type), 'isTypeOf' => [$type, 'isTypeOf'], - 'resolveField' => $type->resolveFieldFn ?? null, + 'resolveField' => $type->resolveFieldFn, + 'argsMapper' => $type->argsMapper, 'astNode' => $type->astNode, 'extensionASTNodes' => $extensionASTNodes, ]); diff --git a/tests/Executor/ExecutorTest.php b/tests/Executor/ExecutorTest.php index f70412e3a..4d3e2c890 100644 --- a/tests/Executor/ExecutorTest.php +++ b/tests/Executor/ExecutorTest.php @@ -330,6 +330,79 @@ public function testCorrectlyThreadsArguments(): void self::assertSame($gotHere, true); } + public function testArgsMapper(): void + { + $doc = ' + { + b { + testMapper(numArg: 123, stringArg: "foo") + } + } + '; + + $mapperCalledCount = 0; + $resolverCalledCount = 0; + $lastArgs = null; + + $docAst = Parser::parse($doc); + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Type', + 'fields' => [ + 'b' => [ + 'type' => Type::listOf( + new ObjectType([ + 'name' => 'ArgsMapperObject', + 'fields' => [ + 'testMapper' => [ + 'type' => Type::string(), + 'args' => [ + 'numArg' => ['type' => Type::int()], + 'stringArg' => ['type' => Type::string()], + ], + 'argsMapper' => static function (array $args) use ( + &$mapperCalledCount + ): object { + ++$mapperCalledCount; + + $stdClass = new \stdClass(); + foreach ($args as $name => $value) { + $stdClass->$name = $value; + } + + return $stdClass; + }, + 'resolve' => static function ($_, \stdClass $args) use ( + &$lastArgs, + &$resolverCalledCount + ): string { + ++$resolverCalledCount; + + if ($lastArgs !== null && $lastArgs !== $args) { + throw new \LogicException('Should receive same args'); + } + + $lastArgs = $args; + self::assertSame(123, $args->numArg); + self::assertSame('foo', $args->stringArg); + + return 'OK'; + }, + ], + ], + ]) + ), + 'resolve' => static fn (): array => [new \stdClass(), new \stdClass(), new \stdClass()], + ], + ], + ]), + ]); + $result = Executor::execute($schema, $docAst); + self::assertEquals(1, $mapperCalledCount); + self::assertEquals(3, $resolverCalledCount); + self::assertCount(0, $result->errors); + } + /** @see it('nulls out error subtrees') */ public function testNullsOutErrorSubtrees(): void { diff --git a/tests/Executor/ResolveTest.php b/tests/Executor/ResolveTest.php index 7ab833eff..b36fcd910 100644 --- a/tests/Executor/ResolveTest.php +++ b/tests/Executor/ResolveTest.php @@ -33,7 +33,7 @@ public function testDefaultFunctionAccessesProperties(): void } /** - * @param UnnamedFieldDefinitionConfig $testField + * @phpstan-param UnnamedFieldDefinitionConfig $testField * * @throws InvariantViolation */