Skip to content

Commit 44587d3

Browse files
dsavinaoojacoboo
andauthored
Native enum support (#409)
* Implement native enum type + mapper * Ignore test output in directory 'build/' * Run tests on PHP 8.1 * Test native enum support if available - PHP >= 8.1 - interface_exists(UnitEnum) * Disable static code analysis on 8.1-specific code files * [wip] Use default Type annotation for enums instead of EnumType * Added symfony/var-dumper so we can actually debug * Excluded a bad rule for 8.1 code style * CS fixes * Additional comments * Ignore Enum type mapping outside root * Define UnitEnum namespace * Moved logic into more optimal location * Removed unused use statement * Support v5 of var-dumper for < PHP8 * Remove root namespace for `UnitEnum` interface check * Ignore PHPStan check for isEnum * Added root namespce back for UnitEnum * Deprecated EnumType annotation and updated docs * CS fixes * Fixed failing test * Fixed bug causing useEnumValues to not never be assigned * Improved documentation around the class attribute for @type Co-authored-by: Jacob Thomason <[email protected]>
1 parent b32857f commit 44587d3

23 files changed

+636
-26
lines changed

.github/workflows/continuous_integration.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
strategy:
1919
matrix:
2020
install-args: ['', '--prefer-lowest']
21-
php-version: ['7.4', '8.0']
21+
php-version: ['7.4', '8.0', '8.1']
2222
fail-fast: false
2323

2424
steps:

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/build/
12
/vendor/
23
/composer.lock
34
/src/Tests/

composer.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@
4040
"phpstan/extension-installer": "^1.1",
4141
"phpstan/phpstan": "^1.0",
4242
"phpstan/phpstan-webmozart-assert": "^1.0",
43-
"phpunit/phpunit": "^8.5.19||^9.5.8",
43+
"phpunit/phpunit": "^8.5.19 || ^9.5.8",
44+
"symfony/var-dumper": "^5.4 || ^6.0",
4445
"thecodingmachine/phpstan-strict-rules": "^1.0"
4546
},
4647
"suggest": {

phpcs.xml.dist

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
<exclude name="SlevomatCodingStandard.ControlStructures.RequireNullCoalesceEqualOperator"/>
2929
<exclude name="Squiz.Commenting.FunctionComment.InvalidNoReturn" />
3030
<exclude name="Generic.Formatting.MultipleStatementAlignment" />
31+
<exclude name="Squiz.Functions.MultiLineFunctionDeclaration.NewlineBeforeOpenBrace" />
3132
</rule>
3233

3334
<!-- Do not align assignments -->

phpstan.neon

+4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ parameters:
44
tmpDir: .phpstan-cache
55
paths:
66
- src
7+
excludePaths:
8+
# TODO: exlude only for PHP < 8.1
9+
- src/Mappers/Root/EnumTypeMapper.php
10+
- src/Types/EnumType.php
711
level: 8
812
checkGenericClassInNonGenericObjectType: false
913
reportUnmatchedIgnoredErrors: false

src/AnnotationReader.php

-4
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
use Doctrine\Common\Annotations\AnnotationException;
88
use Doctrine\Common\Annotations\Reader;
99
use InvalidArgumentException;
10-
use MyCLabs\Enum\Enum;
1110
use ReflectionClass;
1211
use ReflectionMethod;
1312
use ReflectionParameter;
@@ -568,9 +567,6 @@ static function ($attribute) {
568567
return $toAddAnnotations;
569568
}
570569

571-
/**
572-
* @param ReflectionClass<Enum> $refClass
573-
*/
574570
public function getEnumTypeAnnotation(ReflectionClass $refClass): ?EnumType
575571
{
576572
return $this->getClassAnnotation($refClass, EnumType::class);

src/Annotations/EnumType.php

+15-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
/**
1010
* The EnumType annotation is useful to change the name of the generated "enum" type.
1111
*
12+
* @deprecated Use @Type on a native PHP 8.1 Enum instead. Support will be removed in future release.
13+
*
1214
* @Annotation
1315
* @Target({"CLASS"})
1416
* @Attributes({
@@ -21,12 +23,16 @@ class EnumType
2123
/** @var string|null */
2224
private $name;
2325

26+
/** @var bool */
27+
private $useValues;
28+
2429
/**
2530
* @param mixed[] $attributes
2631
*/
27-
public function __construct(array $attributes = [], ?string $name = null)
32+
public function __construct(array $attributes = [], ?string $name = null, ?bool $useValues = null)
2833
{
2934
$this->name = $name ?? $attributes['name'] ?? null;
35+
$this->useValues = $useValues ?? $attributes['useValues'] ?? false;
3036
}
3137

3238
/**
@@ -36,4 +42,12 @@ public function getName(): ?string
3642
{
3743
return $this->name;
3844
}
45+
46+
/**
47+
* Returns true if the enum type should expose backed values instead of case names.
48+
*/
49+
public function useValues(): bool
50+
{
51+
return $this->useValues;
52+
}
3953
}

src/Annotations/Type.php

+20-2
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,21 @@ class Type
4545
*/
4646
private $selfType = false;
4747

48+
/** @var bool */
49+
private $useEnumValues = false;
50+
4851
/**
4952
* @param mixed[] $attributes
5053
* @param class-string<object>|null $class
5154
*/
52-
public function __construct(array $attributes = [], ?string $class = null, ?string $name = null, ?bool $default = null, ?bool $external = null)
53-
{
55+
public function __construct(
56+
array $attributes = [],
57+
?string $class = null,
58+
?string $name = null,
59+
?bool $default = null,
60+
?bool $external = null,
61+
?bool $useEnumValues = null
62+
) {
5463
$external = $external ?? $attributes['external'] ?? null;
5564
$class = $class ?? $attributes['class'] ?? null;
5665
if ($class !== null) {
@@ -63,6 +72,7 @@ public function __construct(array $attributes = [], ?string $class = null, ?stri
6372

6473
// If no value is passed for default, "default" = true
6574
$this->default = $default ?? $attributes['default'] ?? true;
75+
$this->useEnumValues = $useEnumValues ?? $attributes['useEnumValues'] ?? false;
6676

6777
if ($external === null) {
6878
return;
@@ -123,4 +133,12 @@ public function isDefault(): bool
123133
{
124134
return $this->default;
125135
}
136+
137+
/**
138+
* Returns true if this enum type
139+
*/
140+
public function useEnumValues(): bool
141+
{
142+
return $this->useEnumValues;
143+
}
126144
}

src/Mappers/CompositeTypeMapper.php

+1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ public function mapClassToType(string $className, ?OutputType $subType): Mutable
6565
return $typeMapper->mapClassToType($className, $subType);
6666
}
6767
}
68+
6869
throw CannotMapTypeException::createForType($className);
6970
}
7071

src/Mappers/RecursiveTypeMapper.php

+4-1
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ public function mapClassToType(string $className, ?OutputType $subType): Mutable
114114
if ($closestClassName === null) {
115115
throw CannotMapTypeException::createForType($className);
116116
}
117+
117118
$type = $this->typeMapper->mapClassToType($closestClassName, $subType);
118119

119120
// In the event this type was already part of cache, let's not extend it.
@@ -452,8 +453,10 @@ public function getOutputTypes(): array
452453
$types = [];
453454
$typeNames = [];
454455
foreach ($this->typeMapper->getSupportedClasses() as $supportedClass) {
455-
$type = $this->mapClassToType($supportedClass, null);
456+
$type = $this->mapClassToType($supportedClass, null);
457+
456458
$types[$supportedClass] = $type;
459+
457460
if (isset($typeNames[$type->name])) {
458461
throw DuplicateMappingException::createForTypeName($type->name, $typeNames[$type->name], $supportedClass);
459462
}

src/Mappers/Root/EnumTypeMapper.php

+214
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite\Mappers\Root;
6+
7+
use GraphQL\Type\Definition\InputType;
8+
use GraphQL\Type\Definition\NamedType;
9+
use GraphQL\Type\Definition\OutputType;
10+
use GraphQL\Type\Definition\Type as GraphQLType;
11+
use phpDocumentor\Reflection\DocBlock;
12+
use phpDocumentor\Reflection\Type;
13+
use phpDocumentor\Reflection\Types\Object_;
14+
use ReflectionClass;
15+
use ReflectionEnum;
16+
use ReflectionMethod;
17+
use ReflectionProperty;
18+
use Symfony\Contracts\Cache\CacheInterface;
19+
use TheCodingMachine\GraphQLite\AnnotationReader;
20+
use TheCodingMachine\GraphQLite\Types\EnumType;
21+
use TheCodingMachine\GraphQLite\Utils\Namespaces\NS;
22+
use UnitEnum;
23+
24+
use function assert;
25+
use function enum_exists;
26+
27+
/**
28+
* Maps an enum class to a GraphQL type (only available in PHP>=8.1)
29+
*/
30+
class EnumTypeMapper implements RootTypeMapperInterface
31+
{
32+
/** @var array<class-string<UnitEnum>, EnumType> */
33+
private $cache = [];
34+
/** @var array<string, EnumType> */
35+
private $cacheByName = [];
36+
/** @var array<string, class-string<UnitEnum>> */
37+
private $nameToClassMapping;
38+
/** @var RootTypeMapperInterface */
39+
private $next;
40+
/** @var AnnotationReader */
41+
private $annotationReader;
42+
/** @var array|NS[] */
43+
private $namespaces;
44+
/** @var CacheInterface */
45+
private $cacheService;
46+
47+
/**
48+
* @param NS[] $namespaces List of namespaces containing enums. Used when searching an enum by name.
49+
*/
50+
public function __construct(
51+
RootTypeMapperInterface $next,
52+
AnnotationReader $annotationReader,
53+
CacheInterface $cacheService,
54+
array $namespaces
55+
) {
56+
$this->next = $next;
57+
$this->annotationReader = $annotationReader;
58+
$this->cacheService = $cacheService;
59+
$this->namespaces = $namespaces;
60+
}
61+
62+
/**
63+
* @param (OutputType&GraphQLType)|null $subType
64+
* @param ReflectionMethod|ReflectionProperty $reflector
65+
*
66+
* @return OutputType&GraphQLType
67+
*/
68+
public function toGraphQLOutputType(
69+
Type $type,
70+
?OutputType $subType,
71+
$reflector,
72+
DocBlock $docBlockObj
73+
): OutputType {
74+
$result = $this->map($type);
75+
if ($result === null) {
76+
return $this->next->toGraphQLOutputType($type, $subType, $reflector, $docBlockObj);
77+
}
78+
79+
return $result;
80+
}
81+
82+
/**
83+
* Maps into the appropriate InputType
84+
*
85+
* @param InputType|GraphQLType|null $subType
86+
* @param ReflectionMethod|ReflectionProperty $reflector
87+
*
88+
* @return InputType|GraphQLType
89+
*/
90+
public function toGraphQLInputType(
91+
Type $type,
92+
?InputType $subType,
93+
string $argumentName,
94+
$reflector,
95+
DocBlock $docBlockObj
96+
): InputType
97+
{
98+
$result = $this->map($type);
99+
if ($result === null) {
100+
return $this->next->toGraphQLInputType($type, $subType, $argumentName, $reflector, $docBlockObj);
101+
}
102+
103+
return $result;
104+
}
105+
106+
private function map(Type $type): ?EnumType
107+
{
108+
if (! $type instanceof Object_) {
109+
return null;
110+
}
111+
$fqsen = $type->getFqsen();
112+
if ($fqsen === null) {
113+
return null;
114+
}
115+
116+
/** @var class-string<object> $enumClass */
117+
$enumClass = (string) $fqsen;
118+
119+
return $this->mapByClassName($enumClass);
120+
}
121+
122+
/**
123+
* @param class-string $enumClass
124+
*/
125+
private function mapByClassName(string $enumClass): ?EnumType
126+
{
127+
if (isset($this->cache[$enumClass])) {
128+
return $this->cache[$enumClass];
129+
}
130+
131+
if (! enum_exists($enumClass)) {
132+
return null;
133+
}
134+
135+
// phpcs:disable SlevomatCodingStandard.Commenting.InlineDocCommentDeclaration.MissingVariable
136+
/** @var class-string<UnitEnum> $enumClass */
137+
// phpcs:enable SlevomatCodingStandard.Commenting.InlineDocCommentDeclaration.MissingVariable
138+
139+
$reflectionEnum = new ReflectionEnum($enumClass);
140+
141+
$typeAnnotation = $this->annotationReader->getTypeAnnotation($reflectionEnum);
142+
$typeName = ($typeAnnotation !== null ? $typeAnnotation->getName() : null) ?? $reflectionEnum->getShortName();
143+
144+
// Expose values instead of names if specifically configured to and if enum is string-backed
145+
$useValues = $typeAnnotation !== null &&
146+
$typeAnnotation->useEnumValues() &&
147+
$reflectionEnum->isBacked() &&
148+
(string) $reflectionEnum->getBackingType() === 'string';
149+
150+
$type = new EnumType($enumClass, $typeName, $useValues);
151+
152+
return $this->cacheByName[$typeName] = $this->cache[$enumClass] = $type;
153+
}
154+
155+
private function getTypeName(ReflectionClass $reflectionClass): string
156+
{
157+
$typeAnnotation = $this->annotationReader->getTypeAnnotation($reflectionClass);
158+
159+
return ($typeAnnotation !== null ? $typeAnnotation->getName() : null) ?? $reflectionClass->getShortName();
160+
}
161+
162+
/**
163+
* Returns a GraphQL type by name.
164+
* If this root type mapper can return this type in "toGraphQLOutputType" or "toGraphQLInputType", it should
165+
* also map these types by name in the "mapNameToType" method.
166+
*
167+
* @param string $typeName The name of the GraphQL type
168+
*/
169+
public function mapNameToType(string $typeName): NamedType
170+
{
171+
// This is a hack to make sure "$schema->assertValid()" returns true.
172+
// The mapNameToType will fail if the mapByClassName method was not called before.
173+
// This is actually not an issue in real life scenarios where enum types are never queried by type name.
174+
if (isset($this->cacheByName[$typeName])) {
175+
return $this->cacheByName[$typeName];
176+
}
177+
178+
$nameToClassMapping = $this->getNameToClassMapping();
179+
if (isset($this->nameToClassMapping[$typeName])) {
180+
$className = $nameToClassMapping[$typeName];
181+
$type = $this->mapByClassName($className);
182+
assert($type !== null);
183+
return $type;
184+
}
185+
186+
return $this->next->mapNameToType($typeName);
187+
}
188+
189+
/**
190+
* Go through all classes in the defined namespaces and loads the cache.
191+
*
192+
* @return array<string, class-string<UnitEnum>>
193+
*/
194+
private function getNameToClassMapping(): array
195+
{
196+
if ($this->nameToClassMapping === null) {
197+
$this->nameToClassMapping = $this->cacheService->get('enum_name_to_class', function () {
198+
$nameToClassMapping = [];
199+
foreach ($this->namespaces as $ns) {
200+
foreach ($ns->getClassList() as $className => $classRef) {
201+
if (! enum_exists($className)) {
202+
continue;
203+
}
204+
205+
$nameToClassMapping[$this->getTypeName($classRef)] = $className;
206+
}
207+
}
208+
return $nameToClassMapping;
209+
});
210+
}
211+
212+
return $this->nameToClassMapping;
213+
}
214+
}

0 commit comments

Comments
 (0)