diff --git a/CHANGELOG.md b/CHANGELOG.md index 597124776..1860104d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ You can find and compare releases at the [GitHub release page](https://github.co ## Unreleased +### Added + +- Add interface `ArrayAccessPropertyFallback` to let the default field resolver read properties of `\ArrayAccess` objects https://github.com/webonyx/graphql-php/pull/1960 + ## v15.37.2 ### Changed diff --git a/docs/class-reference.md b/docs/class-reference.md index 81cd48e81..92b818564 100644 --- a/docs/class-reference.md +++ b/docs/class-reference.md @@ -1704,6 +1704,17 @@ When the object passed as `$contextValue` to GraphQL execution implements this, its `clone()` method will be called before passing the context down to a field. This allows passing information to child fields in the query tree without affecting sibling or parent fields. +## GraphQL\Executor\ArrayAccessPropertyFallback + +When a value implementing this is resolved by the default field resolver, +a key that is not set by array access is read from the property of the same name. +Plain `\ArrayAccess` values only use array access, so their properties stay hidden. + +@template TKey +@template TValue + +@extends \ArrayAccess + ## GraphQL\Executor\ExecutionResult Returned after [query execution](executing-queries.md). diff --git a/docs/data-fetching.md b/docs/data-fetching.md index 13fad0136..04293dcf5 100644 --- a/docs/data-fetching.md +++ b/docs/data-fetching.md @@ -119,6 +119,9 @@ function defaultFieldResolver($objectValue, array $args, $context, ResolveInfo $ It returns value by key (for arrays) or property (for objects). If the value is not set, it returns **null**. +Objects that implement `ArrayAccess` are read by key only, so their properties stay hidden. +Implement `GraphQL\Executor\ArrayAccessPropertyFallback` instead of `ArrayAccess` to fall back to the property when the key is not set. + To override the default resolver, pass it as an argument to [executeQuery](executing-queries.md). ## Default Field Resolver per Type diff --git a/generate-class-reference.php b/generate-class-reference.php index 841ee88b4..9f5b1bd3c 100644 --- a/generate-class-reference.php +++ b/generate-class-reference.php @@ -21,6 +21,7 @@ GraphQL\Language\AST\NodeKind::class => ['constants' => true], GraphQL\Executor\Executor::class => [], GraphQL\Executor\ScopedContext::class => [], + GraphQL\Executor\ArrayAccessPropertyFallback::class => [], GraphQL\Executor\ExecutionResult::class => [], GraphQL\Executor\Promise\PromiseAdapter::class => [], GraphQL\Deferred::class => [], diff --git a/src/Executor/ArrayAccessPropertyFallback.php b/src/Executor/ArrayAccessPropertyFallback.php new file mode 100644 index 000000000..38ad7bc2c --- /dev/null +++ b/src/Executor/ArrayAccessPropertyFallback.php @@ -0,0 +1,15 @@ + + */ +interface ArrayAccessPropertyFallback extends \ArrayAccess {} diff --git a/src/Utils/Utils.php b/src/Utils/Utils.php index 73b7a9f04..b3253906d 100644 --- a/src/Utils/Utils.php +++ b/src/Utils/Utils.php @@ -4,6 +4,7 @@ use GraphQL\Error\Error; use GraphQL\Error\Warning; +use GraphQL\Executor\ArrayAccessPropertyFallback; use GraphQL\Language\AST\Node; class Utils @@ -268,6 +269,12 @@ public static function suggestionList(string $input, array $options): array */ public static function extractKey($objectLikeValue, string $key) { + if ($objectLikeValue instanceof ArrayAccessPropertyFallback) { + return $objectLikeValue[$key] + ?? $objectLikeValue->{$key} // @phpstan-ignore-line Variable property access is what the implementor opted into + ?? null; + } + if (is_array($objectLikeValue) || $objectLikeValue instanceof \ArrayAccess) { return $objectLikeValue[$key] ?? null; } diff --git a/tests/Executor/ArrayAccessPropertyFallbackTest.php b/tests/Executor/ArrayAccessPropertyFallbackTest.php new file mode 100644 index 000000000..6408b52fb --- /dev/null +++ b/tests/Executor/ArrayAccessPropertyFallbackTest.php @@ -0,0 +1,195 @@ + 'attribute', 'shadowed' => 'attribute', 'nullAttribute' => null]) extends ArrayAccessAttributes implements ArrayAccessPropertyFallback { + public string $shadowed = 'property'; + + public string $property = 'property'; + + public string $nullAttribute = 'property'; + }; + + self::assertSame( + [ + 'data' => [ + 'attribute' => 'attribute', + 'shadowed' => 'attribute', + 'property' => 'property', + 'nullAttribute' => 'property', + 'missing' => null, + ], + ], + self::executeFields(['attribute', 'shadowed', 'property', 'nullAttribute', 'missing'], $rootValue) + ); + } + + public function testHidesPropertiesOfEloquentLikeModel(): void + { + $rootValue = new class(['name' => null]) extends ArrayAccessAttributes { + public bool $exists = true; + + public bool $wasRecentlyCreated = false; + + public string $name = 'property'; + }; + + self::assertSame( + [ + 'data' => [ + 'exists' => null, + 'wasRecentlyCreated' => null, + 'name' => null, + ], + ], + self::executeFields(['exists', 'wasRecentlyCreated', 'name'], $rootValue) + ); + } + + public function testDoesNotCallMagicGetOfCollectionLikeValue(): void + { + $rootValue = new class(['present' => 1]) extends ArrayAccessAttributes { + /** + * @throws \Exception + * + * @return never + */ + public function __get(string $key) + { + throw new \Exception("Property [{$key}] does not exist on this collection instance."); + } + }; + + self::assertSame( + [ + 'data' => [ + 'present' => '1', + 'missing' => null, + ], + ], + self::executeFields(['present', 'missing'], $rootValue) + ); + } + + public function testDoesNotForwardToFirstItemOfFieldItemListLikeValue(): void + { + $rootValue = new class([]) extends ArrayAccessAttributes { + public function __isset(string $name): bool + { + return true; + } + + public function __get(string $name): string + { + return 'referenced entity'; + } + }; + + self::assertSame( + ['data' => ['entity' => null]], + self::executeFields(['entity'], $rootValue) + ); + } + + public function testIgnoresTypenamePropertyOfArrayAccess(): void + { + $pet = new class([]) extends ArrayAccessAttributes { + public string $__typename = 'Cat'; + }; + + self::assertSame( + ['data' => ['pet' => ['__typename' => 'Dog']]], + self::executePet($pet) + ); + } + + public function testReadsTypenamePropertyOfOptedInArrayAccess(): void + { + $pet = new class([]) extends ArrayAccessAttributes implements ArrayAccessPropertyFallback { + public string $__typename = 'Cat'; + }; + + self::assertSame( + ['data' => ['pet' => ['__typename' => 'Cat']]], + self::executePet($pet) + ); + } + + /** + * @param list $fieldNames + * @param \ArrayAccess $rootValue + * + * @throws \Exception + * @throws InvariantViolation + * + * @return array + */ + private static function executeFields(array $fieldNames, \ArrayAccess $rootValue): array + { + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => array_fill_keys($fieldNames, Type::string()), + ]), + ]); + + $query = '{ ' . implode(' ', $fieldNames) . ' }'; + + return GraphQL::executeQuery($schema, $query, $rootValue)->toArray(); + } + + /** + * @param \ArrayAccess $pet + * + * @throws \Exception + * @throws InvariantViolation + * + * @return array + */ + private static function executePet(\ArrayAccess $pet): array + { + $dog = new ObjectType([ + 'name' => 'Dog', + 'fields' => ['name' => Type::string()], + 'isTypeOf' => static fn (): bool => true, + ]); + $cat = new ObjectType([ + 'name' => 'Cat', + 'fields' => ['name' => Type::string()], + ]); + + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'pet' => [ + 'type' => new UnionType([ + 'name' => 'Pet', + 'types' => [$dog, $cat], + ]), + 'resolve' => static fn (): \ArrayAccess => $pet, + ], + ], + ]), + ]); + + return GraphQL::executeQuery($schema, '{ pet { __typename } }')->toArray(); + } +} diff --git a/tests/Executor/TestClasses/ArrayAccessAttributes.php b/tests/Executor/TestClasses/ArrayAccessAttributes.php new file mode 100644 index 000000000..ec2653508 --- /dev/null +++ b/tests/Executor/TestClasses/ArrayAccessAttributes.php @@ -0,0 +1,55 @@ + + */ +class ArrayAccessAttributes implements \ArrayAccess +{ + /** @var array */ + private array $attributes; + + /** @param array $attributes */ + public function __construct(array $attributes) + { + $this->attributes = $attributes; + } + + /** @param mixed $offset */ + #[\ReturnTypeWillChange] + public function offsetExists($offset): bool + { + return isset($this->attributes[$offset]); + } + + /** + * @param mixed $offset + * + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->attributes[$offset] ?? null; + } + + /** + * @param mixed $offset + * @param mixed $value + */ + #[\ReturnTypeWillChange] + public function offsetSet($offset, $value): void + { + $this->attributes[$offset] = $value; + } + + /** @param mixed $offset */ + #[\ReturnTypeWillChange] + public function offsetUnset($offset): void + { + unset($this->attributes[$offset]); + } +}