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

Conversation

dsavina
Copy link
Contributor

@dsavina dsavina commented Nov 25, 2021

This aims to provide support for native enums, introduced with PHP 8.1.

Backed enums using string values will base their names on their case values. Non-backed enums, and int-backed enums, will instead use their case names.

I'm actually wondering about this last rule: should perhaps the int-backed enums be compatible with type Int instead of String? But then, introspection won't be able to hint allowed values, will it?

Any suggestions?

@oojacoboo
Copy link
Collaborator

oojacoboo commented Nov 25, 2021

Closes #407

@dsavina thanks for this PR.

@oojacoboo
Copy link
Collaborator

oojacoboo commented Nov 25, 2021

This looks good @dsavina. We need to get the tests passing. Firstly, we'll want to skip the Enum tests for version less than 8.1 (https://phpunit.readthedocs.io/en/9.5/incomplete-and-skipped-tests.html#skipping-tests-using-requires). We should also update the actions workflow to use the latest 8.1 version instead of 8.0.

Regarding Int, I think we should be preserving types. For allowed types, what are you referring to here? Are you talking about @Validate or @Assertion?

@dsavina
Copy link
Contributor Author

dsavina commented Nov 26, 2021

we'll want to skip the Enum tests for version less than 8.1

I'm aware of the need to filter tests based on PHP version, I'll do it ASAP. In addition, I will also need to isolate enum files in their own namespace to prevent loading invalid PHP code when working with PHP < 8.1.

Regarding Int, I think we should be preserving types. For allowed types, what are you referring to here? Are you talking about @Validate or @Assertion?

I'm rather talking about the GraphQL standard, which only allows string values for an enum. Hence, by construct, it is impossible to fully preserve type for an int-backed enum, we will have to chose between declaring a string enum, meaning ignoring backed values, or simply cast to Int, meaning not listing available values.

Perhaps an example will clarify, say we have an int-backed enum LogLevel:

enum LogLevel: int
{
  case Trace = 0;
  case Debug = 1;
  case Info = 2;
  case Warn = 3;
  case Error = 4;
  case Fatal = 5;
}

a model Log:

class Log
{
  #[Field]
  public LogLevel $level;
  #[Field]
  public DateTime $date;
  #[Field]
  public string $message;
}

and a query logs:

#[Query]
/** @return Log[] */
public function logs(LogLevel $level = LogLevel::Info): iterable
{
  foreach ($this->logs as $log) {
    if ($log->level->value >= $level->value) {
      yield $log;
    }
  }
}

We then have two choices:

  • We use case names Trace, Debug, Info, etc. to declare a GraphQL enum type LogLevel, available in introspection:
query {
  _type(name: "LogLevel") {
    enumValues {
      name
    }
  }
}

which server would respond with:

{
  "data": {
    "__type": {
      "enumValues": [
        {  
          "name": "Trace"
        },
        {  
          "name": "Debug"
        },
        {  
          "name": "Info"
        },
        ...
      ]
    }
  }
}

Requesting logs would look like:

query {
  logs(level: Warn) {
    level
    message
  }
}

which server would respond with:

{
  "data": {
    "logs": [
      {
        "level": "Error",
        "message": "Something went wrong"
      },
      {
        "level": "Fatal",
        "message": "Something went really wrong"
      }
    ]
  }
}
  • We use scalar type Int, silently mapping values 0, 1, 2... respectively to LogLevel::Trace, LogLevel::Debug, LogLevel::Info, etc. In this case, any field typed LogLevel on server side will appear as a simple Int in GraphQL introspection, without hinting available values, but rather throwing exceptions during runtime when receiving out-of-bound values.
    Querying logs now looks like this:
query {
  logs(level: 3) {
    level
    message
  }
}

which server would now respond with:

{
  "data": {
    "logs": [
      {
        "level": 4,
        "message": "Something went wrong"
      },
      {
        "level": 5,
        "message": "Something went really wrong"
      }
    ]
  }
}

