diff --git a/src/Executor/PromiseExecutor.php b/src/Executor/PromiseExecutor.php new file mode 100644 index 000000000..7f76309b8 --- /dev/null +++ b/src/Executor/PromiseExecutor.php @@ -0,0 +1,20 @@ +result = $result; + } + + public function doExecute(): Promise + { + return $this->result; + } +} diff --git a/src/Executor/ReferenceExecutor.php b/src/Executor/ReferenceExecutor.php index c06256d70..93ba8ce80 100644 --- a/src/Executor/ReferenceExecutor.php +++ b/src/Executor/ReferenceExecutor.php @@ -120,19 +120,10 @@ public static function create( ); if (is_array($exeContext)) { - return new class($promiseAdapter->createFulfilled(new ExecutionResult(null, $exeContext))) implements ExecutorImplementation { - private Promise $result; + $executionResult = new ExecutionResult(null, $exeContext); + $fulfilledPromise = $promiseAdapter->createFulfilled($executionResult); - public function __construct(Promise $result) - { - $this->result = $result; - } - - public function doExecute(): Promise - { - return $this->result; - } - }; + return new PromiseExecutor($fulfilledPromise); } return new static($exeContext); @@ -1073,7 +1064,7 @@ protected function completeLeafValue(LeafType $returnType, &$result) * @param \ArrayObject $fieldNodes * @param list $path * @param list $unaliasedPath - * @param array $result + * @param mixed $result * @param mixed $contextValue * * @throws \Exception @@ -1092,6 +1083,7 @@ protected function completeAbstractValue( $contextValue ) { $typeCandidate = $returnType->resolveType($result, $contextValue, $info); + $result = $returnType->resolveValue($result, $contextValue, $info); if ($typeCandidate === null) { $runtimeType = static::defaultTypeResolver($result, $contextValue, $info, $returnType); @@ -1272,7 +1264,7 @@ protected function completeObjectValue( /** * @param \ArrayObject $fieldNodes - * @param array $result + * @param mixed $result */ protected function invalidReturnTypeError( ObjectType $returnType, @@ -1408,7 +1400,7 @@ protected function executeFields(ObjectType $parentType, $rootValue, array $path * * @param array|mixed $results * - * @return array|\stdClass|mixed + * @return non-empty-array|\stdClass|mixed */ protected static function fixResultsIfEmptyArray($results) { diff --git a/src/Type/Definition/AbstractType.php b/src/Type/Definition/AbstractType.php index c646b8805..5da106fe6 100644 --- a/src/Type/Definition/AbstractType.php +++ b/src/Type/Definition/AbstractType.php @@ -7,6 +7,7 @@ /** * @phpstan-type ResolveTypeReturn ObjectType|string|callable(): (ObjectType|string|null)|Deferred|null * @phpstan-type ResolveType callable(mixed $objectValue, mixed $context, ResolveInfo $resolveInfo): ResolveTypeReturn + * @phpstan-type ResolveValue callable(mixed $objectValue, mixed $context, ResolveInfo $resolveInfo): mixed */ interface AbstractType { @@ -21,4 +22,14 @@ interface AbstractType * @phpstan-return ResolveTypeReturn */ public function resolveType($objectValue, $context, ResolveInfo $info); + + /** + * Receives the original resolved value and transforms it if necessary. + * + * @param mixed $objectValue The resolved value for the object type + * @param mixed $context The context that was passed to GraphQL::execute() + * + * @return mixed The possibly transformed value + */ + public function resolveValue($objectValue, $context, ResolveInfo $info); } diff --git a/src/Type/Definition/InterfaceType.php b/src/Type/Definition/InterfaceType.php index d15342c31..601493156 100644 --- a/src/Type/Definition/InterfaceType.php +++ b/src/Type/Definition/InterfaceType.php @@ -10,6 +10,7 @@ /** * @phpstan-import-type ResolveType from AbstractType + * @phpstan-import-type ResolveValue from AbstractType * @phpstan-import-type FieldsConfig from FieldDefinition * * @phpstan-type InterfaceTypeReference InterfaceType|callable(): InterfaceType @@ -19,6 +20,7 @@ * fields: FieldsConfig, * interfaces?: iterable|callable(): iterable, * resolveType?: ResolveType|null, + * resolveValue?: ResolveValue|null, * astNode?: InterfaceTypeDefinitionNode|null, * extensionASTNodes?: array|null * } @@ -76,6 +78,15 @@ public function resolveType($objectValue, $context, ResolveInfo $info) return null; } + public function resolveValue($objectValue, $context, ResolveInfo $info) + { + if (isset($this->config['resolveValue'])) { + return ($this->config['resolveValue'])($objectValue, $context, $info); + } + + return $objectValue; + } + /** * @throws Error * @throws InvariantViolation diff --git a/src/Type/Definition/UnionType.php b/src/Type/Definition/UnionType.php index 4a470b36b..2d1e40f4d 100644 --- a/src/Type/Definition/UnionType.php +++ b/src/Type/Definition/UnionType.php @@ -10,6 +10,7 @@ /** * @phpstan-import-type ResolveType from AbstractType + * @phpstan-import-type ResolveValue from AbstractType * * @phpstan-type ObjectTypeReference ObjectType|callable(): ObjectType * @phpstan-type UnionConfig array{ @@ -17,6 +18,7 @@ * description?: string|null, * types: iterable|callable(): iterable, * resolveType?: ResolveType|null, + * resolveValue?: ResolveValue|null, * astNode?: UnionTypeDefinitionNode|null, * extensionASTNodes?: array|null * } @@ -115,6 +117,15 @@ public function resolveType($objectValue, $context, ResolveInfo $info) return null; } + public function resolveValue($objectValue, $context, ResolveInfo $info) + { + if (isset($this->config['resolveValue'])) { + return ($this->config['resolveValue'])($objectValue, $context, $info); + } + + return $objectValue; + } + public function assertValid(): void { Utils::assertValidName($this->name); diff --git a/tests/Executor/AbstractTest.php b/tests/Executor/AbstractTest.php index 0280cb811..6ad655ae7 100644 --- a/tests/Executor/AbstractTest.php +++ b/tests/Executor/AbstractTest.php @@ -13,6 +13,7 @@ use GraphQL\Tests\Executor\TestClasses\Cat; use GraphQL\Tests\Executor\TestClasses\Dog; use GraphQL\Tests\Executor\TestClasses\Human; +use GraphQL\Tests\Executor\TestClasses\PetEntity; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type; @@ -542,29 +543,35 @@ interface Pet { public function testReturningInvalidValueFromResolveTypeYieldsUsefulError(): void { // @phpstan-ignore-next-line intentionally wrong - $fooInterface = new InterfaceType([ + $FooInterfaceType = new InterfaceType([ 'name' => 'FooInterface', - 'fields' => ['bar' => ['type' => Type::string()]], + 'fields' => [ + 'bar' => Type::string(), + ], 'resolveType' => static fn (): array => [], ]); - $fooObject = new ObjectType([ + $FooObjectType = new ObjectType([ 'name' => 'FooObject', - 'fields' => ['bar' => ['type' => Type::string()]], - 'interfaces' => [$fooInterface], + 'fields' => [ + 'bar' => Type::string(), + ], + 'interfaces' => [$FooInterfaceType], ]); - $schema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'Query', - 'fields' => [ - 'foo' => [ - 'type' => $fooInterface, - 'resolve' => static fn (): string => 'dummy', - ], + $QueryType = new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'foo' => [ + 'type' => $FooInterfaceType, + 'resolve' => static fn (): string => 'dummy', ], - ]), - 'types' => [$fooObject], + ], + ]); + + $schema = new Schema([ + 'query' => $QueryType, + 'types' => [$FooObjectType], ]); $result = GraphQL::executeQuery($schema, '{ foo { bar } }'); @@ -574,7 +581,9 @@ public function testReturningInvalidValueFromResolveTypeYieldsUsefulError(): voi 'errors' => [ [ 'message' => 'Internal server error', - 'locations' => [['line' => 1, 'column' => 3]], + 'locations' => [ + ['line' => 1, 'column' => 3], + ], 'path' => ['foo'], 'extensions' => [ 'debugMessage' => 'Abstract type FooInterface must resolve to an Object type at ' @@ -590,45 +599,47 @@ public function testReturningInvalidValueFromResolveTypeYieldsUsefulError(): voi public function testWarnsAboutOrphanedTypesWhenMissingType(): void { - $fooObject = null; - $fooInterface = new InterfaceType([ + $FooObjectType = null; + $FooInterfaceType = new InterfaceType([ 'name' => 'FooInterface', 'fields' => [ - 'bar' => [ - 'type' => Type::string(), - ], + 'bar' => Type::string(), ], - 'resolveType' => static function () use (&$fooObject): ?ObjectType { - return $fooObject; + 'resolveType' => static function () use (&$FooObjectType): ?ObjectType { + return $FooObjectType; }, ]); - $fooObject = new ObjectType([ + $FooObjectType = new ObjectType([ 'name' => 'FooObject', 'fields' => [ - 'bar' => [ - 'type' => Type::string(), - ], + 'bar' => Type::string(), ], - 'interfaces' => [$fooInterface], + 'interfaces' => [$FooInterfaceType], ]); - $schema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'Query', - 'fields' => [ - 'foo' => [ - 'type' => $fooInterface, - 'resolve' => static fn (): array => ['bar' => 'baz'], + $QueryType = new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'foo' => [ + 'type' => $FooInterfaceType, + 'resolve' => static fn (): array => [ + 'bar' => 'baz', ], ], - ]), + ], + ]); + + $schema = new Schema([ + 'query' => $QueryType, ]); $result = GraphQL::executeQuery($schema, '{ foo { bar } }'); - $error = $result->errors[0] ?? null; - self::assertInstanceOf(Error::class, $error); + $errors = $result->errors; + self::assertCount(1, $errors); + + [$error] = $errors; self::assertStringContainsString( 'Schema does not contain type "FooObject". This can happen when an object type is only referenced indirectly through abstract types and never directly through fields.List the type in the option "types" during schema construction, see https://webonyx.github.io/graphql-php/schema-definition/#configuration-options.', $error->getMessage(), @@ -651,7 +662,7 @@ public function testResolveTypeAllowsResolvingWithTypeName(): void return null; }, 'fields' => [ - 'name' => ['type' => Type::string()], + 'name' => Type::string(), ], ]); @@ -659,8 +670,8 @@ public function testResolveTypeAllowsResolvingWithTypeName(): void 'name' => 'Dog', 'interfaces' => [$PetType], 'fields' => [ - 'name' => ['type' => Type::string()], - 'woofs' => ['type' => Type::boolean()], + 'name' => Type::string(), + 'woofs' => Type::boolean(), ], ]); @@ -668,28 +679,31 @@ public function testResolveTypeAllowsResolvingWithTypeName(): void 'name' => 'Cat', 'interfaces' => [$PetType], 'fields' => [ - 'name' => ['type' => Type::string()], - 'meows' => ['type' => Type::boolean()], + 'name' => Type::string(), + 'meows' => Type::boolean(), ], ]); - $schema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'Query', - 'fields' => [ - 'pets' => [ - 'type' => Type::listOf($PetType), - 'resolve' => static fn (): array => [ - new Dog('Odie', true), - new Cat('Garfield', false), - ], + $QueryType = new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'pets' => [ + 'type' => Type::listOf($PetType), + 'resolve' => static fn (): array => [ + new Dog('Odie', true), + new Cat('Garfield', false), ], ], - ]), + ], + ]); + + $schema = new Schema([ + 'query' => $QueryType, 'types' => [$CatType, $DogType], ]); - $query = '{ + $query = ' + { pets { name ... on Dog { @@ -699,43 +713,41 @@ public function testResolveTypeAllowsResolvingWithTypeName(): void meows } } - }'; + } + '; - $result = GraphQL::executeQuery($schema, $query)->toArray(); + $result = GraphQL::executeQuery($schema, $query); - self::assertEquals( - [ - 'data' => [ - 'pets' => [ - ['name' => 'Odie', 'woofs' => true], - ['name' => 'Garfield', 'meows' => false], - ], + self::assertSame([ + 'data' => [ + 'pets' => [ + ['name' => 'Odie', 'woofs' => true], + ['name' => 'Garfield', 'meows' => false], ], ], - $result - ); + ], $result->toArray()); } public function testHintsOnConflictingTypeInstancesInResolveType(): void { - /** @var InterfaceType|null $iface */ - $iface = null; + /** @var InterfaceType|null $NodeType */ + $NodeType = null; - $createTest = function () use (&$iface): ObjectType { + $createTest = function () use (&$NodeType): ObjectType { return new ObjectType([ 'name' => 'Test', 'fields' => [ 'a' => Type::string(), ], - 'interfaces' => function () use ($iface): array { - self::assertNotNull($iface); + 'interfaces' => function () use ($NodeType): array { + self::assertNotNull($NodeType); - return [$iface]; + return [$NodeType]; }, ]); }; - $iface = new InterfaceType([ + $NodeType = new InterfaceType([ 'name' => 'Node', 'fields' => [ 'a' => Type::string(), @@ -743,34 +755,222 @@ public function testHintsOnConflictingTypeInstancesInResolveType(): void 'resolveType' => $createTest, ]); - $query = new ObjectType([ + $QueryType = new ObjectType([ 'name' => 'Query', 'fields' => [ - 'node' => $iface, + 'node' => $NodeType, 'test' => $createTest(), ], ]); $schema = new Schema([ - 'query' => $query, + 'query' => $QueryType, ]); $schema->assertValid(); $query = ' - { - node { - a - } + { + node { + a } + } '; $result = Executor::execute($schema, Parser::parse($query), ['node' => ['a' => 'value']]); - $error = $result->errors[0] ?? null; - self::assertInstanceOf(Error::class, $error); + $errors = $result->errors; + self::assertCount(1, $errors); + + [$error] = $errors; self::assertStringContainsString( 'Schema must contain unique named types but contains multiple types named "Test". Make sure that `resolveType` function of abstract type "Node" returns the same type instance as referenced anywhere else within the schema (see https://webonyx.github.io/graphql-php/type-definitions/#type-registry).', $error->getMessage() ); } + + public function testResolveValueAllowsModifyingObjectValueForInterfaceType(): void + { + $PetType = new InterfaceType([ + 'name' => 'Pet', + 'resolveType' => static function (PetEntity $objectValue): string { + if ($objectValue->type === 'dog') { + return 'Dog'; + } + + return 'Cat'; + }, + 'resolveValue' => static function (PetEntity $objectValue): object { + if ($objectValue->type === 'dog') { + return new Dog($objectValue->name, $objectValue->vocalizes); + } + + return new Cat($objectValue->name, $objectValue->vocalizes); + }, + 'fields' => [ + 'name' => Type::string(), + ], + ]); + + $DogType = new ObjectType([ + 'name' => 'Dog', + 'interfaces' => [$PetType], + 'fields' => [ + 'name' => [ + 'type' => Type::string(), + 'resolve' => fn (Dog $dog): string => $dog->name, + ], + 'woofs' => [ + 'type' => Type::boolean(), + 'resolve' => fn (Dog $dog): bool => $dog->woofs, + ], + ], + ]); + + $CatType = new ObjectType([ + 'name' => 'Cat', + 'interfaces' => [$PetType], + 'fields' => [ + 'name' => [ + 'type' => Type::string(), + 'resolve' => fn (Cat $cat): string => $cat->name, + ], + 'meows' => [ + 'type' => Type::boolean(), + 'resolve' => fn (Cat $cat): bool => $cat->meows, + ], + ], + ]); + + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'pets' => [ + 'type' => Type::listOf($PetType), + 'resolve' => static fn (): array => [ + new PetEntity('dog', 'Odie', true), + new PetEntity('cat', 'Garfield', false), + ], + ], + ], + ]), + 'types' => [$CatType, $DogType], + ]); + + $query = ' + { + pets { + name + ... on Dog { + woofs + } + ... on Cat { + meows + } + } + } + '; + + $result = GraphQL::executeQuery($schema, $query); + + self::assertSame([ + 'data' => [ + 'pets' => [ + ['name' => 'Odie', 'woofs' => true], + ['name' => 'Garfield', 'meows' => false], + ], + ], + ], $result->toArray()); + } + + public function testResolveValueAllowsModifyingObjectValueForUnionType(): void + { + $DogType = new ObjectType([ + 'name' => 'Dog', + 'fields' => [ + 'name' => [ + 'type' => Type::string(), + 'resolve' => fn (Dog $dog): string => $dog->name, + ], + 'woofs' => [ + 'type' => Type::boolean(), + 'resolve' => fn (Dog $dog): bool => $dog->woofs, + ], + ], + ]); + + $CatType = new ObjectType([ + 'name' => 'Cat', + 'fields' => [ + 'name' => [ + 'type' => Type::string(), + 'resolve' => fn (Cat $cat): string => $cat->name, + ], + 'meows' => [ + 'type' => Type::boolean(), + 'resolve' => fn (Cat $cat): bool => $cat->meows, + ], + ], + ]); + + $PetType = new UnionType([ + 'name' => 'Pet', + 'types' => [$DogType, $CatType], + 'resolveType' => static function (PetEntity $objectValue): string { + if ($objectValue->type === 'dog') { + return 'Dog'; + } + + return 'Cat'; + }, + 'resolveValue' => static function (PetEntity $objectValue): object { + if ($objectValue->type === 'dog') { + return new Dog($objectValue->name, $objectValue->vocalizes); + } + + return new Cat($objectValue->name, $objectValue->vocalizes); + }, + ]); + + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'pets' => [ + 'type' => Type::listOf($PetType), + 'resolve' => static fn (): array => [ + new PetEntity('dog', 'Odie', true), + new PetEntity('cat', 'Garfield', false), + ], + ], + ], + ]), + ]); + + $query = ' + { + pets { + ... on Dog { + name + woofs + } + ... on Cat { + name + meows + } + } + } + '; + + $result = GraphQL::executeQuery($schema, $query); + + self::assertSame([ + 'data' => [ + 'pets' => [ + ['name' => 'Odie', 'woofs' => true], + ['name' => 'Garfield', 'meows' => false], + ], + ], + ], $result->toArray()); + } } diff --git a/tests/Executor/TestClasses/PetEntity.php b/tests/Executor/TestClasses/PetEntity.php new file mode 100644 index 000000000..9cb6faceb --- /dev/null +++ b/tests/Executor/TestClasses/PetEntity.php @@ -0,0 +1,21 @@ +type = $type; + $this->name = $name; + $this->vocalizes = $vocalizes; + } +}