Skip to content

Commit 53dd2b4

Browse files
authored
Merge pull request #1048 from sparklink-pro/enum_php
PHP 8.1 Enum support
2 parents 17149a0 + dacefa6 commit 53dd2b4

File tree

18 files changed

+546
-11
lines changed

18 files changed

+546
-11
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ jobs:
3131
symfony-version: '5.4.*'
3232
dependencies: 'lowest'
3333
remove-dependencies: '--dev symfony/validator doctrine/orm doctrine/annotations'
34-
- php-version: '8.0'
34+
- php-version: '8.1'
3535
symfony-version: '5.4.*'
3636
dependencies: 'lowest'
3737
coverage: "pcov"
@@ -86,7 +86,7 @@ jobs:
8686
uses: "shivammathur/setup-php@v2"
8787
with:
8888
tools: flex
89-
php-version: "8.0"
89+
php-version: "8.1"
9090

9191
- name: "Install dependencies"
9292
uses: ramsey/composer-install@v2
@@ -101,7 +101,6 @@ jobs:
101101
fail-fast: false
102102
matrix:
103103
php-version:
104-
- '8.0'
105104
- '8.1'
106105
steps:
107106
- name: "Checkout"

docs/definitions/type-system/enum.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,57 @@ class Episode
5757
public $value;
5858
}
5959
```
60+
61+
## Working with native PHP 8.1 enums
62+
63+
Given a PHP enum as follow:
64+
65+
```php
66+
namespace App;
67+
68+
enum Color
69+
{
70+
case RED;
71+
case GREEN;
72+
case BLUE;
73+
}
74+
```
75+
76+
You can declare it with annotations or attributes as follow:
77+
78+
```php
79+
#[GQL\Enum]
80+
enum Color
81+
{
82+
#[GQL\Description("The color red")]
83+
case RED;
84+
case GREEN;
85+
case BLUE;
86+
}
87+
```
88+
89+
or with YAML:
90+
91+
```yaml
92+
Color:
93+
type: enum
94+
config:
95+
enumClass: App\Color
96+
```
97+
98+
The possible values will be automatically extracted from the enum but if you need to add description and/or deprecated, you can proceed this way
99+
100+
```yaml
101+
Color:
102+
type: enum
103+
config:
104+
enumClass: App\Color
105+
values:
106+
GREEN:
107+
description: "The color green"
108+
```
109+
When using PHP enum, the serialization will extract the `name` of the enum case, and the deserialization will return the enum case by its `name`.
110+
Even if the used enum is a Backed enum, the serialization and deserialization will always use the `name` and not the value.
111+
112+
113+

src/Config/EnumTypeDefinition.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ public function getDefinition(): ArrayNodeDefinition
2121
$node
2222
->children()
2323
->append($this->nameSection())
24+
->scalarNode('enumClass')
25+
->validate()
26+
->ifTrue(fn () => PHP_VERSION_ID < 80100)
27+
->thenInvalid('The enumClass option requires PHP 8.1 or higher.')
28+
->end()
29+
->validate()
30+
->ifTrue(fn ($v) => !class_exists($v))
31+
->thenInvalid('The specified enum Class "%s" does not exist.')
32+
->end()
33+
->end()
2434
->arrayNode('values')
2535
->useAttributeAsKey('name')
2636
->beforeNormalization()
@@ -49,8 +59,6 @@ public function getDefinition(): ArrayNodeDefinition
4959
->append($this->deprecationReasonSection())
5060
->end()
5161
->end()
52-
->isRequired()
53-
->requiresAtLeastOneElement()
5462
->end()
5563
->append($this->descriptionSection())
5664
->end();

src/Config/Parser/MetadataParser/MetadataParser.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
use function substr;
4343
use function trim;
4444

45+
use const PHP_VERSION_ID;
46+
4547
abstract class MetadataParser implements PreParserInterface
4648
{
4749
public const ANNOTATION_NAMESPACE = 'Overblog\GraphQLBundle\Annotation\\';
@@ -434,15 +436,15 @@ private static function enumMetadataToGQLConfiguration(ReflectionClass $reflecti
434436
{
435437
$metadatas = static::getMetadatas($reflectionClass);
436438
$enumValues = self::getMetadataMatching($metadatas, Metadata\EnumValue::class);
437-
439+
$isPhpEnum = PHP_VERSION_ID >= 80100 && $reflectionClass->isEnum();
438440
$values = [];
439441

440442
foreach ($reflectionClass->getConstants() as $name => $value) {
441443
$reflectionConstant = new ReflectionClassConstant($reflectionClass->getName(), $name);
442444
$valueConfig = self::getDescriptionConfiguration(static::getMetadatas($reflectionConstant), true);
443445

444446
$enumValueAnnotation = current(array_filter($enumValues, fn ($enumValueAnnotation) => $enumValueAnnotation->name === $name));
445-
$valueConfig['value'] = $value;
447+
$valueConfig['value'] = $isPhpEnum ? $value->name : $value;
446448

447449
if (false !== $enumValueAnnotation) {
448450
if (isset($enumValueAnnotation->description)) {
@@ -459,6 +461,9 @@ private static function enumMetadataToGQLConfiguration(ReflectionClass $reflecti
459461

460462
$enumConfiguration = ['values' => $values];
461463
$enumConfiguration = self::getDescriptionConfiguration(static::getMetadatas($reflectionClass)) + $enumConfiguration;
464+
if ($isPhpEnum) {
465+
$enumConfiguration['enumClass'] = $reflectionClass->getName();
466+
}
462467

463468
return ['type' => 'enum', 'config' => $enumConfiguration];
464469
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Overblog\GraphQLBundle\Definition\Type;
6+
7+
use Exception;
8+
use GraphQL\Error\Error;
9+
use GraphQL\Error\SerializationError;
10+
use GraphQL\Language\AST\EnumTypeDefinitionNode;
11+
use GraphQL\Language\AST\EnumTypeExtensionNode;
12+
use GraphQL\Language\AST\EnumValueNode;
13+
use GraphQL\Language\AST\Node;
14+
use GraphQL\Type\Definition\EnumType;
15+
use GraphQL\Utils\Utils;
16+
use ReflectionEnum;
17+
use UnitEnum;
18+
19+
/**
20+
* @phpstan-import-type EnumValues from EnumType
21+
*
22+
* @phpstan-type PhpEnumTypeConfig array{
23+
* name?: string|null,
24+
* description?: string|null,
25+
* enumClass?: class-string<\UnitEnum>|null,
26+
* values?: EnumValues|callable(): EnumValues,
27+
* astNode?: EnumTypeDefinitionNode|null,
28+
* extensionASTNodes?: array<int, EnumTypeExtensionNode>|null
29+
* }
30+
*/
31+
class PhpEnumType extends EnumType
32+
{
33+
/** @var class-string<UnitEnum>|null */
34+
protected ?string $enumClass = null;
35+
36+
/**
37+
* @phpstan-param PhpEnumTypeConfig $config
38+
*/
39+
public function __construct(array $config)
40+
{
41+
if (isset($config['enumClass'])) {
42+
$this->enumClass = $config['enumClass'];
43+
if (!enum_exists($this->enumClass)) {
44+
throw new Error(sprintf('Enum class "%s" does not exist.', $this->enumClass));
45+
}
46+
unset($config['enumClass']);
47+
}
48+
49+
if (!isset($config['values'])) {
50+
$config['values'] = [];
51+
}
52+
53+
parent::__construct($config);
54+
if ($this->enumClass) {
55+
$configValues = $this->config['values'] ?? [];
56+
if (is_callable($configValues)) {
57+
$configValues = $configValues();
58+
}
59+
$reflection = new ReflectionEnum($this->enumClass);
60+
61+
$enumDefinitions = [];
62+
foreach ($reflection->getCases() as $case) {
63+
$enumDefinitions[$case->getName()] = ['value' => $case->getName()];
64+
}
65+
66+
foreach ($configValues as $name => $config) {
67+
if (!isset($enumDefinitions[$name])) {
68+
throw new Error("Enum value {$name} is not defined in {$this->enumClass}");
69+
}
70+
$enumDefinitions[$name]['description'] = $config['description'] ?? null;
71+
$enumDefinitions[$name]['deprecationReason'] = $config['deprecationReason'] ?? null;
72+
}
73+
74+
$this->config['values'] = $enumDefinitions;
75+
}
76+
}
77+
78+
public function parseValue($value): mixed
79+
{
80+
if ($this->enumClass) {
81+
try {
82+
return (new ReflectionEnum($this->enumClass))->getCase($value)->getValue();
83+
} catch (Exception $e) {
84+
throw new Error("Cannot represent enum of class {$this->enumClass} from value {$value}: ".$e->getMessage());
85+
}
86+
}
87+
88+
return parent::parseValue($value);
89+
}
90+
91+
public function parseLiteral(Node $valueNode, ?array $variables = null): mixed
92+
{
93+
if ($this->enumClass) {
94+
if (!$valueNode instanceof EnumValueNode) {
95+
throw new Error("Cannot represent enum of class {$this->enumClass} from node: {$valueNode->__toString()} is not an enum value");
96+
}
97+
try {
98+
return (new ReflectionEnum($this->enumClass))->getCase($valueNode->value)->getValue();
99+
} catch (Exception $e) {
100+
throw new Error("Cannot represent enum of class {$this->enumClass} from literal {$valueNode->value}: ".$e->getMessage());
101+
}
102+
}
103+
104+
return parent::parseLiteral($valueNode, $variables);
105+
}
106+
107+
public function serialize($value): mixed
108+
{
109+
if ($this->enumClass) {
110+
if (!$value instanceof $this->enumClass) {
111+
$valueStr = Utils::printSafe($value);
112+
throw new SerializationError("Cannot serialize value {$valueStr} as it must be an instance of enum {$this->enumClass}.");
113+
}
114+
115+
return $value->name;
116+
}
117+
118+
return parent::serialize($value);
119+
}
120+
}

src/Generator/TypeBuilder.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
use GraphQL\Language\AST\NodeKind;
88
use GraphQL\Language\Parser;
9-
use GraphQL\Type\Definition\EnumType;
109
use GraphQL\Type\Definition\InputObjectType;
1110
use GraphQL\Type\Definition\InterfaceType;
1211
use GraphQL\Type\Definition\ObjectType;
@@ -27,6 +26,7 @@
2726
use Overblog\GraphQLBundle\Definition\Resolver\AliasedInterface;
2827
use Overblog\GraphQLBundle\Definition\Type\CustomScalarType;
2928
use Overblog\GraphQLBundle\Definition\Type\GeneratedTypeInterface;
29+
use Overblog\GraphQLBundle\Definition\Type\PhpEnumType;
3030
use Overblog\GraphQLBundle\Error\ResolveErrors;
3131
use Overblog\GraphQLBundle\ExpressionLanguage\ExpressionLanguage as EL;
3232
use Overblog\GraphQLBundle\Generator\Converter\ExpressionConverter;
@@ -70,7 +70,7 @@ final class TypeBuilder
7070
'input-object' => InputObjectType::class,
7171
'interface' => InterfaceType::class,
7272
'union' => UnionType::class,
73-
'enum' => EnumType::class,
73+
'enum' => PhpEnumType::class,
7474
'custom-scalar' => CustomScalarType::class,
7575
];
7676

@@ -342,6 +342,9 @@ private function buildConfig(array $config): Collection
342342
if (isset($c->values)) {
343343
$configLoader->addItem('values', Collection::assoc($c->values));
344344
}
345+
if (isset($c->enumClass)) {
346+
$configLoader->addItem('enumClass', $c->enumClass);
347+
}
345348

346349
// only by custom-scalar types
347350
if ('custom-scalar' === $this->type) {

src/Transformer/ArgumentsTransformer.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use GraphQL\Type\Definition\NonNull;
1111
use GraphQL\Type\Definition\ResolveInfo;
1212
use GraphQL\Type\Definition\Type;
13+
use Overblog\GraphQLBundle\Definition\Type\PhpEnumType;
1314
use Overblog\GraphQLBundle\Error\InvalidArgumentError;
1415
use Overblog\GraphQLBundle\Error\InvalidArgumentsError;
1516
use Symfony\Component\PropertyAccess\PropertyAccess;
@@ -80,6 +81,10 @@ private function populateObject(Type $type, $data, bool $multiple, ResolveInfo $
8081
}
8182

8283
if ($type instanceof EnumType) {
84+
/** Enum based on PHP Enum are already processed by PhpEnumType */
85+
if (isset($type->config['enumClass'])) { /** @phpstan-ignore-line */
86+
return $data;
87+
}
8388
$instance = $this->getTypeClassInstance($type->name);
8489
if ($instance) {
8590
$this->accessor->setValue($instance, 'value', $data);

tests/Config/Parser/MetadataParserTest.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Doctrine\Common\Annotations\Reader;
88
use Doctrine\ORM\Mapping\Column;
99
use Exception;
10+
use Overblog\GraphQLBundle\Tests\Config\Parser\fixtures\annotations\Enum\Color;
1011
use RecursiveDirectoryIterator;
1112
use RecursiveIteratorIterator;
1213
use SplFileInfo;
@@ -62,6 +63,9 @@ public function setUp(): void
6263
if (!self::isDoctrineOrmInstalled() && 'Lightsaber.php' === $file->getFileName()) {
6364
continue 2;
6465
}
66+
if (PHP_VERSION_ID < 80100 && 'Color.php' === $file->getFileName()) {
67+
continue 2;
68+
}
6569
}
6670

6771
$files[] = $file->getPathname();
@@ -249,6 +253,18 @@ public function testEnum(): void
249253
'TWILEK' => ['value' => '4'],
250254
],
251255
]);
256+
257+
if (PHP_VERSION_ID >= 80100) {
258+
$this->expect('Color', 'enum', [
259+
'enumClass' => Color::class,
260+
'values' => [
261+
'RED' => ['value' => 'RED', 'description' => 'The color red'],
262+
'GREEN' => ['value' => 'GREEN'],
263+
'BLUE' => ['value' => 'BLUE'],
264+
'YELLOW' => ['value' => 'YELLOW'],
265+
],
266+
]);
267+
}
252268
}
253269

254270
public function testUnion(): void
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Overblog\GraphQLBundle\Tests\Config\Parser\fixtures\annotations\Enum;
6+
7+
use Overblog\GraphQLBundle\Annotation as GQL;
8+
9+
/**
10+
* @GQL\Enum
11+
* @GQL\EnumValue(name="RED", description="The color red")
12+
*/
13+
#[GQL\Enum]
14+
enum Color
15+
{
16+
#[GQL\Description('The color red')]
17+
case RED;
18+
19+
case GREEN;
20+
21+
case BLUE;
22+
23+
case YELLOW;
24+
}

0 commit comments

Comments
 (0)