Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Native enum support #409

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6dcec26
Implement native enum type + mapper
dsavina Nov 25, 2021
73f3420
Ignore test output in directory 'build/'
dsavina Feb 14, 2022
02769b2
Run tests on PHP 8.1
dsavina Feb 14, 2022
d4439e0
Test native enum support if available
dsavina Feb 14, 2022
453b85c
Disable static code analysis on 8.1-specific code files
dsavina Feb 14, 2022
c3a6920
[wip] Use default Type annotation for enums instead of EnumType
dsavina Feb 24, 2022
d85227e
Merge remote-tracking branch 'dsavina/test/native-enum-support' into …
oojacoboo Mar 29, 2022
178ee95
Added symfony/var-dumper so we can actually debug
oojacoboo Mar 29, 2022
8cd54b8
Excluded a bad rule for 8.1 code style
oojacoboo Mar 29, 2022
8cb5138
CS fixes
oojacoboo Mar 29, 2022
408db3f
Additional comments
oojacoboo Mar 29, 2022
db91015
Ignore Enum type mapping outside root
oojacoboo Mar 29, 2022
380d61e
Define UnitEnum namespace
oojacoboo Mar 29, 2022
bca078e
Moved logic into more optimal location
oojacoboo Mar 29, 2022
185bc9c
Removed unused use statement
oojacoboo Mar 29, 2022
127694b
Merge branch 'master' into feature/native-enum-support
oojacoboo Mar 29, 2022
9c418d0
Support v5 of var-dumper for < PHP8
oojacoboo Mar 29, 2022
7a22c45
Remove root namespace for `UnitEnum` interface check
oojacoboo Mar 29, 2022
8a88dfc
Ignore PHPStan check for isEnum
oojacoboo Mar 29, 2022
f3018f1
Added root namespce back for UnitEnum
oojacoboo Mar 29, 2022
c778661
Deprecated EnumType annotation and updated docs
oojacoboo Mar 30, 2022
2109afa
CS fixes
oojacoboo Apr 4, 2022
6256fbc
Fixed failing test
oojacoboo Apr 4, 2022
3e01865
Fixed bug causing useEnumValues to not never be assigned
oojacoboo Apr 4, 2022
351619a
Improved documentation around the class attribute for @Type
oojacoboo Apr 7, 2022
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
2 changes: 1 addition & 1 deletion .github/workflows/continuous_integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
strategy:
matrix:
install-args: ['', '--prefer-lowest']
php-version: ['7.4', '8.0']
php-version: ['7.4', '8.0', '8.1']
fail-fast: false