We can also query:

query {
  logs(level: 6) {
    level
    message
  }
}

which would throw an error, as 6 is not a valid LogLevel value.

I'm not comfortable with this second option, where the introspection never specified which values are allowed, since parameter level is simply signed Int for the GraphQL client. I feel like this case should rather be implemented using a custom scalar type.

@oojacoboo
Copy link
Collaborator

oojacoboo commented Nov 29, 2021

It looks like Apollo Server allows for you to override which one is used through a resolver:
https://www.apollographql.com/docs/apollo-server/schema/schema/#enum-types

From the spec:

Enum values are only used in contexts where the precise enumeration type is known. Therefore it’s not necessary to supply an enumeration type name in the literal.

In the case of an int value enum, I find that data to generally be important. In the example you've given, there isn't any way, client-side, to know the order of the LogLevels, unless it's been previously agreed upon (ie. following a spec). However, if the name and value were both provided, there isn't any need to synchronize or converge on some kind of spec.

I can see a lot of cases where, both, the name and value are important. Is it possible to allow both of these fields while still being compliant with the spec? I haven't used enums in GraphQL too much. I do recall the current implementation with MyCLabs\Enum being frustrating to work with, for this exact reason. I believe we ended up swapping the actual values and names in a PHP class, which was not desirable from the backend perspective.

In my opinion, we need to find out if it's possible and/or desirable to output both the name and the value, or we need to
provide an annotation/attribute option to choose which gets output. And, this should apply to all enums, not just int backed ones.

@Lappihuan
Copy link
Contributor

There is no such thing as a Enum value in GraphQL, afaik most languages don't expose the Enum value.
Additionally, the API Consumer shouldn't care about the Enum value in PHP, he doesn't know about PHP.

@oojacoboo
Copy link
Collaborator

@Lappihuan I do not disagree with you and am not suggesting that PHP has any relevance to this conversation. However, if there is a value that's available and it does not break the spec to include it as a field, I'm not sure I see the issue with providing it. There are many cases where having a value can be extremely valuable, in conjunction with the name.

If it breaks the spec and won't work, that's that - let's discount the idea, and move forward with an annotation option.

@oojacoboo
Copy link
Collaborator

@dsavina would be great to get this wrapped up. We should go ahead with what you have now.

In order for us to support the option to output either the constant name or the assigned value, we'd really need to add a new attribute for Enum types, or possibly add a new property to the Type attribute (don't like this). This can be added as a new feature improvement.

As for outputting the name and the value, I don't even know if you could query the "value" of an Enum through most clients. Technically you could make it available in the __schema. However, that doesn't mean clients would allow access, as many are probably just assigning a single string value.

@aszenz
Copy link
Contributor

aszenz commented Jan 5, 2022

If I understand the issue correctly, why can't we only allow string backed enums and refuse to accept other php enums ?
That seems to me the simplest option

@Lappihuan
Copy link
Contributor

Lappihuan commented Jan 5, 2022

i'm still wondering why this is even a question.
the library already implements enums with the 3rd party library myclabs/php-enum.
a 1:1 implementation of that with native enums is the only thing that makes sense.

i am still confused why the value of a enum would be of any intrest for an api consumer.

the current implementation would work like this:

PHP:

class StatusEnum extends Enum
{
    private const ON = 'on';
    private const OFF = 'off';
    private const PENDING = 'pending';
}

GQL:

enum StatusEnum {
    ON
    OFF
    PENDING
}

No mather what the backed value is:
PHP:

class StatusEnum extends Enum
{
    private const ON = 1;
    private const OFF = 2;
    private const PENDING = 3;
}

GQL:

enum StatusEnum {
    ON
    OFF
    PENDING
}

@oojacoboo
Copy link
Collaborator

We should just follow the webonyx implementation.

https://webonyx.github.io/graphql-php/type-definitions/enums/

