Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions docs/class-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TKey, TValue>

## GraphQL\Executor\ExecutionResult

Returned after [query execution](executing-queries.md).
Expand Down
3 changes: 3 additions & 0 deletions docs/data-fetching.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions generate-class-reference.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 => [],
Expand Down
15 changes: 15 additions & 0 deletions src/Executor/ArrayAccessPropertyFallback.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php declare(strict_types=1);

namespace GraphQL\Executor;

/**
* 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<TKey, TValue>
*/
interface ArrayAccessPropertyFallback extends \ArrayAccess {}
7 changes: 7 additions & 0 deletions src/Utils/Utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use GraphQL\Error\Error;
use GraphQL\Error\Warning;
use GraphQL\Executor\ArrayAccessPropertyFallback;
use GraphQL\Language\AST\Node;

class Utils
Expand Down Expand Up @@ -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;
}
Expand Down
195 changes: 195 additions & 0 deletions tests/Executor/ArrayAccessPropertyFallbackTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
<?php declare(strict_types=1);

namespace GraphQL\Tests\Executor;

use GraphQL\Error\InvariantViolation;
use GraphQL\Executor\ArrayAccessPropertyFallback;
use GraphQL\GraphQL;
use GraphQL\Tests\Executor\TestClasses\ArrayAccessAttributes;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Definition\UnionType;
use GraphQL\Type\Schema;
use PHPUnit\Framework\TestCase;

/**
* @see https://github.com/webonyx/graphql-php/pull/1960#issuecomment-5865658411
*/
final class ArrayAccessPropertyFallbackTest extends TestCase
{
public function testReadsPropertiesOfOptedInArrayAccess(): void
{
$rootValue = new class(['attribute' => '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<string> $fieldNames
* @param \ArrayAccess<string, mixed> $rootValue
*
* @throws \Exception
* @throws InvariantViolation
*
* @return array<string, mixed>
*/
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<string, mixed> $pet
*
* @throws \Exception
* @throws InvariantViolation
*
* @return array<string, mixed>
*/
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();
}
}
55 changes: 55 additions & 0 deletions tests/Executor/TestClasses/ArrayAccessAttributes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php declare(strict_types=1);

namespace GraphQL\Tests\Executor\TestClasses;

/**
* Exposes attributes through array access, like Eloquent models or Laravel collections.
*
* @phpstan-implements \ArrayAccess<string, mixed>
*/
class ArrayAccessAttributes implements \ArrayAccess
{
/** @var array<string, mixed> */
private array $attributes;

/** @param array<string, mixed> $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]);
}
}
Loading