steps:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/build/
/vendor/
/composer.lock
/src/Tests/
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
"phpstan/extension-installer": "^1.1",
"phpstan/phpstan": "^1.0",
"phpstan/phpstan-webmozart-assert": "^1.0",
"phpunit/phpunit": "^8.5.19||^9.5.8",
"phpunit/phpunit": "^8.5.19 || ^9.5.8",
"symfony/var-dumper": "^5.4 || ^6.0",
"thecodingmachine/phpstan-strict-rules": "^1.0"
},
"suggest": {
Expand Down
1 change: 1 addition & 0 deletions phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
<exclude name="SlevomatCodingStandard.ControlStructures.RequireNullCoalesceEqualOperator"/>
<exclude name="Squiz.Commenting.FunctionComment.InvalidNoReturn" />
<exclude name="Generic.Formatting.MultipleStatementAlignment" />
<exclude name="Squiz.Functions.MultiLineFunctionDeclaration.NewlineBeforeOpenBrace" />
</rule>

<!-- Do not align assignments -->
Expand Down
4 changes: 4 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ parameters:
tmpDir: .phpstan-cache
paths:
- src
excludePaths:
# TODO: exlude only for PHP < 8.1
- src/Mappers/Root/EnumTypeMapper.php
- src/Types/EnumType.php
level: 8
checkGenericClassInNonGenericObjectType: false
reportUnmatchedIgnoredErrors: false
Expand Down
4 changes: 0 additions & 4 deletions src/AnnotationReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
use Doctrine\Common\Annotations\AnnotationException;
use Doctrine\Common\Annotations\Reader;
use InvalidArgumentException;
use MyCLabs\Enum\Enum;
use ReflectionClass;
use ReflectionMethod;
use ReflectionParameter;
Expand Down Expand Up @@ -568,9 +567,6 @@ static function ($attribute) {
return $toAddAnnotations;
}

/**
* @param ReflectionClass<Enum> $refClass
*/
public function getEnumTypeAnnotation(ReflectionClass $refClass): ?EnumType
{
return $this->getClassAnnotation($refClass, EnumType::class);
Expand Down
16 changes: 15 additions & 1 deletion src/Annotations/EnumType.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
/**
* The EnumType annotation is useful to change the name of the generated "enum" type.
*
* @deprecated Use @Type on a native PHP 8.1 Enum instead. Support will be removed in future release.
*
* @Annotation
* @Target({"CLASS"})
* @Attributes({
Expand All @@ -21,12 +23,16 @@ class EnumType
/** @var string|null */
private $name;

/** @var bool */
private $useValues;

/**
* @param mixed[] $attributes
*/
public function __construct(array $attributes = [], ?string $name = null)
public function __construct(array $attributes = [], ?string $name = null, ?bool $useValues = null)
{
$this->name = $name ?? $attributes['name'] ?? null;
$this->useValues = $useValues ?? $attributes['useValues'] ?? false;
}

/**
Expand All @@ -36,4 +42,12 @@ public function getName(): ?string
{
return $this->name;
}

/**
* Returns true if the enum type should expose backed values instead of case names.
*/
public function useValues(): bool
{
return $this->useValues;
}
}
22 changes: 20 additions & 2 deletions src/Annotations/Type.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,21 @@ class Type
*/
private $selfType = false;

/** @var bool */
private $useEnumValues = false;

/**
* @param mixed[] $attributes
* @param class-string<object>|null $class
*/
public function __construct(array $attributes = [], ?string $class = null, ?string $name = null, ?bool $default = null, ?bool $external = null)
{
public function __construct(
array $attributes = [],
?string $class = null,
?string $name = null,
?bool $default = null,
?bool $external = null,
?bool $useEnumValues = null
) {
$external = $external ?? $attributes['external'] ?? null;
$class = $class ?? $attributes['class'] ?? null;
if ($class !== null) {
Expand All @@ -63,6 +72,7 @@ public function __construct(array $attributes = [], ?string $class = null, ?stri

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

if ($external === null) {
return;
Expand Down Expand Up @@ -123,4 +133,12 @@ public function isDefault(): bool
{
return $this->default;
}

/**
* Returns true if this enum type
*/
public function useEnumValues(): bool
{
return $this->useEnumValues;
}
}
1 change: 1 addition & 0 deletions src/Mappers/CompositeTypeMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public function mapClassToType(string $className, ?OutputType $subType): Mutable
return $typeMapper->mapClassToType($className, $subType);
}
}

throw CannotMapTypeException::createForType($className);
}

Expand Down
5 changes: 4 additions & 1 deletion src/Mappers/RecursiveTypeMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ public function mapClassToType(string $className, ?OutputType $subType): Mutable
if ($closestClassName === null) {
throw CannotMapTypeException::createForType($className);
}

$type = $this->typeMapper->mapClassToType($closestClassName, $subType);

// In the event this type was already part of cache, let's not extend it.
Expand Down Expand Up @@ -452,8 +453,10 @@ public function getOutputTypes(): array
$types = [];
$typeNames = [];
foreach ($this->typeMapper->getSupportedClasses() as $supportedClass) {
$type = $this->mapClassToType($supportedClass, null);
$type = $this->mapClassToType($supportedClass, null);

$types[$supportedClass] = $type;

if (isset($typeNames[$type->name])) {
throw DuplicateMappingException::createForTypeName($type->name, $typeNames[$type->name], $supportedClass);
}
Expand Down
214 changes: 214 additions & 0 deletions src/Mappers/Root/EnumTypeMapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
<?php

declare(strict_types=1);

namespace TheCodingMachine\GraphQLite\Mappers\Root;

use GraphQL\Type\Definition\InputType;
use GraphQL\Type\Definition\NamedType;
use GraphQL\Type\Definition\OutputType;
use GraphQL\Type\Definition\Type as GraphQLType;
use phpDocumentor\Reflection\DocBlock;
use phpDocumentor\Reflection\Type;
use phpDocumentor\Reflection\Types\Object_;
use ReflectionClass;
use ReflectionEnum;
use ReflectionMethod;
use ReflectionProperty;
use Symfony\Contracts\Cache\CacheInterface;
use TheCodingMachine\GraphQLite\AnnotationReader;
use TheCodingMachine\GraphQLite\Types\EnumType;
use TheCodingMachine\GraphQLite\Utils\Namespaces\NS;
use UnitEnum;

use function assert;
use function enum_exists;

/**
* Maps an enum class to a GraphQL type (only available in PHP>=8.1)
*/
class EnumTypeMapper implements RootTypeMapperInterface
{
/** @var array<class-string<UnitEnum>, EnumType> */
private $cache = [];
/** @var array<string, EnumType> */
private $cacheByName = [];
/** @var array<string, class-string<UnitEnum>> */
private $nameToClassMapping;
/** @var RootTypeMapperInterface */
private $next;
/** @var AnnotationReader */
private $annotationReader;
/** @var array|NS[] */
private $namespaces;
/** @var CacheInterface */
private $cacheService;

/**
* @param NS[] $namespaces List of namespaces containing enums. Used when searching an enum by name.
*/
public function __construct(
RootTypeMapperInterface $next,
AnnotationReader $annotationReader,
CacheInterface $cacheService,
array $namespaces
) {
$this->next = $next;
$this->annotationReader = $annotationReader;
$this->cacheService = $cacheService;
$this->namespaces = $namespaces;
}

/**
* @param (OutputType&GraphQLType)|null $subType
* @param ReflectionMethod|ReflectionProperty $reflector
*
* @return OutputType&GraphQLType
*/
public function toGraphQLOutputType(
Type $type,
?OutputType $subType,
$reflector,
DocBlock $docBlockObj
): OutputType {
$result = $this->map($type);
if ($result === null) {
return $this->next->toGraphQLOutputType($type, $subType, $reflector, $docBlockObj);
}

return $result;
}

/**
* Maps into the appropriate InputType
*
* @param InputType|GraphQLType|null $subType
* @param ReflectionMethod|ReflectionProperty $reflector
*
* @return InputType|GraphQLType
*/
public function toGraphQLInputType(
Type $type,
?InputType $subType,
string $argumentName,
$reflector,
DocBlock $docBlockObj
): InputType
{
$result = $this->map($type);
if ($result === null) {
return $this->next->toGraphQLInputType($type, $subType, $argumentName, $reflector, $docBlockObj);
}

return $result;
}

private function map(Type $type): ?EnumType
{
if (! $type instanceof Object_) {
return null;
}
$fqsen = $type->getFqsen();
if ($fqsen === null) {
return null;
}

/** @var class-string<object> $enumClass */
$enumClass = (string) $fqsen;

return $this->mapByClassName($enumClass);
}

/**
* @param class-string $enumClass
*/
private function mapByClassName(string $enumClass): ?EnumType
{
if (isset($this->cache[$enumClass])) {
return $this->cache[$enumClass];
}

if (! enum_exists($enumClass)) {
return null;
}

// phpcs:disable SlevomatCodingStandard.Commenting.InlineDocCommentDeclaration.MissingVariable
/** @var class-string<UnitEnum> $enumClass */
// phpcs:enable SlevomatCodingStandard.Commenting.InlineDocCommentDeclaration.MissingVariable
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we getting much value out of this? I'd just remove it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PHPStan (before I disabled it) required a class-string<UnitEnum>, as signed in EnumType constructor, and wasn't able to determine $enumClass complies at this point (after the enum_exist check).

On the other side, CodeSniffer doesn't like precising the type subsequently to a variable declaration (which I think is a shame, but I guess I understand the rule), therefore the phpcs:disable directive.

I could simply soften the typecheck in EnumType::__constructor, from class-string<UnitEnum> to class-string, but I must say I'm not so fond of this option.

Copy link
Collaborator

@oojacoboo oojacoboo Feb 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, we should be doing better validation in the constructor for this string, instead of relying on static type analysis. What kind of feedback is a developer going to here here if an incorrect class-string is provided? It's nice to provide these additional static checks for IDE completion and lib development testing, but provides little runtime, or even development, assurances.

Is this error not referring to the fact that it's a param and not a var in terms of phpcs parsing? Why aren't we applying this in the docblock of the current function?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation lies precisely in the enum_exists() check above. The ideal would have been for PHPStan to exploit this check in order to infer a class-string<UnitEnum>, just like is_int() would lead to inferring an integer value, I'm merely giving it a little push here.

The input argument of the method doesn't strictly have to be a valid enum class name, it simply won't be mapped as an EnumType if not; null will be returned, and the mapper will fallback to the next in line (once again, behaviour copied from MyCLabsTypeMapper). Typing the argument as class-string<UnitEnum> means the check must be done outside of the method, both in EnumTypeMapper::map(Type $type) and EnumTypeMapper::mapNameToType(string $typeName), but even then PHPStan would need this little push as it's unable to infer class-string<UnitEnum> from enum_exists().


$reflectionEnum = new ReflectionEnum($enumClass);

$typeAnnotation = $this->annotationReader->getTypeAnnotation($reflectionEnum);
$typeName = ($typeAnnotation !== null ? $typeAnnotation->getName() : null) ?? $reflectionEnum->getShortName();

// Expose values instead of names if specifically configured to and if enum is string-backed
$useValues = $typeAnnotation !== null &&
$typeAnnotation->useEnumValues() &&
$reflectionEnum->isBacked() &&
(string) $reflectionEnum->getBackingType() === 'string';

$type = new EnumType($enumClass, $typeName, $useValues);

return $this->cacheByName[$typeName] = $this->cache[$enumClass] = $type;
}

private function getTypeName(ReflectionClass $reflectionClass): string
{
$typeAnnotation = $this->annotationReader->getTypeAnnotation($reflectionClass);

return ($typeAnnotation !== null ? $typeAnnotation->getName() : null) ?? $reflectionClass->getShortName();
}

/**
* Returns a GraphQL type by name.
* If this root type mapper can return this type in "toGraphQLOutputType" or "toGraphQLInputType", it should
* also map these types by name in the "mapNameToType" method.
*
* @param string $typeName The name of the GraphQL type
*/
public function mapNameToType(string $typeName): NamedType
{
// This is a hack to make sure "$schema->assertValid()" returns true.
// The mapNameToType will fail if the mapByClassName method was not called before.
// This is actually not an issue in real life scenarios where enum types are never queried by type name.
if (isset($this->cacheByName[$typeName])) {
return $this->cacheByName[$typeName];
}

$nameToClassMapping = $this->getNameToClassMapping();
if (isset($this->nameToClassMapping[$typeName])) {
$className = $nameToClassMapping[$typeName];
$type = $this->mapByClassName($className);
assert($type !== null);
return $type;
}

return $this->next->mapNameToType($typeName);
}

/**
* Go through all classes in the defined namespaces and loads the cache.
*
* @return array<string, class-string<UnitEnum>>
*/
private function getNameToClassMapping(): array
{
if ($this->nameToClassMapping === null) {
$this->nameToClassMapping = $this->cacheService->get('enum_name_to_class', function () {
$nameToClassMapping = [];
foreach ($this->namespaces as $ns) {
foreach ($ns->getClassList() as $className => $classRef) {
if (! enum_exists($className)) {
continue;
}

$nameToClassMapping[$this->getTypeName($classRef)] = $className;
}
}
return $nameToClassMapping;
});
}

return $this->nameToClassMapping;
}
}
Loading