The current MyCLabs implementation uses the key for both the name and value of the EnumType, internally. This results in the resolver always returning the key/name. I know I've ran into this issue before during resolution. Internally, you want the value assigned, if it differs from the key/name.

I'm all for getting this right now though, now, and not trying to create a 1:1 implementation of the MyCLabs implementation.

Current MyCLabs implementation:

class MyCLabsEnumType extends EnumType
{
    public function __construct(string $enumClassName, string $typeName)
    {
        $consts         = $enumClassName::toArray();
        $constInstances = [];
        foreach ($consts as $key => $value) {
            $constInstances[$key] = ['value' => $enumClassName::$key()];
        }

        parent::__construct([
            'name' => $typeName,
            'values' => $constInstances,
        ]);
    }
...

As you can see, the key is being assigned to both, the key and value. However, the webonyx implementation supports keys and values, according to the native BackedEnum and resolves them accordingly.

@Lappihuan
Copy link
Contributor

Exposing the value over GQL is just not in the GQL Spec...
As for mapping return types internally, that has nothing to do with GQL but just the TypeMapper.
The TypeMapper should be designed to allow all usecases of native enums.

@oojacoboo
Copy link
Collaborator

@Lappihuan can you be more concrete here in terms of what you're suggesting different? What is wrong with implementing Enums according to the webonyx type mapping implementation?

@dsavina
Copy link
Contributor Author

dsavina commented Jan 10, 2022

Sorry I couldn't get back on this subject earlier.

Β 

@oojacoboo commented 5 days ago
As you can see, the key is being assigned to both, the key and value.

This is not exact: the enum name is indeed used as the key in the Webonyx representation of the enum case. However, the value obtained with expression $enumClassName::$key() is an instance of TEnum of MyCLabs\Enum\Enum, different from the key. If I understand correctly, Webonyx exposes the key to the GraphQL Api, and map it internally to the corresponding value (i.e. an object, instance of the corresponding enum class, whether MyCLabs or native).

Therefore, the question remains regarding the exposed names. I think the best approach would be to leave the choice to developers, by supporting an option bool $mapValues in the Type annotation, only applicable for string-backed enums. Default behavior would map the enum names.

@oojacoboo
Copy link
Collaborator

oojacoboo commented Jan 10, 2022

@dsavina I'm all for offering the option here. That was my initial recommendation. For the sake of simplicity, I think we could go with a direct mapping according to the webonyx implementation. But, if you will add the option, let's do that.

We have the EnumType attribute used to change the name of an Enum type. Right now, Enums are converted to Types by extending the MyCLabs enum class, via the type-mapper. I'm assuming this is the reason for the additional attribute, as opposed to using the default Type attribute.

I think we should unify these attributes by deprecating the EnumType attribute, as the Type.name will serve for this purpose. Then, we can add an argument to the Type attribute, clarifying that it's intended for Enums - maybe bool $useEnumValues. mapEnumValues feels a bit ambiguous, but I don't hate it.

@oojacoboo
Copy link
Collaborator

Hey @dsavina I'd like to target a 5.1 release with this and #435. Any chance you'll have some time to get this wrapped up soon?

@dsavina
Copy link
Contributor Author

dsavina commented Jan 25, 2022

Hi @oojacoboo,

I'm sorry, I'm very busy these days, I won't be able to come back on this for the next two weeks. I should find the time to do it somewhere around February 10, would that work?

@oojacoboo
Copy link
Collaborator

@dsavina that should be fine. We'll be anxiously awaiting :)

@withinboredom
Copy link

We'll be anxiously awaiting :)

image

@dsavina dsavina force-pushed the feature/native-enum-support branch from 7b362d4 to 87e3883 Compare February 11, 2022 15:30
@dsavina
Copy link
Contributor Author

dsavina commented Feb 11, 2022

Hi there,

Just a quick update: I found some time to change the implementation in order to use Type annotation for enums, with optional argument useEnumValues to expose values instead of names.

I couldn't update the tests in order to bypass all this whenever running under 8.1, I will do it first thing on Monday.

@dsavina dsavina force-pushed the feature/native-enum-support branch 2 times, most recently from 95d6076 to 0181ed9 Compare February 14, 2022 16:10
@dsavina
Copy link
Contributor Author

dsavina commented Feb 14, 2022

Hi,

I did all I could, I'm not 100% satisfied but I guess it can be improved afterwards:

  • I had to rollback on using Type annotation instead of EnumType, as it somehow led to the schema trying to declare an object type conflicting with the already existing enum type.
  • I had to disable PHPStan on the files referencing PHP 8.1-specificities (UnitEnum, BackedEnum, enum_exists, ReflectionEnum), since the analysis failed with versions prior to 8.1.
  • Tests are OK whether running with 8.1 or older; I did some gross conditional service declaration to that end, but no shame there :)


// 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().

}

/** @var array<string, class-string<UnitEnum>> */
private $nameToClassMapping;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's put all properties at the top of the class.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Absolutely, my bad (copied from src/Mappers/Root/MyCLabsEnumTypeMapper.php)

@oojacoboo
Copy link
Collaborator

@dsavina thanks again for the work on this. I added a few comments on the code, but overall I think this is good.

I agree that it'd be ideal to use a Type annotation instead of a custom EnumType. Deprecating EnumType now would allow us to tie that directly to the use of the MyCLabs enum. If we support EnumType now for BackedUnum instances, we won't be able to remove it at the same time as MyCLabs support - in the future.

Did you follow the implementation for the MyCLabs enum? Does it have the same object type declaration issue?

@dsavina
Copy link
Contributor Author

dsavina commented Feb 15, 2022

I'm not sure I understand the last question, but my guess is that using Type instead of EnumType on MyCLabs enums would raise the same errors.

I suspect the RecursiveTypeMapper to priorize the mappers differently and pull the rug under the EnumTypeMapper, but this is pure speculation, I have no proof for a strong case here.

@oojacoboo
Copy link
Collaborator

oojacoboo commented Feb 17, 2022

So, regarding my last comment, it's mostly around future deprecation and API planning. If we support EnumType for UnitEnum now, it'll be much harder to deprecate support for EnumType - ever, since people will be relying on it for enums built into PHP. It'll be a hard BC break. Whereas, deprecating EnumType now, targeting removal in the near future, isn't such a hard thing to do because you should migrate to using Type and PHP enums, instead of MyCLabs.

@dsavina
Copy link
Contributor Author

dsavina commented Feb 17, 2022

As I said earlier:

I had to rollback on using Type annotation instead of EnumType, as it somehow led to the schema trying to declare an object type conflicting with the already existing enum type.

To give you more context, my first implementation using Type annotations resulted in the schema analyzer confusing enum fields as object types when running Schema::assertValid():

- GraphQL\Error\InvariantViolation : Schema must contain unique named types but contains multiple types named "Color" (see http://webonyx.github.io/graphql-php/type-system/#type-registry).

Debugging showed that, indeed, in GraphQL\Utils\TypeInfo::extractTypes(), assertion $typeMap[$type->name] === $type fails, since $type is a TypeAnnotatedObjectType, vs $typeMap[$type->name] being the expected EnumType. Sadly, my understanding ends here, I'm clearly not familiar enough with GraphQLite, nor do I have the time to investigate this thoroughly πŸ˜• Perhaps you or @moufmouf will have an idea?

@oojacoboo
Copy link
Collaborator

@dsavina I don't think @moufmouf monitors this lib anymore. I haven't seen any responses from him in quite some time.

Regarding that error, that's a fairly common one. My guess is that a type is being created according to the implementation you had, but also it's assumed, as you state, that it's a standard type and creating one for that as well. It shouldn't be too difficult to add a condition, check if it's an enum, and push the mapping accordingly.

Do you still have the implementation for this? If you can commit that, I'd be happy to have a look - see if I can get it working.

@dsavina dsavina force-pushed the feature/native-enum-support branch from 1aa7ffd to dfec80d Compare February 24, 2022 11:12
@oojacoboo
Copy link
Collaborator

@dsavina I'm not sure what changes you've made on this branch to support the EnumType annotation for native enums. I'd think we'd be left with EnumType fragments if I pushed on here, no?

@oojacoboo
Copy link
Collaborator

oojacoboo commented Mar 29, 2022

@dsavina I've merged and pushed everything onto this PR. As you can see, there is a failing test for an enum. It looks like it's not using the values of the enum properly. If you could please resolve this issue and review everything else, we can go ahead and get this merged in ASAP - target a new release.

Questions:

  • How does the class property of the Type annotation apply with enums? We probably should update annotation documentation on this.
  • Do we have tests covering the @Type annotation with different properties being applied on enums?

@codecov-commenter
Copy link

codecov-commenter commented Mar 30, 2022

Codecov Report

Merging #409 (351619a) into master (f5a0a2b) will decrease coverage by 1.89%.
The diff coverage is 8.13%.

@@             Coverage Diff              @@
##             master     #409      +/-   ##
============================================
- Coverage     98.15%   96.25%   -1.90%     
- Complexity     1590     1625      +35     
============================================
  Files           144      146       +2     
  Lines          4002     4085      +83     
============================================
+ Hits           3928     3932       +4     
- Misses           74      153      +79     
Impacted Files Coverage Ξ”
src/AnnotationReader.php 94.92% <ΓΈ> (ΓΈ)
src/Mappers/CompositeTypeMapper.php 100.00% <ΓΈ> (ΓΈ)
src/Mappers/Root/EnumTypeMapper.php 0.00% <0.00%> (ΓΈ)
src/Types/EnumType.php 0.00% <0.00%> (ΓΈ)
src/Utils/Namespaces/NS.php 91.66% <33.33%> (-8.34%) ⬇️
src/Annotations/EnumType.php 71.42% <50.00%> (-28.58%) ⬇️
src/Annotations/Type.php 94.11% <50.00%> (-5.89%) ⬇️
src/SchemaFactory.php 99.32% <50.00%> (-0.68%) ⬇️
src/Mappers/RecursiveTypeMapper.php 94.81% <100.00%> (ΓΈ)

Continue to review full report at Codecov.

Legend - Click here to learn more
Ξ” = absolute <relative> (impact), ΓΈ = not affected, ? = missing data
Powered by Codecov. Last update f5a0a2b...351619a. Read the comment docs.

@oojacoboo oojacoboo marked this pull request as ready for review April 4, 2022 05:19
@oojacoboo
Copy link
Collaborator

oojacoboo commented Apr 4, 2022

@dsavina there was a bug with the useEnumValues property not registering. That bug has been resolved and tests are now passing. This PR is ready to merge. Please review and let me know on the previous questions...

Questions:

How does the class property of the Type annotation apply with enums? We probably should update annotation documentation on this.
Do we have tests covering the @type annotation with different properties being applied on enums?

@oojacoboo oojacoboo merged commit 44587d3 into thecodingmachine:master Apr 7, 2022
@oojacoboo
Copy link
Collaborator

I've updated the documentation a bit and it's now merged into master. Thanks for kicking things off with this @dsavina and everyone else for your contributions.

@dsavina
Copy link
Contributor Author

dsavina commented Apr 7, 2022

Hi @oojacoboo, sorry for not being available during the past week. This is great news, thank you for the last corrections!

@oojacoboo
Copy link
Collaborator

oojacoboo commented Apr 8, 2022

For the record, I've been able to test this on a rather large API implementation and everything looks good. It's running native Enum and MyCLabs\Enum side-by-side at the moment.

@porozhnyy
Copy link

Hey! Thanks for the great changes! Are there any plans to make a new release with these changes? @oojacoboo @dsavina

@oojacoboo
Copy link
Collaborator

oojacoboo commented Apr 26, 2022

Please see here: #469 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants