From 522c52e744743968abd0803ce53604db68e502a8 Mon Sep 17 00:00:00 2001 From: Vincent Date: Sat, 2 Jan 2021 22:16:11 +0100 Subject: [PATCH 01/23] [WIP] Annotations refactoring & PHP 8 Attributes --- .php_cs.dist | 2 + src/Annotation/Access.php | 13 +- src/Annotation/Arg.php | 21 +- src/Annotation/Deprecated.php | 13 +- src/Annotation/Description.php | 15 +- src/Annotation/Enum.php | 18 +- src/Annotation/EnumValue.php | 21 +- src/Annotation/Field.php | 40 +- src/Annotation/FieldsBuilder.php | 16 +- src/Annotation/Input.php | 20 +- src/Annotation/IsPublic.php | 15 +- src/Annotation/Mutation.php | 32 +- src/Annotation/Provider.php | 19 +- src/Annotation/Query.php | 34 +- src/Annotation/Relay/Connection.php | 20 +- src/Annotation/Relay/Edge.php | 13 +- src/Annotation/Scalar.php | 17 +- src/Annotation/Type.php | 42 +- src/Annotation/TypeInterface.php | 18 +- src/Annotation/Union.php | 23 +- src/Config/Parser/AnnotationParser.php | 1061 +---------------- src/Config/Parser/AttributeParser.php | 32 + .../Parser/MetadataParser/ClassesTypesMap.php | 76 ++ .../Parser/MetadataParser/MetadataParser.php | 938 +++++++++++++++ .../TypeGuesser/DoctrineTypeGuesser.php | 168 +++ .../TypeGuesser/TypeGuesser.php | 23 + .../TypeGuesser/TypeGuesserInterface.php | 13 + .../TypeGuesser/TypeGuessingException.php | 11 + .../TypeGuesser/TypeHintTypeGuesser.php | 84 ++ .../_GraphClass.php} | 4 +- .../Compiler/ConfigParserPass.php | 6 +- tests/Config/Parser/AnnotationParserTest.php | 527 +------- tests/Config/Parser/AttributeParserTest.php | 23 + tests/Config/Parser/MetadataParserTest.php | 552 +++++++++ .../annotations/Deprecated/Deprecated.php | 29 + .../Parser/fixtures/annotations/Enum/Race.php | 11 +- .../fixtures/annotations/Input/Planet.php | 9 + .../annotations/Invalid/InvalidAccess.php | 2 + .../Invalid/InvalidArgumentGuessing.php | 2 + .../InvalidDoctrineRelationGuessing.php | 2 + .../Invalid/InvalidDoctrineTypeGuessing.php | 2 + .../Invalid/InvalidPrivateMethod.php | 2 + .../annotations/Invalid/InvalidProvider.php | 3 + .../Invalid/InvalidReturnTypeGuessing.php | 2 + .../annotations/Invalid/InvalidUnion.php | 1 + .../annotations/Relay/EnemiesConnection.php | 1 + .../annotations/Relay/FriendsConnection.php | 1 + .../Relay/FriendsConnectionEdge.php | 1 + .../Repository/PlanetRepository.php | 43 +- .../Repository/WeaponRepository.php | 4 + .../annotations/Scalar/GalaxyCoordinates.php | 2 + .../fixtures/annotations/Scalar/MyScalar2.php | 1 + .../fixtures/annotations/Type/Animal.php | 5 + .../fixtures/annotations/Type/Armored.php | 2 + .../fixtures/annotations/Type/Battle.php | 3 + .../Parser/fixtures/annotations/Type/Cat.php | 3 + .../fixtures/annotations/Type/Character.php | 8 +- .../fixtures/annotations/Type/Crystal.php | 6 +- .../fixtures/annotations/Type/Droid.php | 3 + .../Parser/fixtures/annotations/Type/Hero.php | 3 + .../fixtures/annotations/Type/Lightsaber.php | 10 + .../fixtures/annotations/Type/Mandalorian.php | 1 + .../fixtures/annotations/Type/Planet.php | 7 + .../annotations/Type/RootMutation.php | 1 + .../annotations/Type/RootMutation2.php | 1 + .../fixtures/annotations/Type/RootQuery.php | 1 + .../fixtures/annotations/Type/RootQuery2.php | 1 + .../Parser/fixtures/annotations/Type/Sith.php | 20 +- .../fixtures/annotations/Union/Killable.php | 1 + .../annotations/Union/SearchResult.php | 2 + .../annotations/Union/SearchResult2.php | 1 + 71 files changed, 2460 insertions(+), 1667 deletions(-) create mode 100644 src/Config/Parser/AttributeParser.php create mode 100644 src/Config/Parser/MetadataParser/ClassesTypesMap.php create mode 100644 src/Config/Parser/MetadataParser/MetadataParser.php create mode 100644 src/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php create mode 100644 src/Config/Parser/MetadataParser/TypeGuesser/TypeGuesser.php create mode 100644 src/Config/Parser/MetadataParser/TypeGuesser/TypeGuesserInterface.php create mode 100644 src/Config/Parser/MetadataParser/TypeGuesser/TypeGuessingException.php create mode 100644 src/Config/Parser/MetadataParser/TypeGuesser/TypeHintTypeGuesser.php rename src/Config/Parser/{Annotation/GraphClass.php => MetadataParser/_GraphClass.php} (95%) create mode 100644 tests/Config/Parser/AttributeParserTest.php create mode 100644 tests/Config/Parser/MetadataParserTest.php create mode 100644 tests/Config/Parser/fixtures/annotations/Deprecated/Deprecated.php diff --git a/.php_cs.dist b/.php_cs.dist index b50b93074..0bf4d3950 100644 --- a/.php_cs.dist +++ b/.php_cs.dist @@ -18,6 +18,8 @@ return PhpCsFixer\Config::create() 'no_superfluous_phpdoc_tags' => ['allow_mixed' => true], 'global_namespace_import' => ['import_functions' => true, 'import_classes' => true, 'import_constants' => true], 'phpdoc_summary' => false, + 'hash_to_slash_comment' => false, + 'single_line_comment_style' => false ] ) ->setFinder($finder) diff --git a/src/Annotation/Access.php b/src/Annotation/Access.php index 4462a204f..a5e42daba 100644 --- a/src/Annotation/Access.php +++ b/src/Annotation/Access.php @@ -4,20 +4,29 @@ namespace Overblog\GraphQLBundle\Annotation; +use Attribute; +use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; + /** * Annotation for GraphQL access on fields. * * @Annotation * @Target({"CLASS", "PROPERTY", "METHOD"}) */ -final class Access implements Annotation +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::TARGET_PROPERTY)] +final class Access implements NamedArgumentConstructorAnnotation, Annotation { /** * Field access. * * @Required - * + * * @var string */ public string $value; + + public function __construct(string $value) + { + $this->value = $value; + } } diff --git a/src/Annotation/Arg.php b/src/Annotation/Arg.php index 970f2c8d0..8c6362397 100644 --- a/src/Annotation/Arg.php +++ b/src/Annotation/Arg.php @@ -4,13 +4,17 @@ namespace Overblog\GraphQLBundle\Annotation; +use \Attribute; +use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; + /** * Annotation for GraphQL argument. * * @Annotation - * @Target("ANNOTATION") + * @Target({"ANNOTATION","PROPERTY","METHOD"}) */ -final class Arg implements Annotation +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +final class Arg implements NamedArgumentConstructorAnnotation, Annotation { /** * Argument name. @@ -26,7 +30,7 @@ final class Arg implements Annotation * * @var string */ - public string $description; + public ?string $description; /** * Argument type. @@ -43,4 +47,15 @@ final class Arg implements Annotation * @var mixed */ public $default; + + /** + * @param mixed|null $default + */ + public function __construct(string $name, string $type, ?string $description = null, $default = null) + { + $this->name = $name; + $this->description = $description; + $this->type = $type; + $this->default = $default; + } } diff --git a/src/Annotation/Deprecated.php b/src/Annotation/Deprecated.php index 4e4efd004..eae3a47e9 100644 --- a/src/Annotation/Deprecated.php +++ b/src/Annotation/Deprecated.php @@ -4,20 +4,29 @@ namespace Overblog\GraphQLBundle\Annotation; +use \Attribute; +use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; + /** * Annotation for GraphQL to mark a field as deprecated. * * @Annotation * @Target({"METHOD", "PROPERTY"}) */ -final class Deprecated implements Annotation +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER | Attribute::TARGET_CLASS_CONSTANT)] +final class Deprecated implements NamedArgumentConstructorAnnotation, Annotation { /** * The deprecation reason. * * @Required - * + * * @var string */ public string $value; + + public function __construct(string $value) + { + $this->value = $value; + } } diff --git a/src/Annotation/Description.php b/src/Annotation/Description.php index a56612c70..e659e6c04 100644 --- a/src/Annotation/Description.php +++ b/src/Annotation/Description.php @@ -4,20 +4,29 @@ namespace Overblog\GraphQLBundle\Annotation; +use \Attribute; +use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; + /** - * Annotation for GraphQL description. + * Annotation for GraphQL to mark a field as deprecated. * * @Annotation * @Target({"CLASS", "METHOD", "PROPERTY"}) */ -final class Description implements Annotation +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER | Attribute::TARGET_CLASS_CONSTANT)] +final class Description implements NamedArgumentConstructorAnnotation, Annotation { /** * The object description. * * @Required - * + * * @var string */ public string $value; + + public function __construct(string $value) + { + $this->value = $value; + } } diff --git a/src/Annotation/Enum.php b/src/Annotation/Enum.php index 5ac03a5af..5eabe5201 100644 --- a/src/Annotation/Enum.php +++ b/src/Annotation/Enum.php @@ -4,23 +4,35 @@ namespace Overblog\GraphQLBundle\Annotation; +use \Attribute; +use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; + /** * Annotation for GraphQL enum. * * @Annotation * @Target("CLASS") */ -final class Enum implements Annotation +#[Attribute(Attribute::TARGET_CLASS)] +final class Enum implements NamedArgumentConstructorAnnotation, Annotation { /** * Enum name. - * + * * @var string */ - public string $name; + public ?string $name; /** * @var array<\Overblog\GraphQLBundle\Annotation\EnumValue> + * + * @deprecated */ public array $values; + + public function __construct(?string $name = null, array $values = []) + { + $this->name = $name; + $this->values = $values; + } } diff --git a/src/Annotation/EnumValue.php b/src/Annotation/EnumValue.php index 0cd08298b..651926e78 100644 --- a/src/Annotation/EnumValue.php +++ b/src/Annotation/EnumValue.php @@ -4,26 +4,37 @@ namespace Overblog\GraphQLBundle\Annotation; +use Attribute; +use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; + /** * Annotation for GraphQL enum value. * * @Annotation - * @Target("ANNOTATION") + * @Target({"ANNOTATION", "CLASS"}) */ -final class EnumValue implements Annotation +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_CLASS_CONSTANT | Attribute::IS_REPEATABLE)] +final class EnumValue implements NamedArgumentConstructorAnnotation, Annotation { /** * @var string */ - public string $name; + public ?string $name; /** * @var string */ - public string $description; + public ?string $description; /** * @var string */ - public string $deprecationReason; + public ?string $deprecationReason; + + public function __construct(?string $name = null, ?string $description = null, ?string $deprecationReason = null) + { + $this->name = $name; + $this->description = $description; + $this->deprecationReason = $deprecationReason; + } } diff --git a/src/Annotation/Field.php b/src/Annotation/Field.php index 6bee5351c..653e800bc 100644 --- a/src/Annotation/Field.php +++ b/src/Annotation/Field.php @@ -4,41 +4,47 @@ namespace Overblog\GraphQLBundle\Annotation; +use \Attribute; +use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; + /** * Annotation for GraphQL field. * * @Annotation * @Target({"PROPERTY", "METHOD"}) */ -class Field implements Annotation +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD)] +class Field implements NamedArgumentConstructorAnnotation, Annotation { /** * The field name. - * + * * @var string */ - public string $name; + public ?string $name; /** * Field Type. - * + * * @var string */ - public string $type; + public ?string $type; /** * Field arguments. * * @var array<\Overblog\GraphQLBundle\Annotation\Arg> + * + * @deprecated */ public array $args = []; /** * Resolver for this property. - * + * * @var string */ - public string $resolve; + public ?string $resolve; /** * Args builder. @@ -59,5 +65,23 @@ class Field implements Annotation * * @var string */ - public $complexity; + public ?string $complexity; + + public function __construct( + ?string $name = null, + ?string $type = null, + array $args = [], + ?string $resolve = null, + $argsBuilder = null, + $fieldBuilder = null, + ?string $complexity = null + ) { + $this->name = $name; + $this->type = $type; + $this->args = $args; + $this->resolve = $resolve; + $this->argsBuilder = $argsBuilder; + $this->fieldBuilder = $fieldBuilder; + $this->complexity = $complexity; + } } diff --git a/src/Annotation/FieldsBuilder.php b/src/Annotation/FieldsBuilder.php index 8e29913f3..1810aa424 100644 --- a/src/Annotation/FieldsBuilder.php +++ b/src/Annotation/FieldsBuilder.php @@ -4,19 +4,23 @@ namespace Overblog\GraphQLBundle\Annotation; +use \Attribute; +use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; + /** * Annotation for GraphQL fields builders. * * @Annotation - * @Target("ANNOTATION") + * @Target({"ANNOTATION", "CLASS"}) */ -final class FieldsBuilder implements Annotation +#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] +final class FieldsBuilder implements NamedArgumentConstructorAnnotation, Annotation { /** * Builder name. * * @Required - * + * * @var string */ public string $builder; @@ -27,4 +31,10 @@ final class FieldsBuilder implements Annotation * @var mixed */ public $builderConfig = []; + + public function __construct(string $builder, array $builderConfig = []) + { + $this->builder = $builder; + $this->builderConfig = $builderConfig; + } } diff --git a/src/Annotation/Input.php b/src/Annotation/Input.php index 1b1dfe119..8ba6fd722 100644 --- a/src/Annotation/Input.php +++ b/src/Annotation/Input.php @@ -4,25 +4,35 @@ namespace Overblog\GraphQLBundle\Annotation; +use \Attribute; +use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; + /** * Annotation for GraphQL input type. * * @Annotation * @Target("CLASS") */ -final class Input implements Annotation +#[Attribute(Attribute::TARGET_CLASS)] +final class Input implements NamedArgumentConstructorAnnotation, Annotation { /** * Type name. - * + * * @var string */ - public string $name; + public ?string $name; /** * Is the type a relay input. - * - * @var bool + * + * @var boolean */ public bool $isRelay = false; + + public function __construct(?string $name = null, bool $isRelay = false) + { + $this->name = $name; + $this->isRelay = $isRelay; + } } diff --git a/src/Annotation/IsPublic.php b/src/Annotation/IsPublic.php index c841894ce..8a1a4d308 100644 --- a/src/Annotation/IsPublic.php +++ b/src/Annotation/IsPublic.php @@ -4,20 +4,29 @@ namespace Overblog\GraphQLBundle\Annotation; +use \Attribute; +use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; + /** * Annotation for GraphQL public on fields. * * @Annotation * @Target({"CLASS", "METHOD", "PROPERTY"}) */ -final class IsPublic implements Annotation +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::TARGET_PROPERTY)] +final class IsPublic implements NamedArgumentConstructorAnnotation, Annotation { - /** + /** * Field publicity. * * @Required - * + * * @var string */ public string $value; + + public function __construct(string $value) + { + $this->value = $value; + } } diff --git a/src/Annotation/Mutation.php b/src/Annotation/Mutation.php index f1dd2b040..7f5176182 100644 --- a/src/Annotation/Mutation.php +++ b/src/Annotation/Mutation.php @@ -4,25 +4,41 @@ namespace Overblog\GraphQLBundle\Annotation; +use Attribute; +use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; + /** * Annotation for GraphQL mutation. * * @Annotation * @Target({"METHOD"}) */ -final class Mutation extends Field +#[Attribute(Attribute::TARGET_METHOD)] +final class Mutation extends Field implements NamedArgumentConstructorAnnotation { - /** - * @var array - * - * @deprecated This property is deprecated since 1.0 and will be removed in 1.1. Use $targetTypes instead. - */ - public array $targetType; - /** * The target types to attach this mutation to (useful when multiple schemas are allowed). * * @var array */ public array $targetTypes; + + public function __construct( + ?string $name = null, + ?string $type = null, + array $args = [], + ?string $resolve = null, + $argsBuilder = null, + $fieldBuilder = null, + ?string $complexity = null, + $targetTypes = null, + $targetType = null + ) { + parent::__construct($name, $type, $args, $resolve, $argsBuilder, $fieldBuilder, $complexity); + if ($targetTypes) { + $this->targetTypes = is_string($targetTypes) ? [$targetTypes] : $targetTypes; + } elseif ($targetType) { + $this->targetTypes = is_string($targetType) ? [$targetType] : $targetType; + } + } } diff --git a/src/Annotation/Provider.php b/src/Annotation/Provider.php index 0b04438bf..c23433704 100644 --- a/src/Annotation/Provider.php +++ b/src/Annotation/Provider.php @@ -4,32 +4,43 @@ namespace Overblog\GraphQLBundle\Annotation; +use \Attribute; +use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; + /** * Annotation for operations provider. * * @Annotation * @Target({"CLASS"}) */ -final class Provider implements Annotation +#[Attribute(Attribute::TARGET_CLASS)] +final class Provider implements NamedArgumentConstructorAnnotation, Annotation { /** * Optionnal prefix for provider fields. * * @var string */ - public string $prefix; + public ?string $prefix; /** * The default target types to attach the provider queries to. * * @var array */ - public array $targetQueryTypes; + public ?array $targetQueryTypes; /** * The default target types to attach the provider mutations to. * * @var array */ - public array $targetMutationTypes; + public ?array $targetMutationTypes; + + public function __construct(?string $prefix = null, $targetQueryTypes = null, $targetMutationTypes = null) + { + $this->prefix = $prefix; + $this->targetQueryTypes = is_string($targetQueryTypes) ? [$targetQueryTypes] : $targetQueryTypes; + $this->targetMutationTypes = is_string($targetMutationTypes) ? [$targetMutationTypes] : $targetMutationTypes; + } } diff --git a/src/Annotation/Query.php b/src/Annotation/Query.php index bd698fef1..05a874753 100644 --- a/src/Annotation/Query.php +++ b/src/Annotation/Query.php @@ -4,25 +4,41 @@ namespace Overblog\GraphQLBundle\Annotation; +use Attribute; +use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; + /** * Annotation for GraphQL query. * * @Annotation * @Target({"METHOD"}) */ -final class Query extends Field +#[Attribute(Attribute::TARGET_METHOD)] +final class Query extends Field implements NamedArgumentConstructorAnnotation { - /** - * @var array - * - * @deprecated This property is deprecated since 1.0 and will be removed in 1.1. Use $targetTypes instead. - */ - public array $targetType; - /** * The target types to attach this query to. * * @var array */ - public array $targetTypes; + public ?array $targetTypes; + + public function __construct( + ?string $name = null, + ?string $type = null, + array $args = [], + ?string $resolve = null, + $argsBuilder = null, + $fieldBuilder = null, + ?string $complexity = null, + $targetTypes = null, + $targetType = null + ) { + parent::__construct($name, $type, $args, $resolve, $argsBuilder, $fieldBuilder, $complexity); + if ($targetTypes) { + $this->targetTypes = is_string($targetTypes) ? [$targetTypes] : $targetTypes; + } elseif ($targetType) { + $this->targetTypes = is_string($targetType) ? [$targetType] : $targetType; + } + } } diff --git a/src/Annotation/Relay/Connection.php b/src/Annotation/Relay/Connection.php index 62657c39c..d3656f774 100644 --- a/src/Annotation/Relay/Connection.php +++ b/src/Annotation/Relay/Connection.php @@ -4,6 +4,9 @@ namespace Overblog\GraphQLBundle\Annotation\Relay; +use \Attribute; +use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; +use Overblog\GraphQLBundle\Annotation\Annotation; use Overblog\GraphQLBundle\Annotation\Type; /** @@ -12,19 +15,26 @@ * @Annotation * @Target("CLASS") */ -final class Connection extends Type +#[Attribute(Attribute::TARGET_CLASS)] +final class Connection extends Type implements NamedArgumentConstructorAnnotation { /** * Connection Edge type. - * + * * @var string */ - public string $edge; + public ?string $edge; /** * Connection Node type. - * + * * @var string */ - public string $node; + public ?string $node; + + public function __construct(string $edge = null, string $node = null) + { + $this->edge = $edge; + $this->node = $node; + } } diff --git a/src/Annotation/Relay/Edge.php b/src/Annotation/Relay/Edge.php index 120288f1c..8d6dff21e 100644 --- a/src/Annotation/Relay/Edge.php +++ b/src/Annotation/Relay/Edge.php @@ -4,6 +4,9 @@ namespace Overblog\GraphQLBundle\Annotation\Relay; +use \Attribute; +use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; +use Overblog\GraphQLBundle\Annotation\Annotation; use Overblog\GraphQLBundle\Annotation\Type; /** @@ -12,14 +15,20 @@ * @Annotation * @Target("CLASS") */ -final class Edge extends Type +#[Attribute(Attribute::TARGET_CLASS)] +final class Edge extends Type implements NamedArgumentConstructorAnnotation { /** * Edge Node type. * * @Required - * + * * @var string */ public string $node; + + public function __construct(string $node) + { + $this->node = $node; + } } diff --git a/src/Annotation/Scalar.php b/src/Annotation/Scalar.php index 89d697a13..edc59c622 100644 --- a/src/Annotation/Scalar.php +++ b/src/Annotation/Scalar.php @@ -4,22 +4,31 @@ namespace Overblog\GraphQLBundle\Annotation; +use \Attribute; +use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; + /** * Annotation for GraphQL scalar. * * @Annotation * @Target("CLASS") */ -final class Scalar implements Annotation +#[Attribute(Attribute::TARGET_CLASS)] +final class Scalar implements NamedArgumentConstructorAnnotation, Annotation { /** * @var string */ - public string $name; - + public ?string $name; /** * @var string */ - public string $scalarType; + public ?string $scalarType; + + public function __construct(?string $name = null, ?string $scalarType = null) + { + $this->name = $name; + $this->scalarType = $scalarType; + } } diff --git a/src/Annotation/Type.php b/src/Annotation/Type.php index 6f7982ccc..01003382b 100644 --- a/src/Annotation/Type.php +++ b/src/Annotation/Type.php @@ -4,53 +4,75 @@ namespace Overblog\GraphQLBundle\Annotation; +use \Attribute; +use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; + /** * Annotation for GraphQL type. * * @Annotation * @Target("CLASS") */ -class Type implements Annotation +#[Attribute(Attribute::TARGET_CLASS)] +class Type implements NamedArgumentConstructorAnnotation, Annotation { /** * Type name. - * + * * @var string */ - public string $name; + public ?string $name; /** * Type inherited interfaces. * * @var string[] */ - public array $interfaces; + public array $interfaces = []; /** * Is the type a relay payload. - * - * @var bool + * + * @var boolean */ public bool $isRelay = false; /** * Expression to a target fields resolver. - * + * * @var string */ - public string $resolveField; + public ?string $resolveField; /** * List of fields builder. * * @var array<\Overblog\GraphQLBundle\Annotation\FieldsBuilder> + * + * @deprecated */ public array $builders = []; /** * Expression to resolve type for interfaces. - * + * * @var string */ - public string $isTypeOf; + public ?string $isTypeOf; + + public function __construct( + ?string $name = null, + array $interfaces = [], + bool $isRelay = false, + ?string $resolveField = null, + array $builders = [], + ?string $isTypeOf = null + ) { + $this->name = $name; + $this->interfaces = $interfaces; + $this->isRelay = $isRelay; + $this->resolveField = $resolveField; + $this->builders = $builders; + $this->isTypeOf = $isTypeOf; + } } diff --git a/src/Annotation/TypeInterface.php b/src/Annotation/TypeInterface.php index 0720a0d60..f6ef76456 100644 --- a/src/Annotation/TypeInterface.php +++ b/src/Annotation/TypeInterface.php @@ -4,27 +4,37 @@ namespace Overblog\GraphQLBundle\Annotation; +use \Attribute; +use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; + /** * Annotation for GraphQL interface. * * @Annotation * @Target("CLASS") */ -final class TypeInterface implements Annotation +#[Attribute(Attribute::TARGET_CLASS)] +final class TypeInterface implements NamedArgumentConstructorAnnotation, Annotation { /** * Interface name. - * + * * @var string */ - public string $name; + public ?string $name; /** * Resolver type for interface. * * @Required - * + * * @var string */ public string $resolveType; + + public function __construct(?string $name = null, string $resolveType) + { + $this->name = $name; + $this->resolveType = $resolveType; + } } diff --git a/src/Annotation/Union.php b/src/Annotation/Union.php index 7af0ef6f0..5bd5c38f3 100644 --- a/src/Annotation/Union.php +++ b/src/Annotation/Union.php @@ -4,32 +4,43 @@ namespace Overblog\GraphQLBundle\Annotation; +use \Attribute; +use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; + /** * Annotation for GraphQL union. * * @Annotation * @Target("CLASS") */ -final class Union implements Annotation +#[Attribute(Attribute::TARGET_CLASS)] +final class Union implements NamedArgumentConstructorAnnotation, Annotation { /** * Union name. - * + * * @var string */ - public string $name; + public ?string $name; /** * Union types. * * @var array */ - public array $types; + public array $types = []; /** * Resolver type for union. - * + * * @var string */ - public string $resolveType; + public ?string $resolveType; + + public function __construct(?string $name = null, array $types = [], ?string $resolveType = null) + { + $this->name = $name; + $this->types = $types; + $this->resolveType = $resolveType; + } } diff --git a/src/Config/Parser/AnnotationParser.php b/src/Config/Parser/AnnotationParser.php index 8ca6a6901..5ab3d9102 100644 --- a/src/Config/Parser/AnnotationParser.php +++ b/src/Config/Parser/AnnotationParser.php @@ -4,1065 +4,46 @@ namespace Overblog\GraphQLBundle\Config\Parser; -use Doctrine\Common\Annotations\AnnotationException; -use Doctrine\ORM\Mapping\Column; -use Doctrine\ORM\Mapping\JoinColumn; -use Doctrine\ORM\Mapping\ManyToMany; -use Doctrine\ORM\Mapping\ManyToOne; -use Doctrine\ORM\Mapping\OneToMany; -use Doctrine\ORM\Mapping\OneToOne; -use Exception; -use Overblog\GraphQLBundle\Annotation as GQL; -use Overblog\GraphQLBundle\Config\Parser\Annotation\GraphClass; -use Overblog\GraphQLBundle\Relay\Connection\ConnectionInterface; -use Overblog\GraphQLBundle\Relay\Connection\EdgeInterface; -use ReflectionException; +use Doctrine\Common\Annotations\AnnotationReader; +use Doctrine\Common\Annotations\AnnotationRegistry; +use Overblog\GraphQLBundle\Config\Parser\MetadataParser\MetadataParser; +use ReflectionClass; use ReflectionMethod; -use ReflectionNamedType; use ReflectionProperty; use Reflector; use RuntimeException; -use SplFileInfo; -use Symfony\Component\Config\Resource\FileResource; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; -use function array_filter; -use function array_keys; -use function array_map; -use function array_unshift; -use function current; -use function file_get_contents; -use function get_class; -use function implode; -use function in_array; -use function is_array; -use function is_string; -use function preg_match; -use function sprintf; -use function str_replace; -use function strlen; -use function strpos; -use function substr; -use function trim; -class AnnotationParser implements PreParserInterface +class AnnotationParser extends MetadataParser { - private static array $classesMap = []; - private static array $providers = []; - private static array $doctrineMapping = []; - private static array $graphClassCache = []; + const METADATA_FORMAT = '@%s'; - private const GQL_SCALAR = 'scalar'; - private const GQL_ENUM = 'enum'; - private const GQL_TYPE = 'type'; - private const GQL_INPUT = 'input'; - private const GQL_UNION = 'union'; - private const GQL_INTERFACE = 'interface'; + protected static ?AnnotationReader $annotationReader = null; - /** - * @see https://facebook.github.io/graphql/draft/#sec-Input-and-Output-Types - */ - private const VALID_INPUT_TYPES = [self::GQL_SCALAR, self::GQL_ENUM, self::GQL_INPUT]; - private const VALID_OUTPUT_TYPES = [self::GQL_SCALAR, self::GQL_TYPE, self::GQL_INTERFACE, self::GQL_UNION, self::GQL_ENUM]; - - /** - * {@inheritdoc} - * - * @throws InvalidArgumentException - * @throws ReflectionException - */ - public static function preParse(SplFileInfo $file, ContainerBuilder $container, array $configs = []): void - { - $container->setParameter('overblog_graphql_types.classes_map', self::processFile($file, $container, $configs, true)); - } - - /** - * @throws InvalidArgumentException - * @throws ReflectionException - */ - public static function parse(SplFileInfo $file, ContainerBuilder $container, array $configs = []): array - { - return self::processFile($file, $container, $configs, false); - } - - /** - * @internal - */ - public static function reset(): void - { - self::$classesMap = []; - self::$providers = []; - self::$graphClassCache = []; - } - - /** - * Process a file. - * - * @throws InvalidArgumentException|ReflectionException|AnnotationException - */ - private static function processFile(SplFileInfo $file, ContainerBuilder $container, array $configs, bool $preProcess): array + protected static function getMetadatas(Reflector $reflector): array { - self::$doctrineMapping = $configs['doctrine']['types_mapping']; - $container->addResource(new FileResource($file->getRealPath())); - - try { - $className = $file->getBasename('.php'); - if (preg_match('#namespace (.+);#', file_get_contents($file->getRealPath()), $matches)) { - $className = trim($matches[1]).'\\'.$className; - } - - $gqlTypes = []; - $graphClass = self::getGraphClass($className); - - foreach ($graphClass->getAnnotations() as $classAnnotation) { - $gqlTypes = self::classAnnotationsToGQLConfiguration( - $graphClass, - $classAnnotation, - $configs, - $gqlTypes, - $preProcess - ); - } - - return $preProcess ? self::$classesMap : $gqlTypes; - } catch (\InvalidArgumentException $e) { - throw new InvalidArgumentException(sprintf('Failed to parse GraphQL annotations from file "%s".', $file), $e->getCode(), $e); - } - } - - private static function classAnnotationsToGQLConfiguration( - GraphClass $graphClass, - object $classAnnotation, - array $configs, - array $gqlTypes, - bool $preProcess - ): array { - $gqlConfiguration = $gqlType = $gqlName = null; + $reader = self::getAnnotationReader(); switch (true) { - case $classAnnotation instanceof GQL\Type: - $gqlType = self::GQL_TYPE; - $gqlName = $classAnnotation->name ?? $graphClass->getShortName(); - if (!$preProcess) { - $gqlConfiguration = self::typeAnnotationToGQLConfiguration($graphClass, $classAnnotation, $gqlName, $configs); - - if ($classAnnotation instanceof GQL\Relay\Connection) { - if (!$graphClass->implementsInterface(ConnectionInterface::class)) { - throw new InvalidArgumentException(sprintf('The annotation @Connection on class "%s" can only be used on class implementing the ConnectionInterface.', $graphClass->getName())); - } - - if (!(isset($classAnnotation->edge) xor isset($classAnnotation->node))) { - throw new InvalidArgumentException(sprintf('The annotation @Connection on class "%s" is invalid. You must define either the "edge" OR the "node" attribute, but not both.', $graphClass->getName())); - } - - $edgeType = $classAnnotation->edge ?? false; - if (!$edgeType) { - $edgeType = $gqlName.'Edge'; - $gqlTypes[$edgeType] = [ - 'type' => 'object', - 'config' => [ - 'builders' => [ - ['builder' => 'relay-edge', 'builderConfig' => ['nodeType' => $classAnnotation->node]], - ], - ], - ]; - } - - if (!isset($gqlConfiguration['config']['builders'])) { - $gqlConfiguration['config']['builders'] = []; - } - - array_unshift($gqlConfiguration['config']['builders'], ['builder' => 'relay-connection', 'builderConfig' => ['edgeType' => $edgeType]]); - } - } - break; - - case $classAnnotation instanceof GQL\Input: - $gqlType = self::GQL_INPUT; - $gqlName = $classAnnotation->name ?? self::suffixName($graphClass->getShortName(), 'Input'); - if (!$preProcess) { - $gqlConfiguration = self::inputAnnotationToGQLConfiguration($graphClass, $classAnnotation); - } - break; - - case $classAnnotation instanceof GQL\Scalar: - $gqlType = self::GQL_SCALAR; - if (!$preProcess) { - $gqlConfiguration = self::scalarAnnotationToGQLConfiguration($graphClass, $classAnnotation); - } - break; - - case $classAnnotation instanceof GQL\Enum: - $gqlType = self::GQL_ENUM; - if (!$preProcess) { - $gqlConfiguration = self::enumAnnotationToGQLConfiguration($graphClass, $classAnnotation); - } - break; - - case $classAnnotation instanceof GQL\Union: - $gqlType = self::GQL_UNION; - if (!$preProcess) { - $gqlConfiguration = self::unionAnnotationToGQLConfiguration($graphClass, $classAnnotation); - } - break; - - case $classAnnotation instanceof GQL\TypeInterface: - $gqlType = self::GQL_INTERFACE; - if (!$preProcess) { - $gqlConfiguration = self::typeInterfaceAnnotationToGQLConfiguration($graphClass, $classAnnotation); - } - break; - - case $classAnnotation instanceof GQL\Provider: - if ($preProcess) { - self::$providers[] = ['metadata' => $graphClass, 'annotation' => $classAnnotation]; - } - - return []; - } - - if (null !== $gqlType) { - if (!$gqlName) { - $gqlName = isset($classAnnotation->name) ? $classAnnotation->name : $graphClass->getShortName(); - } - - if ($preProcess) { - if (isset(self::$classesMap[$gqlName])) { - throw new InvalidArgumentException(sprintf('The GraphQL type "%s" has already been registered in class "%s"', $gqlName, self::$classesMap[$gqlName]['class'])); - } - self::$classesMap[$gqlName] = ['type' => $gqlType, 'class' => $graphClass->getName()]; - } else { - $gqlTypes = [$gqlName => $gqlConfiguration] + $gqlTypes; - } - } - - return $gqlTypes; - } - - /** - * @throws ReflectionException - */ - private static function getGraphClass(string $className): GraphClass - { - self::$graphClassCache[$className] ??= new GraphClass($className); - - return self::$graphClassCache[$className]; - } - - private static function typeAnnotationToGQLConfiguration( - GraphClass $graphClass, - GQL\Type $classAnnotation, - string $gqlName, - array $configs - ): array { - $isMutation = $isDefault = $isRoot = false; - if (isset($configs['definitions']['schema'])) { - $defaultSchemaName = isset($configs['definitions']['schema']['default']) ? 'default' : array_key_first($configs['definitions']['schema']); - foreach ($configs['definitions']['schema'] as $schemaName => $schema) { - $schemaQuery = $schema['query'] ?? null; - $schemaMutation = $schema['mutation'] ?? null; - - if ($gqlName === $schemaQuery) { - $isRoot = true; - if ($defaultSchemaName === $schemaName) { - $isDefault = true; - } - } elseif ($gqlName === $schemaMutation) { - $isMutation = true; - $isRoot = true; - if ($defaultSchemaName === $schemaName) { - $isDefault = true; - } - } - } - } - - $currentValue = $isRoot ? sprintf("service('%s')", self::formatNamespaceForExpression($graphClass->getName())) : 'value'; - - $gqlConfiguration = self::graphQLTypeConfigFromAnnotation($graphClass, $classAnnotation, $currentValue); - - $providerFields = self::getGraphQLFieldsFromProviders($graphClass, $isMutation ? GQL\Mutation::class : GQL\Query::class, $gqlName, $isDefault); - $gqlConfiguration['config']['fields'] = array_merge($gqlConfiguration['config']['fields'], $providerFields); - - if ($classAnnotation instanceof GQL\Relay\Edge) { - if (!$graphClass->implementsInterface(EdgeInterface::class)) { - throw new InvalidArgumentException(sprintf('The annotation @Edge on class "%s" can only be used on class implementing the EdgeInterface.', $graphClass->getName())); - } - if (!isset($gqlConfiguration['config']['builders'])) { - $gqlConfiguration['config']['builders'] = []; - } - array_unshift($gqlConfiguration['config']['builders'], ['builder' => 'relay-edge', 'builderConfig' => ['nodeType' => $classAnnotation->node]]); - } - - return $gqlConfiguration; - } - - private static function graphQLTypeConfigFromAnnotation(GraphClass $graphClass, GQL\Type $typeAnnotation, string $currentValue): array - { - $typeConfiguration = []; - $fieldsFromProperties = self::getGraphQLTypeFieldsFromAnnotations($graphClass, $graphClass->getPropertiesExtended(), GQL\Field::class, $currentValue); - $fieldsFromMethods = self::getGraphQLTypeFieldsFromAnnotations($graphClass, $graphClass->getMethods(), GQL\Field::class, $currentValue); - - $typeConfiguration['fields'] = array_merge($fieldsFromProperties, $fieldsFromMethods); - $typeConfiguration = self::getDescriptionConfiguration($graphClass->getAnnotations()) + $typeConfiguration; - - if (isset($typeAnnotation->interfaces)) { - $typeConfiguration['interfaces'] = $typeAnnotation->interfaces; - } else { - $interfaces = array_keys(self::searchClassesMapBy(function ($gqlType, $configuration) use ($graphClass) { - ['class' => $interfaceClassName] = $configuration; - - $interfaceMetadata = self::getGraphClass($interfaceClassName); - if ($interfaceMetadata->isInterface() && $graphClass->implementsInterface($interfaceMetadata->getName())) { - return true; - } - - return $graphClass->isSubclassOf($interfaceClassName); - }, self::GQL_INTERFACE)); - - sort($interfaces); - $typeConfiguration['interfaces'] = $interfaces; - } - - if (isset($typeAnnotation->resolveField)) { - $typeConfiguration['resolveField'] = self::formatExpression($typeAnnotation->resolveField); - } - - if (isset($typeAnnotation->builders) && !empty($typeAnnotation->builders)) { - $typeConfiguration['builders'] = array_map(function ($fieldsBuilderAnnotation) { - return ['builder' => $fieldsBuilderAnnotation->builder, 'builderConfig' => $fieldsBuilderAnnotation->builderConfig]; - }, $typeAnnotation->builders); - } - - if (isset($typeAnnotation->isTypeOf)) { - $typeConfiguration['isTypeOf'] = $typeAnnotation->isTypeOf; - } - - $publicAnnotation = self::getFirstAnnotationMatching($graphClass->getAnnotations(), GQL\IsPublic::class); - if (null !== $publicAnnotation) { - $typeConfiguration['fieldsDefaultPublic'] = self::formatExpression($publicAnnotation->value); - } - - $accessAnnotation = self::getFirstAnnotationMatching($graphClass->getAnnotations(), GQL\Access::class); - if (null !== $accessAnnotation) { - $typeConfiguration['fieldsDefaultAccess'] = self::formatExpression($accessAnnotation->value); - } - - return ['type' => $typeAnnotation->isRelay ? 'relay-mutation-payload' : 'object', 'config' => $typeConfiguration]; - } - - /** - * Create a GraphQL Interface type configuration from annotations on properties. - */ - private static function typeInterfaceAnnotationToGQLConfiguration(GraphClass $graphClass, GQL\TypeInterface $interfaceAnnotation): array - { - $interfaceConfiguration = []; - - $fieldsFromProperties = self::getGraphQLTypeFieldsFromAnnotations($graphClass, $graphClass->getPropertiesExtended()); - $fieldsFromMethods = self::getGraphQLTypeFieldsFromAnnotations($graphClass, $graphClass->getMethods()); - - $interfaceConfiguration['fields'] = array_merge($fieldsFromProperties, $fieldsFromMethods); - $interfaceConfiguration = self::getDescriptionConfiguration($graphClass->getAnnotations()) + $interfaceConfiguration; - - $interfaceConfiguration['resolveType'] = self::formatExpression($interfaceAnnotation->resolveType); - - return ['type' => 'interface', 'config' => $interfaceConfiguration]; - } - - /** - * Create a GraphQL Input type configuration from annotations on properties. - */ - private static function inputAnnotationToGQLConfiguration(GraphClass $graphClass, GQL\Input $inputAnnotation): array - { - $inputConfiguration = array_merge([ - 'fields' => self::getGraphQLInputFieldsFromAnnotations($graphClass, $graphClass->getPropertiesExtended()), - ], self::getDescriptionConfiguration($graphClass->getAnnotations())); - - return ['type' => $inputAnnotation->isRelay ? 'relay-mutation-input' : 'input-object', 'config' => $inputConfiguration]; - } - - /** - * Get a GraphQL scalar configuration from given scalar annotation. - */ - private static function scalarAnnotationToGQLConfiguration(GraphClass $graphClass, GQL\Scalar $scalarAnnotation): array - { - $scalarConfiguration = []; - - if (isset($scalarAnnotation->scalarType)) { - $scalarConfiguration['scalarType'] = self::formatExpression($scalarAnnotation->scalarType); - } else { - $scalarConfiguration = [ - 'serialize' => [$graphClass->getName(), 'serialize'], - 'parseValue' => [$graphClass->getName(), 'parseValue'], - 'parseLiteral' => [$graphClass->getName(), 'parseLiteral'], - ]; - } - - $scalarConfiguration = self::getDescriptionConfiguration($graphClass->getAnnotations()) + $scalarConfiguration; - - return ['type' => 'custom-scalar', 'config' => $scalarConfiguration]; - } - - /** - * Get a GraphQL Enum configuration from given enum annotation. - */ - private static function enumAnnotationToGQLConfiguration(GraphClass $graphClass, GQL\Enum $enumAnnotation): array - { - $enumValues = $enumAnnotation->values ?? []; - - $values = []; - - foreach ($graphClass->getConstants() as $name => $value) { - $valueAnnotation = current(array_filter($enumValues, fn ($enumValueAnnotation) => $enumValueAnnotation->name == $name)); - $valueConfig = []; - $valueConfig['value'] = $value; - - if ($valueAnnotation && isset($valueAnnotation->description)) { - $valueConfig['description'] = $valueAnnotation->description; - } - - if ($valueAnnotation && isset($valueAnnotation->deprecationReason)) { - $valueConfig['deprecationReason'] = $valueAnnotation->deprecationReason; - } - - $values[$name] = $valueConfig; - } - - $enumConfiguration = ['values' => $values]; - $enumConfiguration = self::getDescriptionConfiguration($graphClass->getAnnotations()) + $enumConfiguration; - - return ['type' => 'enum', 'config' => $enumConfiguration]; - } - - /** - * Get a GraphQL Union configuration from given union annotation. - */ - private static function unionAnnotationToGQLConfiguration(GraphClass $graphClass, GQL\Union $unionAnnotation): array - { - $unionConfiguration = []; - if (isset($unionAnnotation->types)) { - $unionConfiguration['types'] = $unionAnnotation->types; - } else { - $types = array_keys(self::searchClassesMapBy(function ($gqlType, $configuration) use ($graphClass) { - $typeClassName = $configuration['class']; - $typeMetadata = self::getGraphClass($typeClassName); - - if ($graphClass->isInterface() && $typeMetadata->implementsInterface($graphClass->getName())) { - return true; - } - - return $typeMetadata->isSubclassOf($graphClass->getName()); - }, self::GQL_TYPE)); - sort($types); - $unionConfiguration['types'] = $types; - } - - $unionConfiguration = self::getDescriptionConfiguration($graphClass->getAnnotations()) + $unionConfiguration; - - if (isset($unionAnnotation->resolveType)) { - $unionConfiguration['resolveType'] = self::formatExpression($unionAnnotation->resolveType); - } else { - if ($graphClass->hasMethod('resolveType')) { - $method = $graphClass->getMethod('resolveType'); - if ($method->isStatic() && $method->isPublic()) { - $unionConfiguration['resolveType'] = self::formatExpression(sprintf("@=call('%s::%s', [service('overblog_graphql.type_resolver'), value], true)", self::formatNamespaceForExpression($graphClass->getName()), 'resolveType')); - } else { - throw new InvalidArgumentException(sprintf('The "resolveType()" method on class must be static and public. Or you must define a "resolveType" attribute on the @Union annotation.')); - } - } else { - throw new InvalidArgumentException(sprintf('The annotation @Union has no "resolveType" attribute and the related class has no "resolveType()" public static method. You need to define of them.')); - } - } - - return ['type' => 'union', 'config' => $unionConfiguration]; - } - - /** - * @phpstan-param ReflectionMethod|ReflectionProperty $reflector - * @phpstan-param class-string $fieldAnnotationName - * - * @throws AnnotationException - */ - private static function getTypeFieldConfigurationFromReflector(GraphClass $graphClass, Reflector $reflector, string $fieldAnnotationName, string $currentValue = 'value'): array - { - $annotations = $graphClass->getAnnotations($reflector); - - $fieldAnnotation = self::getFirstAnnotationMatching($annotations, $fieldAnnotationName); - $accessAnnotation = self::getFirstAnnotationMatching($annotations, GQL\Access::class); - $publicAnnotation = self::getFirstAnnotationMatching($annotations, GQL\IsPublic::class); - - if (null === $fieldAnnotation) { - if (null !== $accessAnnotation || null !== $publicAnnotation) { - throw new InvalidArgumentException(sprintf('The annotations "@Access" and/or "@Visible" defined on "%s" are only usable in addition of annotation "@Field"', $reflector->getName())); - } - - return []; - } - - if ($reflector instanceof ReflectionMethod && !$reflector->isPublic()) { - throw new InvalidArgumentException(sprintf('The Annotation "@Field" can only be applied to public method. The method "%s" is not public.', $reflector->getName())); - } - - $fieldName = $reflector->getName(); - $fieldConfiguration = []; - - if (isset($fieldAnnotation->type)) { - $fieldConfiguration['type'] = $fieldAnnotation->type; - } - - $fieldConfiguration = self::getDescriptionConfiguration($annotations, true) + $fieldConfiguration; - - $args = []; - - foreach ($fieldAnnotation->args as $arg) { - $args[$arg->name] = ['type' => $arg->type]; - - if (isset($arg->description)) { - $args[$arg->name]['description'] = $arg->description; - } - - if (isset($arg->default)) { - $args[$arg->name]['defaultValue'] = $arg->default; - } - } - - if (empty($fieldAnnotation->args) && $reflector instanceof ReflectionMethod) { - $args = self::guessArgs($reflector); - } - - if (!empty($args)) { - $fieldConfiguration['args'] = $args; - } - - $fieldName = $fieldAnnotation->name ?? $fieldName; - - if (isset($fieldAnnotation->resolve)) { - $fieldConfiguration['resolve'] = self::formatExpression($fieldAnnotation->resolve); - } else { - if ($reflector instanceof ReflectionMethod) { - $fieldConfiguration['resolve'] = self::formatExpression(sprintf('call(%s.%s, %s)', $currentValue, $reflector->getName(), self::formatArgsForExpression($args))); - } else { - if ($fieldName !== $reflector->getName() || 'value' !== $currentValue) { - $fieldConfiguration['resolve'] = self::formatExpression(sprintf('%s.%s', $currentValue, $reflector->getName())); - } - } - } - - if ($fieldAnnotation->argsBuilder) { - if (is_string($fieldAnnotation->argsBuilder)) { - $fieldConfiguration['argsBuilder'] = $fieldAnnotation->argsBuilder; - } elseif (is_array($fieldAnnotation->argsBuilder)) { - list($builder, $builderConfig) = $fieldAnnotation->argsBuilder; - $fieldConfiguration['argsBuilder'] = ['builder' => $builder, 'config' => $builderConfig]; - } else { - throw new InvalidArgumentException(sprintf('The attribute "argsBuilder" on GraphQL annotation "@%s" defined on "%s" must be a string or an array where first index is the builder name and the second is the config.', $fieldAnnotationName, $reflector->getName())); - } - } - - if ($fieldAnnotation->fieldBuilder) { - if (is_string($fieldAnnotation->fieldBuilder)) { - $fieldConfiguration['builder'] = $fieldAnnotation->fieldBuilder; - } elseif (is_array($fieldAnnotation->fieldBuilder)) { - list($builder, $builderConfig) = $fieldAnnotation->fieldBuilder; - $fieldConfiguration['builder'] = $builder; - $fieldConfiguration['builderConfig'] = $builderConfig ?: []; - } else { - throw new InvalidArgumentException(sprintf('The attribute "fieldBuilder" on GraphQL annotation "@%s" defined on "%s" must be a string or an array where first index is the builder name and the second is the config.', $fieldAnnotationName, $reflector->getName())); - } - } else { - if (!isset($fieldAnnotation->type)) { - if ($reflector instanceof ReflectionMethod) { - /** @var ReflectionMethod $reflector */ - if ($reflector->hasReturnType()) { - try { - // @phpstan-ignore-next-line - $fieldConfiguration['type'] = self::resolveGraphQLTypeFromReflectionType($reflector->getReturnType(), self::VALID_OUTPUT_TYPES); - } catch (Exception $e) { - throw new InvalidArgumentException(sprintf('The attribute "type" on GraphQL annotation "@%s" is missing on method "%s" and cannot be auto-guessed from type hint "%s"', $fieldAnnotationName, $reflector->getName(), (string) $reflector->getReturnType())); - } - } else { - throw new InvalidArgumentException(sprintf('The attribute "type" on GraphQL annotation "@%s" is missing on method "%s" and cannot be auto-guessed as there is not return type hint.', $fieldAnnotationName, $reflector->getName())); - } - } else { - try { - $fieldConfiguration['type'] = self::guessType($graphClass, $reflector, self::VALID_OUTPUT_TYPES); - } catch (Exception $e) { - throw new InvalidArgumentException(sprintf('The attribute "type" on "@%s" defined on "%s" is required and cannot be auto-guessed : %s.', $fieldAnnotationName, $reflector->getName(), $e->getMessage())); - } - } - } - } - - if ($accessAnnotation) { - $fieldConfiguration['access'] = self::formatExpression($accessAnnotation->value); - } - - if ($publicAnnotation) { - $fieldConfiguration['public'] = self::formatExpression($publicAnnotation->value); - } - - if ($fieldAnnotation->complexity) { - $fieldConfiguration['complexity'] = self::formatExpression($fieldAnnotation->complexity); - } - - return [$fieldName => $fieldConfiguration]; - } - - /** - * Create GraphQL input fields configuration based on annotations. - * - * @param ReflectionProperty[] $reflectors - * - * @throws AnnotationException - */ - private static function getGraphQLInputFieldsFromAnnotations(GraphClass $graphClass, array $reflectors): array - { - $fields = []; - - foreach ($reflectors as $reflector) { - $annotations = $graphClass->getAnnotations($reflector); - - /** @var GQL\Field|null $fieldAnnotation */ - $fieldAnnotation = self::getFirstAnnotationMatching($annotations, GQL\Field::class); - - // No field annotation found - if (null === $fieldAnnotation) { - continue; - } - - // Ignore field with resolver when the type is an Input - if (isset($fieldAnnotation->resolve)) { - continue; - } - - $fieldName = $reflector->getName(); - if (isset($fieldAnnotation->type)) { - $fieldType = $fieldAnnotation->type; - } else { - try { - $fieldType = self::guessType($graphClass, $reflector, self::VALID_INPUT_TYPES); - } catch (Exception $e) { - throw new InvalidArgumentException(sprintf('The attribute "type" on GraphQL annotation "@%s" is missing on property "%s" and cannot be auto-guessed as there is no type hint or Doctrine annotation.', GQL\Field::class, $reflector->getName())); - } - } - $fieldConfiguration = []; - if ($fieldType) { - // Resolve a PHP class from a GraphQL type - $resolvedType = self::$classesMap[$fieldType] ?? null; - // We found a type but it is not allowed - if (null !== $resolvedType && !in_array($resolvedType['type'], self::VALID_INPUT_TYPES)) { - throw new InvalidArgumentException(sprintf('The type "%s" on "%s" is a "%s" not valid on an Input @Field. Only Input, Scalar and Enum are allowed.', $fieldType, $reflector->getName(), $resolvedType['type'])); - } - - $fieldConfiguration['type'] = $fieldType; - } - - $fieldConfiguration = array_merge(self::getDescriptionConfiguration($annotations, true), $fieldConfiguration); - $fields[$fieldName] = $fieldConfiguration; - } - - return $fields; - } - - /** - * Create GraphQL type fields configuration based on annotations. - * - * @phpstan-param class-string $fieldAnnotationName - * - * @param ReflectionProperty[]|ReflectionMethod[] $reflectors - * - * @throws AnnotationException - */ - private static function getGraphQLTypeFieldsFromAnnotations(GraphClass $graphClass, array $reflectors, string $fieldAnnotationName = GQL\Field::class, string $currentValue = 'value'): array - { - $fields = []; - - foreach ($reflectors as $reflector) { - $fields = array_merge($fields, self::getTypeFieldConfigurationFromReflector($graphClass, $reflector, $fieldAnnotationName, $currentValue)); - } - - return $fields; - } - - /** - * @phpstan-param class-string $expectedAnnotation - * - * Return fields config from Provider methods. - * Loop through configured provider and extract fields targeting the targetType. - */ - private static function getGraphQLFieldsFromProviders(GraphClass $graphClass, string $expectedAnnotation, string $targetType, bool $isDefaultTarget = false): array - { - $fields = []; - foreach (self::$providers as ['metadata' => $providerMetadata, 'annotation' => $providerAnnotation]) { - $defaultAccessAnnotation = self::getFirstAnnotationMatching($providerMetadata->getAnnotations(), GQL\Access::class); - $defaultIsPublicAnnotation = self::getFirstAnnotationMatching($providerMetadata->getAnnotations(), GQL\IsPublic::class); - - $defaultAccess = $defaultAccessAnnotation ? self::formatExpression($defaultAccessAnnotation->value) : false; - $defaultIsPublic = $defaultIsPublicAnnotation ? self::formatExpression($defaultIsPublicAnnotation->value) : false; - - $methods = []; - // First found the methods matching the targeted type - foreach ($providerMetadata->getMethods() as $method) { - $annotations = $providerMetadata->getAnnotations($method); - - $annotation = self::getFirstAnnotationMatching($annotations, [GQL\Mutation::class, GQL\Query::class]); - if (null === $annotation) { - continue; - } - - // TODO: Remove old property check in 1.1 - $annotationTargets = $annotation->targetTypes ?? $annotation->targetType ?? null; - - if (null === $annotationTargets) { - if ($annotation instanceof GQL\Mutation && isset($providerAnnotation->targetMutationTypes)) { - $annotationTargets = $providerAnnotation->targetMutationTypes; - } elseif ($annotation instanceof GQL\Query && isset($providerAnnotation->targetQueryTypes)) { - $annotationTargets = $providerAnnotation->targetQueryTypes; - } - } - - if (null === $annotationTargets) { - if ($isDefaultTarget) { - $annotationTargets = [$targetType]; - if (!$annotation instanceof $expectedAnnotation) { - continue; - } - } else { - continue; - } - } - - if (!in_array($targetType, $annotationTargets)) { - continue; - } - - if (!$annotation instanceof $expectedAnnotation) { - if (GQL\Mutation::class === $expectedAnnotation) { - $message = sprintf('The provider "%s" try to add a query field on type "%s" (through @Query on method "%s") but "%s" is a mutation.', $providerMetadata->getName(), $targetType, $method->getName(), $targetType); - } else { - $message = sprintf('The provider "%s" try to add a mutation on type "%s" (through @Mutation on method "%s") but "%s" is not a mutation.', $providerMetadata->getName(), $targetType, $method->getName(), $targetType); - } - - throw new InvalidArgumentException($message); - } - $methods[$method->getName()] = $method; - } - - $currentValue = sprintf("service('%s')", self::formatNamespaceForExpression($providerMetadata->getName())); - $providerFields = self::getGraphQLTypeFieldsFromAnnotations($graphClass, $methods, $expectedAnnotation, $currentValue); - foreach ($providerFields as $fieldName => $fieldConfig) { - if (isset($providerAnnotation->prefix)) { - $fieldName = sprintf('%s%s', $providerAnnotation->prefix, $fieldName); - } - - if ($defaultAccess && !isset($fieldConfig['access'])) { - $fieldConfig['access'] = $defaultAccess; - } - - if ($defaultIsPublic && !isset($fieldConfig['public'])) { - $fieldConfig['public'] = $defaultIsPublic; - } - - $fields[$fieldName] = $fieldConfig; - } + case $reflector instanceof ReflectionClass: return $reader->getClassAnnotations($reflector); + case $reflector instanceof ReflectionMethod: return $reader->getMethodAnnotations($reflector); + case $reflector instanceof ReflectionProperty: return $reader->getPropertyAnnotations($reflector); } - return $fields; + return []; } - /** - * Get the config for description & deprecation reason. - */ - private static function getDescriptionConfiguration(array $annotations, bool $withDeprecation = false): array + protected static function getAnnotationReader(): AnnotationReader { - $config = []; - $descriptionAnnotation = self::getFirstAnnotationMatching($annotations, GQL\Description::class); - if (null !== $descriptionAnnotation) { - $config['description'] = $descriptionAnnotation->value; - } - - if ($withDeprecation) { - $deprecatedAnnotation = self::getFirstAnnotationMatching($annotations, GQL\Deprecated::class); - if (null !== $deprecatedAnnotation) { - $config['deprecationReason'] = $deprecatedAnnotation->value; + if (null === self::$annotationReader) { + if (!class_exists(AnnotationReader::class) || + !class_exists(AnnotationRegistry::class)) { + throw new RuntimeException('In order to use graphql annotation, you need to require doctrine annotations'); } - } - - return $config; - } - - /** - * Format an array of args to a list of arguments in an expression. - */ - private static function formatArgsForExpression(array $args): string - { - $mapping = []; - foreach ($args as $name => $config) { - $mapping[] = sprintf('%s: "%s"', $name, $config['type']); - } - return sprintf('arguments({%s}, args)', implode(', ', $mapping)); - } - - /** - * Format a namespace to be used in an expression (double escape). - */ - private static function formatNamespaceForExpression(string $namespace): string - { - return str_replace('\\', '\\\\', $namespace); - } - - /** - * Get the first annotation matching given class. - * - * @phpstan-template T of object - * @phpstan-param class-string|class-string[] $annotationClass - * @phpstan-return T|null - * - * @param string|array $annotationClass - * - * @return object|null - */ - private static function getFirstAnnotationMatching(array $annotations, $annotationClass) - { - if (is_string($annotationClass)) { - $annotationClass = [$annotationClass]; + AnnotationRegistry::registerLoader('class_exists'); + self::$annotationReader = new AnnotationReader(); } - foreach ($annotations as $annotation) { - foreach ($annotationClass as $class) { - if ($annotation instanceof $class) { - return $annotation; - } - } - } - - return null; - } - - /** - * Format an expression (ie. add "@=" if not set). - */ - private static function formatExpression(string $expression): string - { - return '@=' === substr($expression, 0, 2) ? $expression : sprintf('@=%s', $expression); - } - - /** - * Suffix a name if it is not already. - */ - private static function suffixName(string $name, string $suffix): string - { - return substr($name, -strlen($suffix)) === $suffix ? $name : sprintf('%s%s', $name, $suffix); - } - - /** - * Try to guess a field type base on his annotations. - * - * @throws RuntimeException - */ - private static function guessType(GraphClass $graphClass, ReflectionProperty $reflector, array $filterGraphQLTypes = []): string - { - if ($reflector->hasType()) { - try { - // @phpstan-ignore-next-line - return self::resolveGraphQLTypeFromReflectionType($reflector->getType(), $filterGraphQLTypes); - } catch (Exception $e) { - } - } - - $annotations = $graphClass->getAnnotations($reflector); - $columnAnnotation = self::getFirstAnnotationMatching($annotations, Column::class); - if (null !== $columnAnnotation) { - $type = self::resolveTypeFromDoctrineType($columnAnnotation->type); - $nullable = $columnAnnotation->nullable; - if ($type) { - return $nullable ? $type : sprintf('%s!', $type); - } else { - throw new RuntimeException(sprintf('Unable to auto-guess GraphQL type from Doctrine type "%s"', $columnAnnotation->type)); - } - } - - $associationAnnotations = [ - OneToMany::class => true, - OneToOne::class => false, - ManyToMany::class => true, - ManyToOne::class => false, - ]; - - $associationAnnotation = self::getFirstAnnotationMatching($annotations, array_keys($associationAnnotations)); - if (null !== $associationAnnotation) { - $target = self::fullyQualifiedClassName($associationAnnotation->targetEntity, $graphClass->getNamespaceName()); - $type = self::resolveTypeFromClass($target, ['type']); - - if ($type) { - $isMultiple = $associationAnnotations[get_class($associationAnnotation)]; - if ($isMultiple) { - return sprintf('[%s]!', $type); - } else { - $isNullable = false; - $joinColumn = self::getFirstAnnotationMatching($annotations, JoinColumn::class); - if (null !== $joinColumn) { - $isNullable = $joinColumn->nullable; - } - - return sprintf('%s%s', $type, $isNullable ? '' : '!'); - } - } else { - throw new RuntimeException(sprintf('Unable to auto-guess GraphQL type from Doctrine target class "%s" (check if the target class is a GraphQL type itself (with a @GQL\Type annotation).', $target)); - } - } - - throw new InvalidArgumentException(sprintf('No Doctrine ORM annotation found.')); - } - - /** - * Resolve a FQN from classname and namespace. - * - * @internal - */ - public static function fullyQualifiedClassName(string $className, string $namespace): string - { - if (false === strpos($className, '\\') && $namespace) { - return $namespace.'\\'.$className; - } - - return $className; - } - - /** - * Resolve a GraphQLType from a doctrine type. - */ - private static function resolveTypeFromDoctrineType(string $doctrineType): ?string - { - if (isset(self::$doctrineMapping[$doctrineType])) { - return self::$doctrineMapping[$doctrineType]; - } - - switch ($doctrineType) { - case 'integer': - case 'smallint': - case 'bigint': - return 'Int'; - case 'string': - case 'text': - return 'String'; - case 'bool': - case 'boolean': - return 'Boolean'; - case 'float': - case 'decimal': - return 'Float'; - default: - return null; - } - } - - /** - * Transform a method arguments from reflection to a list of GraphQL argument. - */ - private static function guessArgs(ReflectionMethod $method): array - { - $arguments = []; - foreach ($method->getParameters() as $index => $parameter) { - if (!$parameter->hasType()) { - throw new InvalidArgumentException(sprintf('Argument n°%s "$%s" on method "%s" cannot be auto-guessed as there is not type hint.', $index + 1, $parameter->getName(), $method->getName())); - } - - try { - // @phpstan-ignore-next-line - $gqlType = self::resolveGraphQLTypeFromReflectionType($parameter->getType(), self::VALID_INPUT_TYPES, $parameter->isDefaultValueAvailable()); - } catch (Exception $e) { - throw new InvalidArgumentException(sprintf('Argument n°%s "$%s" on method "%s" cannot be auto-guessed : %s".', $index + 1, $parameter->getName(), $method->getName(), $e->getMessage())); - } - - $argumentConfig = []; - if ($parameter->isDefaultValueAvailable()) { - $argumentConfig['defaultValue'] = $parameter->getDefaultValue(); - } - - $argumentConfig['type'] = $gqlType; - - $arguments[$parameter->getName()] = $argumentConfig; - } - - return $arguments; - } - - private static function resolveGraphQLTypeFromReflectionType(ReflectionNamedType $type, array $filterGraphQLTypes = [], bool $isOptional = false): string - { - $sType = $type->getName(); - if ($type->isBuiltin()) { - $gqlType = self::resolveTypeFromPhpType($sType); - if (null === $gqlType) { - throw new RuntimeException(sprintf('No corresponding GraphQL type found for builtin type "%s"', $sType)); - } - } else { - $gqlType = self::resolveTypeFromClass($sType, $filterGraphQLTypes); - if (null === $gqlType) { - throw new RuntimeException(sprintf('No corresponding GraphQL %s found for class "%s"', $filterGraphQLTypes ? implode(',', $filterGraphQLTypes) : 'object', $sType)); - } - } - - return sprintf('%s%s', $gqlType, ($type->allowsNull() || $isOptional) ? '' : '!'); - } - - /** - * Resolve a GraphQL Type from a class name. - */ - private static function resolveTypeFromClass(string $className, array $wantedTypes = []): ?string - { - foreach (self::$classesMap as $gqlType => $config) { - if ($config['class'] === $className) { - if (in_array($config['type'], $wantedTypes)) { - return $gqlType; - } - } - } - - return null; - } - - /** - * Search the classes map for class by predicate. - * - * @return array - */ - private static function searchClassesMapBy(callable $predicate, string $type) - { - $classNames = []; - foreach (self::$classesMap as $gqlType => $config) { - if ($config['type'] !== $type) { - continue; - } - - if ($predicate($gqlType, $config)) { - $classNames[$gqlType] = $config; - } - } - - return $classNames; - } - - /** - * Convert a PHP Builtin type to a GraphQL type. - */ - private static function resolveTypeFromPhpType(string $phpType): ?string - { - switch ($phpType) { - case 'boolean': - case 'bool': - return 'Boolean'; - case 'integer': - case 'int': - return 'Int'; - case 'float': - case 'double': - return 'Float'; - case 'string': - return 'String'; - default: - return null; - } + return self::$annotationReader; } } diff --git a/src/Config/Parser/AttributeParser.php b/src/Config/Parser/AttributeParser.php new file mode 100644 index 000000000..b928f92f1 --- /dev/null +++ b/src/Config/Parser/AttributeParser.php @@ -0,0 +1,32 @@ +getAttributes(); + } + + // @phpstan-ignore-line + return array_map(fn (ReflectionAttribute $attribute) => $attribute->newInstance(), $attributes); + } +} diff --git a/src/Config/Parser/MetadataParser/ClassesTypesMap.php b/src/Config/Parser/MetadataParser/ClassesTypesMap.php new file mode 100644 index 000000000..af477d7de --- /dev/null +++ b/src/Config/Parser/MetadataParser/ClassesTypesMap.php @@ -0,0 +1,76 @@ +classesMap[$gqlType]); + } + + public function getType(string $gqlType): ?array + { + return $this->classesMap[$gqlType] ?? null; + } + + /** + * Add a class & a type to the map + */ + public function addClassType(string $typeName, string $className, string $graphQLType): void + { + $this->classesMap[$typeName] = ['class' => $className, 'type' => $graphQLType]; + } + + /** + * Resolve the type associated with given class name + */ + public function resolveType(string $className, array $filteredTypes = []): ?string + { + foreach ($this->classesMap as $gqlType => $config) { + if ($config['class'] === $className) { + if (in_array($config['type'], $filteredTypes)) { + return $gqlType; + } + } + } + + return null; + } + + /** + * Resolve the class name associated with given type + */ + public function resolveClass(string $typeName): ?string + { + return isset($this->classesMap[$typeName]) ? $this->classesMap[$typeName]['class'] : null; + } + + /** + * Search the classes map for class by predicate. + */ + public function searchClassesMapBy(callable $predicate, string $type): array + { + $classNames = []; + foreach ($this->classesMap as $gqlType => $config) { + if ($config['type'] !== $type) { + continue; + } + + if ($predicate($gqlType, $config)) { + $classNames[$gqlType] = $config; + } + } + + return $classNames; + } + + public function toArray(): array + { + return $this->classesMap; + } +} diff --git a/src/Config/Parser/MetadataParser/MetadataParser.php b/src/Config/Parser/MetadataParser/MetadataParser.php new file mode 100644 index 000000000..55578ca51 --- /dev/null +++ b/src/Config/Parser/MetadataParser/MetadataParser.php @@ -0,0 +1,938 @@ +setParameter('overblog_graphql_types.classes_map', self::processFile($file, $container, $configs, true)); + } + + /** + * @throws InvalidArgumentException + * @throws ReflectionException + */ + public static function parse(SplFileInfo $file, ContainerBuilder $container, array $configs = []): array + { + return self::processFile($file, $container, $configs, false); + } + + /** + * @internal + */ + public static function reset(array $configs): void + { + self::$map = new ClassesTypesMap(); + self::$typeGuessers = [ + new TypeHintTypeGuesser(self::$map), + new DoctrineTypeGuesser(self::$map, $configs['doctrine']['types_mapping']), + ]; + self::$providers = []; + self::$reflections = []; + } + + /** + * Process a file. + * + * @throws InvalidArgumentException|ReflectionException|AnnotationException + */ + private static function processFile(SplFileInfo $file, ContainerBuilder $container, array $configs, bool $preProcess): array + { + $container->addResource(new FileResource($file->getRealPath())); + + try { + $className = $file->getBasename('.php'); + if (preg_match('#namespace (.+);#', file_get_contents($file->getRealPath()), $matches)) { + $className = trim($matches[1]).'\\'.$className; + } + + $gqlTypes = []; + $reflectionClass = self::getClassReflection($className); + + foreach (static::getMetadatas($reflectionClass) as $classMetadata) { + if ($classMetadata instanceof Meta) { + $gqlTypes = self::classMetadatasToGQLConfiguration( + $reflectionClass, + $classMetadata, + $configs, + $gqlTypes, + $preProcess + ); + } + } + + return $preProcess ? self::$map->toArray() : $gqlTypes; + } catch (\InvalidArgumentException $e) { + throw new InvalidArgumentException(sprintf('Failed to parse GraphQL metadata from file "%s".', $file), $e->getCode(), $e); + } + } + + private static function classMetadatasToGQLConfiguration( + ReflectionClass $reflectionClass, + Meta $classMetadata, + array $configs, + array $gqlTypes, + bool $preProcess + ): array { + $gqlConfiguration = $gqlType = $gqlName = null; + + switch (true) { + case $classMetadata instanceof Metadata\Type: + $gqlType = self::GQL_TYPE; + $gqlName = $classMetadata->name ?? $reflectionClass->getShortName(); + if (!$preProcess) { + $gqlConfiguration = self::typeMetadataToGQLConfiguration($reflectionClass, $classMetadata, $gqlName, $configs); + + if ($classMetadata instanceof Metadata\Relay\Connection) { + if (!$reflectionClass->implementsInterface(ConnectionInterface::class)) { + throw new InvalidArgumentException(sprintf('The metadata %s on class "%s" can only be used on class implementing the ConnectionInterface.', self::formatMetadata('Connection'), $reflectionClass->getName())); + } + + if (!(isset($classMetadata->edge) xor isset($classMetadata->node))) { + throw new InvalidArgumentException(sprintf('The metadata %s on class "%s" is invalid. You must define either the "edge" OR the "node" attribute, but not both.', self::formatMetadata('Connection'), $reflectionClass->getName())); + } + + $edgeType = $classMetadata->edge ?? false; + if (!$edgeType) { + $edgeType = $gqlName.'Edge'; + $gqlTypes[$edgeType] = [ + 'type' => 'object', + 'config' => [ + 'builders' => [ + ['builder' => 'relay-edge', 'builderConfig' => ['nodeType' => $classMetadata->node]], + ], + ], + ]; + } + + if (!isset($gqlConfiguration['config']['builders'])) { + $gqlConfiguration['config']['builders'] = []; + } + + array_unshift($gqlConfiguration['config']['builders'], ['builder' => 'relay-connection', 'builderConfig' => ['edgeType' => $edgeType]]); + } + } + break; + + case $classMetadata instanceof Metadata\Input: + $gqlType = self::GQL_INPUT; + $gqlName = $classMetadata->name ?? self::suffixName($reflectionClass->getShortName(), 'Input'); + if (!$preProcess) { + $gqlConfiguration = self::inputMetadataToGQLConfiguration($reflectionClass, $classMetadata); + } + break; + + case $classMetadata instanceof Metadata\Scalar: + $gqlType = self::GQL_SCALAR; + if (!$preProcess) { + $gqlConfiguration = self::scalarMetadataToGQLConfiguration($reflectionClass, $classMetadata); + } + break; + + case $classMetadata instanceof Metadata\Enum: + $gqlType = self::GQL_ENUM; + if (!$preProcess) { + $gqlConfiguration = self::enumMetadataToGQLConfiguration($reflectionClass, $classMetadata); + } + break; + + case $classMetadata instanceof Metadata\Union: + $gqlType = self::GQL_UNION; + if (!$preProcess) { + $gqlConfiguration = self::unionMetadataToGQLConfiguration($reflectionClass, $classMetadata); + } + break; + + case $classMetadata instanceof Metadata\TypeInterface: + $gqlType = self::GQL_INTERFACE; + if (!$preProcess) { + $gqlConfiguration = self::typeInterfaceMetadataToGQLConfiguration($reflectionClass, $classMetadata); + } + break; + + case $classMetadata instanceof Metadata\Provider: + if ($preProcess) { + self::$providers[] = ['reflectionClass' => $reflectionClass, 'metadata' => $classMetadata]; + } + + return []; + } + + if (null !== $gqlType) { + if (!$gqlName) { + $gqlName = isset($classMetadata->name) ? $classMetadata->name : $reflectionClass->getShortName(); + } + + if ($preProcess) { + if (self::$map->hasType($gqlName)) { + throw new InvalidArgumentException(sprintf('The GraphQL type "%s" has already been registered in class "%s"', $gqlName, self::$map->getType($gqlName)['class'])); + } + self::$map->addClassType($gqlName, $reflectionClass->getName(), $gqlType); + } else { + $gqlTypes = [$gqlName => $gqlConfiguration] + $gqlTypes; + } + } + + return $gqlTypes; + } + + /** + * @throws ReflectionException + */ + private static function getClassReflection(string $className): ReflectionClass + { + self::$reflections[$className] ??= new ReflectionClass($className); + + return self::$reflections[$className]; + } + + private static function typeMetadataToGQLConfiguration( + ReflectionClass $reflectionClass, + Metadata\Type $classMetadata, + string $gqlName, + array $configs + ): array { + $isMutation = $isDefault = $isRoot = false; + if (isset($configs['definitions']['schema'])) { + $defaultSchemaName = isset($configs['definitions']['schema']['default']) ? 'default' : array_key_first($configs['definitions']['schema']); + foreach ($configs['definitions']['schema'] as $schemaName => $schema) { + $schemaQuery = $schema['query'] ?? null; + $schemaMutation = $schema['mutation'] ?? null; + + if ($gqlName === $schemaQuery) { + $isRoot = true; + if ($defaultSchemaName === $schemaName) { + $isDefault = true; + } + } elseif ($gqlName === $schemaMutation) { + $isMutation = true; + $isRoot = true; + if ($defaultSchemaName === $schemaName) { + $isDefault = true; + } + } + } + } + + $currentValue = $isRoot ? sprintf("service('%s')", self::formatNamespaceForExpression($reflectionClass->getName())) : 'value'; + + $gqlConfiguration = self::graphQLTypeConfigFromAnnotation($reflectionClass, $classMetadata, $currentValue); + + $providerFields = self::getGraphQLFieldsFromProviders($reflectionClass, $isMutation ? Metadata\Mutation::class : Metadata\Query::class, $gqlName, $isDefault); + $gqlConfiguration['config']['fields'] = array_merge($gqlConfiguration['config']['fields'], $providerFields); + + if ($classMetadata instanceof Metadata\Relay\Edge) { + if (!$reflectionClass->implementsInterface(EdgeInterface::class)) { + throw new InvalidArgumentException(sprintf('The metadata %s on class "%s" can only be used on class implementing the EdgeInterface.', self::formatMetadata('Edge'), $reflectionClass->getName())); + } + if (!isset($gqlConfiguration['config']['builders'])) { + $gqlConfiguration['config']['builders'] = []; + } + array_unshift($gqlConfiguration['config']['builders'], ['builder' => 'relay-edge', 'builderConfig' => ['nodeType' => $classMetadata->node]]); + } + + return $gqlConfiguration; + } + + private static function graphQLTypeConfigFromAnnotation(ReflectionClass $reflectionClass, Metadata\Type $typeAnnotation, string $currentValue): array + { + $typeConfiguration = []; + $metadatas = static::getMetadatas($reflectionClass); + + $fieldsFromProperties = self::getGraphQLTypeFieldsFromAnnotations($reflectionClass, self::getClassProperties($reflectionClass), Metadata\Field::class, $currentValue); + $fieldsFromMethods = self::getGraphQLTypeFieldsFromAnnotations($reflectionClass, $reflectionClass->getMethods(), Metadata\Field::class, $currentValue); + + $typeConfiguration['fields'] = array_merge($fieldsFromProperties, $fieldsFromMethods); + $typeConfiguration = self::getDescriptionConfiguration($metadatas) + $typeConfiguration; + + if (!empty($typeAnnotation->interfaces)) { + $typeConfiguration['interfaces'] = $typeAnnotation->interfaces; + } else { + $interfaces = array_keys(self::$map->searchClassesMapBy(function ($gqlType, $configuration) use ($reflectionClass) { + ['class' => $interfaceClassName] = $configuration; + + $interfaceMetadata = self::getClassReflection($interfaceClassName); + if ($interfaceMetadata->isInterface() && $reflectionClass->implementsInterface($interfaceMetadata->getName())) { + return true; + } + + return $reflectionClass->isSubclassOf($interfaceClassName); + }, self::GQL_INTERFACE)); + + sort($interfaces); + $typeConfiguration['interfaces'] = $interfaces; + } + + if (isset($typeAnnotation->resolveField)) { + $typeConfiguration['resolveField'] = self::formatExpression($typeAnnotation->resolveField); + } + + $buildersAnnotations = array_merge(self::getMetadataMatching($metadatas, Metadata\FieldsBuilder::class), $typeAnnotation->builders); + if (!empty($buildersAnnotations)) { + $typeConfiguration['builders'] = array_map(function ($fieldsBuilderAnnotation) { + return ['builder' => $fieldsBuilderAnnotation->builder, 'builderConfig' => $fieldsBuilderAnnotation->builderConfig]; + }, $buildersAnnotations); + } + + if (isset($typeAnnotation->isTypeOf)) { + $typeConfiguration['isTypeOf'] = $typeAnnotation->isTypeOf; + } + + $publicMetadata = self::getFirstMetadataMatching($metadatas, Metadata\IsPublic::class); + if (null !== $publicMetadata) { + $typeConfiguration['fieldsDefaultPublic'] = self::formatExpression($publicMetadata->value); + } + + $accessMetadata = self::getFirstMetadataMatching($metadatas, Metadata\Access::class); + if (null !== $accessMetadata) { + $typeConfiguration['fieldsDefaultAccess'] = self::formatExpression($accessMetadata->value); + } + + return ['type' => $typeAnnotation->isRelay ? 'relay-mutation-payload' : 'object', 'config' => $typeConfiguration]; + } + + /** + * Create a GraphQL Interface type configuration from metadatas on properties. + */ + private static function typeInterfaceMetadataToGQLConfiguration(ReflectionClass $reflectionClass, Metadata\TypeInterface $interfaceAnnotation): array + { + $interfaceConfiguration = []; + + $fieldsFromProperties = self::getGraphQLTypeFieldsFromAnnotations($reflectionClass, self::getClassProperties($reflectionClass)); + $fieldsFromMethods = self::getGraphQLTypeFieldsFromAnnotations($reflectionClass, $reflectionClass->getMethods()); + + $interfaceConfiguration['fields'] = array_merge($fieldsFromProperties, $fieldsFromMethods); + $interfaceConfiguration = self::getDescriptionConfiguration(static::getMetadatas($reflectionClass)) + $interfaceConfiguration; + + $interfaceConfiguration['resolveType'] = self::formatExpression($interfaceAnnotation->resolveType); + + return ['type' => 'interface', 'config' => $interfaceConfiguration]; + } + + /** + * Create a GraphQL Input type configuration from metadatas on properties. + */ + private static function inputMetadataToGQLConfiguration(ReflectionClass $reflectionClass, Metadata\Input $inputAnnotation): array + { + $inputConfiguration = array_merge([ + 'fields' => self::getGraphQLInputFieldsFromMetadatas($reflectionClass, self::getClassProperties($reflectionClass)), + ], self::getDescriptionConfiguration(static::getMetadatas($reflectionClass))); + + return ['type' => $inputAnnotation->isRelay ? 'relay-mutation-input' : 'input-object', 'config' => $inputConfiguration]; + } + + /** + * Get a GraphQL scalar configuration from given scalar metadata. + */ + private static function scalarMetadataToGQLConfiguration(ReflectionClass $reflectionClass, Metadata\Scalar $scalarAnnotation): array + { + $scalarConfiguration = []; + + if (isset($scalarAnnotation->scalarType)) { + $scalarConfiguration['scalarType'] = self::formatExpression($scalarAnnotation->scalarType); + } else { + $scalarConfiguration = [ + 'serialize' => [$reflectionClass->getName(), 'serialize'], + 'parseValue' => [$reflectionClass->getName(), 'parseValue'], + 'parseLiteral' => [$reflectionClass->getName(), 'parseLiteral'], + ]; + } + + $scalarConfiguration = self::getDescriptionConfiguration(static::getMetadatas($reflectionClass)) + $scalarConfiguration; + + return ['type' => 'custom-scalar', 'config' => $scalarConfiguration]; + } + + /** + * Get a GraphQL Enum configuration from given enum metadata. + */ + private static function enumMetadataToGQLConfiguration(ReflectionClass $reflectionClass, Metadata\Enum $enumMetadata): array + { + $metadatas = static::getMetadatas($reflectionClass); + $enumValues = array_merge(self::getMetadataMatching($metadatas, Metadata\EnumValue::class), $enumMetadata->values); + + $values = []; + + foreach ($reflectionClass->getConstants() as $name => $value) { + $valueAnnotation = current(array_filter($enumValues, fn ($enumValueAnnotation) => $enumValueAnnotation->name === $name)); + $valueConfig = []; + $valueConfig['value'] = $value; + + if ($valueAnnotation && isset($valueAnnotation->description)) { + $valueConfig['description'] = $valueAnnotation->description; + } + + if ($valueAnnotation && isset($valueAnnotation->deprecationReason)) { + $valueConfig['deprecationReason'] = $valueAnnotation->deprecationReason; + } + + $values[$name] = $valueConfig; + } + + $enumConfiguration = ['values' => $values]; + $enumConfiguration = self::getDescriptionConfiguration(static::getMetadatas($reflectionClass)) + $enumConfiguration; + + return ['type' => 'enum', 'config' => $enumConfiguration]; + } + + /** + * Get a GraphQL Union configuration from given union metadata. + */ + private static function unionMetadataToGQLConfiguration(ReflectionClass $reflectionClass, Metadata\Union $unionMetadata): array + { + $unionConfiguration = []; + if (!empty($unionMetadata->types)) { + $unionConfiguration['types'] = $unionMetadata->types; + } else { + $types = array_keys(self::$map->searchClassesMapBy(function ($gqlType, $configuration) use ($reflectionClass) { + $typeClassName = $configuration['class']; + $typeMetadata = self::getClassReflection($typeClassName); + + if ($reflectionClass->isInterface() && $typeMetadata->implementsInterface($reflectionClass->getName())) { + return true; + } + + return $typeMetadata->isSubclassOf($reflectionClass->getName()); + }, self::GQL_TYPE)); + sort($types); + $unionConfiguration['types'] = $types; + } + + $unionConfiguration = self::getDescriptionConfiguration(static::getMetadatas($reflectionClass)) + $unionConfiguration; + + if (isset($unionMetadata->resolveType)) { + $unionConfiguration['resolveType'] = self::formatExpression($unionMetadata->resolveType); + } else { + if ($reflectionClass->hasMethod('resolveType')) { + $method = $reflectionClass->getMethod('resolveType'); + if ($method->isStatic() && $method->isPublic()) { + $unionConfiguration['resolveType'] = self::formatExpression(sprintf("@=call('%s::%s', [service('overblog_graphql.type_resolver'), value], true)", self::formatNamespaceForExpression($reflectionClass->getName()), 'resolveType')); + } else { + throw new InvalidArgumentException(sprintf('The "resolveType()" method on class must be static and public. Or you must define a "resolveType" attribute on the %s metadata.', self::formatMetadata('Union'))); + } + } else { + throw new InvalidArgumentException(sprintf('The metadata %s has no "resolveType" attribute and the related class has no "resolveType()" public static method. You need to define of them.', self::formatMetadata('Union'))); + } + } + + return ['type' => 'union', 'config' => $unionConfiguration]; + } + + /** + * @phpstan-param ReflectionMethod|ReflectionProperty $reflector + * @phpstan-param class-string $fieldMetadataName + * + * @throws AnnotationException + */ + private static function getTypeFieldConfigurationFromReflector(ReflectionClass $reflectionClass, Reflector $reflector, string $fieldMetadataName, string $currentValue = 'value'): array + { + /** @var ReflectionProperty|ReflectionMethod $reflector */ + $metadatas = static::getMetadatas($reflector); + + $fieldMetadata = self::getFirstMetadataMatching($metadatas, $fieldMetadataName); + $accessMetadata = self::getFirstMetadataMatching($metadatas, Metadata\Access::class); + $publicMetadata = self::getFirstMetadataMatching($metadatas, Metadata\IsPublic::class); + + if (null === $fieldMetadata) { + if (null !== $accessMetadata || null !== $publicMetadata) { + throw new InvalidArgumentException(sprintf('The metadatas %s and/or %s defined on "%s" are only usable in addition of metadata %s', self::formatMetadata('Access'), self::formatMetadata('Visible'), $reflector->getName(), self::formatMetadata('Field'))); + } + + return []; + } + + if ($reflector instanceof ReflectionMethod && !$reflector->isPublic()) { + throw new InvalidArgumentException(sprintf('The metadata %s can only be applied to public method. The method "%s" is not public.', self::formatMetadata('Field'), $reflector->getName())); + } + + $fieldName = $reflector->getName(); + $fieldConfiguration = []; + + if (isset($fieldMetadata->type)) { + $fieldConfiguration['type'] = $fieldMetadata->type; + } + + $fieldConfiguration = self::getDescriptionConfiguration($metadatas, true) + $fieldConfiguration; + + $args = []; + + $argAnnotations = array_merge(self::getMetadataMatching($metadatas, Metadata\Arg::class), $fieldMetadata->args); + + foreach ($argAnnotations as $arg) { + $args[$arg->name] = ['type' => $arg->type]; + + if (isset($arg->description)) { + $args[$arg->name]['description'] = $arg->description; + } + + if (isset($arg->default)) { + $args[$arg->name]['defaultValue'] = $arg->default; + } + } + + if (empty($argAnnotations) && $reflector instanceof ReflectionMethod) { + $args = self::guessArgs($reflectionClass, $reflector); + } + + if (!empty($args)) { + $fieldConfiguration['args'] = $args; + } + + $fieldName = $fieldMetadata->name ?? $fieldName; + + if (isset($fieldMetadata->resolve)) { + $fieldConfiguration['resolve'] = self::formatExpression($fieldMetadata->resolve); + } else { + if ($reflector instanceof ReflectionMethod) { + $fieldConfiguration['resolve'] = self::formatExpression(sprintf('call(%s.%s, %s)', $currentValue, $reflector->getName(), self::formatArgsForExpression($args))); + } else { + if ($fieldName !== $reflector->getName() || 'value' !== $currentValue) { + $fieldConfiguration['resolve'] = self::formatExpression(sprintf('%s.%s', $currentValue, $reflector->getName())); + } + } + } + + if ($fieldMetadata->argsBuilder) { + if (is_string($fieldMetadata->argsBuilder)) { + $fieldConfiguration['argsBuilder'] = $fieldMetadata->argsBuilder; + } elseif (is_array($fieldMetadata->argsBuilder)) { + list($builder, $builderConfig) = $fieldMetadata->argsBuilder; + $fieldConfiguration['argsBuilder'] = ['builder' => $builder, 'config' => $builderConfig]; + } else { + throw new InvalidArgumentException(sprintf('The attribute "argsBuilder" on metadata %s defined on "%s" must be a string or an array where first index is the builder name and the second is the config.', static::formatMetadata($fieldMetadataName), $reflector->getName())); + } + } + + if ($fieldMetadata->fieldBuilder) { + if (is_string($fieldMetadata->fieldBuilder)) { + $fieldConfiguration['builder'] = $fieldMetadata->fieldBuilder; + } elseif (is_array($fieldMetadata->fieldBuilder)) { + list($builder, $builderConfig) = $fieldMetadata->fieldBuilder; + $fieldConfiguration['builder'] = $builder; + $fieldConfiguration['builderConfig'] = $builderConfig ?: []; + } else { + throw new InvalidArgumentException(sprintf('The attribute "fieldBuilder" on metadata %s defined on "%s" must be a string or an array where first index is the builder name and the second is the config.', static::formatMetadata($fieldMetadataName), $reflector->getName())); + } + } else { + if (!isset($fieldMetadata->type)) { + try { + $fieldConfiguration['type'] = self::guessType($reflectionClass, $reflector, self::VALID_OUTPUT_TYPES); + } catch (TypeGuessingException $e) { + $error = sprintf('The attribute "type" on %s is missing on %s "%s" and cannot be auto-guessed from the following type guessers:'."\n%s\n", static::formatMetadata($fieldMetadataName), $reflector instanceof ReflectionProperty ? 'property' : 'method', $reflector->getName(), $e->getMessage()); + + throw new InvalidArgumentException($error); + } + } + } + + if ($accessMetadata) { + $fieldConfiguration['access'] = self::formatExpression($accessMetadata->value); + } + + if ($publicMetadata) { + $fieldConfiguration['public'] = self::formatExpression($publicMetadata->value); + } + + if (isset($fieldMetadata->complexity)) { + $fieldConfiguration['complexity'] = self::formatExpression($fieldMetadata->complexity); + } + + return [$fieldName => $fieldConfiguration]; + } + + /** + * Create GraphQL input fields configuration based on metadatas. + * + * @param ReflectionProperty[] $reflectors + * + * @throws AnnotationException + */ + private static function getGraphQLInputFieldsFromMetadatas(ReflectionClass $reflectionClass, array $reflectors): array + { + $fields = []; + + foreach ($reflectors as $reflector) { + $metadatas = static::getMetadatas($reflector); + + /** @var Metadata\Field|null $fieldMetadata */ + $fieldMetadata = self::getFirstMetadataMatching($metadatas, Metadata\Field::class); + + // No field metadata found + if (null === $fieldMetadata) { + continue; + } + + // Ignore field with resolver when the type is an Input + if (isset($fieldMetadata->resolve)) { + continue; + } + + $fieldName = $reflector->getName(); + if (isset($fieldMetadata->type)) { + $fieldType = $fieldMetadata->type; + } else { + try { + $fieldType = self::guessType($reflectionClass, $reflector, self::VALID_INPUT_TYPES); + } catch (TypeGuessingException $e) { + throw new InvalidArgumentException(sprintf('The attribute "type" on %s is missing on property "%s" and cannot be auto-guessed from the following type guessers:'."\n%s\n", self::formatMetadata(Metadata\Field::class), $reflector->getName(), $e->getMessage())); + } + } + $fieldConfiguration = []; + if ($fieldType) { + // Resolve a PHP class from a GraphQL type + $resolvedType = self::$map->getType($fieldType); + // We found a type but it is not allowed + if (null !== $resolvedType && !in_array($resolvedType['type'], self::VALID_INPUT_TYPES)) { + throw new InvalidArgumentException(sprintf('The type "%s" on "%s" is a "%s" not valid on an Input %s. Only Input, Scalar and Enum are allowed.', $fieldType, $reflector->getName(), $resolvedType['type'], self::formatMetadata('Field'))); + } + + $fieldConfiguration['type'] = $fieldType; + } + + $fieldConfiguration = array_merge(self::getDescriptionConfiguration($metadatas, true), $fieldConfiguration); + $fields[$fieldName] = $fieldConfiguration; + } + + return $fields; + } + + /** + * Create GraphQL type fields configuration based on metadatas. + * + * @phpstan-param class-string $fieldMetadataName + * + * @param ReflectionProperty[]|ReflectionMethod[] $reflectors + * + * @throws AnnotationException + */ + private static function getGraphQLTypeFieldsFromAnnotations(ReflectionClass $reflectionClass, array $reflectors, string $fieldMetadataName = Metadata\Field::class, string $currentValue = 'value'): array + { + $fields = []; + + foreach ($reflectors as $reflector) { + $fields = array_merge($fields, self::getTypeFieldConfigurationFromReflector($reflectionClass, $reflector, $fieldMetadataName, $currentValue)); + } + + return $fields; + } + + /** + * @phpstan-param class-string $expectedMetadata + * + * Return fields config from Provider methods. + * Loop through configured provider and extract fields targeting the targetType. + */ + private static function getGraphQLFieldsFromProviders(ReflectionClass $reflectionClass, string $expectedMetadata, string $targetType, bool $isDefaultTarget = false): array + { + $fields = []; + foreach (self::$providers as ['reflectionClass' => $providerReflection, 'metadata' => $providerMetadata]) { + $defaultAccessAnnotation = self::getFirstMetadataMatching(static::getMetadatas($providerReflection), Metadata\Access::class); + $defaultIsPublicAnnotation = self::getFirstMetadataMatching(static::getMetadatas($providerReflection), Metadata\IsPublic::class); + + $defaultAccess = $defaultAccessAnnotation ? self::formatExpression($defaultAccessAnnotation->value) : false; + $defaultIsPublic = $defaultIsPublicAnnotation ? self::formatExpression($defaultIsPublicAnnotation->value) : false; + + $methods = []; + // First found the methods matching the targeted type + foreach ($providerReflection->getMethods() as $method) { + $metadatas = static::getMetadatas($method); + + $metadata = self::getFirstMetadataMatching($metadatas, [Metadata\Mutation::class, Metadata\Query::class]); + if (null === $metadata) { + continue; + } + + // TODO: Remove old property check in 1.1 + $metadataTargets = $metadata->targetTypes ?? $metadata->targetType ?? null; + + if (null === $metadataTargets) { + if ($metadata instanceof Metadata\Mutation && isset($providerMetadata->targetMutationTypes)) { + $metadataTargets = $providerMetadata->targetMutationTypes; + } elseif ($metadata instanceof Metadata\Query && isset($providerMetadata->targetQueryTypes)) { + $metadataTargets = $providerMetadata->targetQueryTypes; + } + } + + if (null === $metadataTargets) { + if ($isDefaultTarget) { + $metadataTargets = [$targetType]; + if (!$metadata instanceof $expectedMetadata) { + continue; + } + } else { + continue; + } + } + + if (!in_array($targetType, $metadataTargets)) { + continue; + } + + if (!$metadata instanceof $expectedMetadata) { + if (Metadata\Mutation::class === $expectedMetadata) { + $message = sprintf('The provider "%s" try to add a query field on type "%s" (through %s on method "%s") but "%s" is a mutation.', $providerReflection->getName(), $targetType, self::formatMetadata('Query'), $method->getName(), $targetType); + } else { + $message = sprintf('The provider "%s" try to add a mutation on type "%s" (through %s on method "%s") but "%s" is not a mutation.', $providerReflection->getName(), $targetType, self::formatMetadata('Mutation'), $method->getName(), $targetType); + } + + throw new InvalidArgumentException($message); + } + $methods[$method->getName()] = $method; + } + + $currentValue = sprintf("service('%s')", self::formatNamespaceForExpression($providerReflection->getName())); + $providerFields = self::getGraphQLTypeFieldsFromAnnotations($reflectionClass, $methods, $expectedMetadata, $currentValue); + foreach ($providerFields as $fieldName => $fieldConfig) { + if (isset($providerMetadata->prefix)) { + $fieldName = sprintf('%s%s', $providerMetadata->prefix, $fieldName); + } + + if ($defaultAccess && !isset($fieldConfig['access'])) { + $fieldConfig['access'] = $defaultAccess; + } + + if ($defaultIsPublic && !isset($fieldConfig['public'])) { + $fieldConfig['public'] = $defaultIsPublic; + } + + $fields[$fieldName] = $fieldConfig; + } + } + + return $fields; + } + + /** + * Get the config for description & deprecation reason. + */ + private static function getDescriptionConfiguration(array $metadatas, bool $withDeprecation = false): array + { + $config = []; + $descriptionAnnotation = self::getFirstMetadataMatching($metadatas, Metadata\Description::class); + if (null !== $descriptionAnnotation) { + $config['description'] = $descriptionAnnotation->value; + } + + if ($withDeprecation) { + $deprecatedAnnotation = self::getFirstMetadataMatching($metadatas, Metadata\Deprecated::class); + if (null !== $deprecatedAnnotation) { + $config['deprecationReason'] = $deprecatedAnnotation->value; + } + } + + return $config; + } + + /** + * Format an array of args to a list of arguments in an expression. + */ + private static function formatArgsForExpression(array $args): string + { + $mapping = []; + foreach ($args as $name => $config) { + $mapping[] = sprintf('%s: "%s"', $name, $config['type']); + } + + return sprintf('arguments({%s}, args)', implode(', ', $mapping)); + } + + /** + * Format a namespace to be used in an expression (double escape). + */ + private static function formatNamespaceForExpression(string $namespace): string + { + return str_replace('\\', '\\\\', $namespace); + } + + /** + * Get the first metadata matching given class. + * + * @phpstan-template T of object + * @phpstan-param class-string|class-string[] $metadataClasses + * @phpstan-return T|null + * + * @return object|null + */ + private static function getFirstMetadataMatching(array $metadatas, $metadataClasses) + { + $metas = self::getMetadataMatching($metadatas, $metadataClasses); + + return array_shift($metas); + } + + /** + * Return the metadata matching given class + * + * @phpstan-template T of object + * @phpstan-param class-string|class-string[] $metadataClasses + * + * @return array + */ + private static function getMetadataMatching(array $metadatas, $metadataClasses) + { + if (is_string($metadataClasses)) { + $metadataClasses = [$metadataClasses]; + } + + return array_filter($metadatas, function ($metadata) use ($metadataClasses) { + foreach ($metadataClasses as $metadataClass) { + if ($metadata instanceof $metadataClass) { + return true; + } + } + + return false; + }); + } + + /** + * Format an expression (ie. add "@=" if not set). + */ + private static function formatExpression(string $expression): string + { + return '@=' === substr($expression, 0, 2) ? $expression : sprintf('@=%s', $expression); + } + + /** + * Suffix a name if it is not already. + */ + private static function suffixName(string $name, string $suffix): string + { + return substr($name, -strlen($suffix)) === $suffix ? $name : sprintf('%s%s', $name, $suffix); + } + + /** + * Try to guess a GraphQL type using configured type guessers + * + * @throws RuntimeException + */ + private static function guessType(ReflectionClass $reflectionClass, Reflector $reflector, array $filterGraphQLTypes = []): string + { + $errors = []; + foreach (self::$typeGuessers as $typeGuesser) { + try { + $type = $typeGuesser->guessType($reflectionClass, $reflector, $filterGraphQLTypes); + + return $type; + } catch (TypeGuessingException $exception) { + $errors[] = sprintf('[%s] %s', $typeGuesser->getName(), $exception->getMessage()); + } + } + + throw new TypeGuessingException(join("\n", $errors)); + } + + /** + * Transform a method arguments from reflection to a list of GraphQL argument. + */ + private static function guessArgs(ReflectionClass $reflectionClass, ReflectionMethod $method): array + { + $arguments = []; + foreach ($method->getParameters() as $index => $parameter) { + try { + $gqlType = self::guessType($reflectionClass, $parameter, self::VALID_INPUT_TYPES); + } catch (TypeGuessingException $exception) { + throw new InvalidArgumentException(sprintf('Argument n°%s "$%s" on method "%s" cannot be auto-guessed from the following type guessers:'."\n%s\n", $index + 1, $parameter->getName(), $method->getName(), $exception->getMessage())); + } + + $argumentConfig = []; + if ($parameter->isDefaultValueAvailable()) { + $argumentConfig['defaultValue'] = $parameter->getDefaultValue(); + } + + $argumentConfig['type'] = $gqlType; + + $arguments[$parameter->getName()] = $argumentConfig; + } + + return $arguments; + } + + private static function getClassProperties(ReflectionClass $reflectionClass) + { + $properties = []; + do { + foreach ($reflectionClass->getProperties() as $property) { + if (isset($properties[$property->getName()])) { + continue; + } + $properties[$property->getName()] = $property; + } + } while ($reflectionClass = $reflectionClass->getParentClass()); + + return $properties; + } + + protected static function formatMetadata(string $className): string + { + return sprintf(static::METADATA_FORMAT, str_replace(self::ANNOTATION_NAMESPACE, '', $className)); + } + + abstract protected static function getMetadatas(Reflector $reflector): array; +} diff --git a/src/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php b/src/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php new file mode 100644 index 000000000..b2671d565 --- /dev/null +++ b/src/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php @@ -0,0 +1,168 @@ +doctrineMapping = $doctrineMapping; + } + + public function getName(): string + { + return 'Doctrine annotations '; + } + + public function guessType(ReflectionClass $reflectionClass, Reflector $reflector, array $filterGraphQLTypes = []): ?string + { + if (!$reflector instanceof ReflectionProperty) { + throw new TypeGuessingException('Doctrine type guesser only apply to properties'); + } + /** @var Column|null $columnAnnotation */ + $columnAnnotation = $this->getAnnotation($reflector, Column::class); + + if (null !== $columnAnnotation) { + $type = $this->resolveTypeFromDoctrineType($columnAnnotation->type); + $nullable = $columnAnnotation->nullable; + if ($type) { + return $nullable ? $type : sprintf('%s!', $type); + } else { + throw new TypeGuessingException(sprintf('Unable to auto-guess GraphQL type from Doctrine type "%s"', $columnAnnotation->type)); + } + } + + $associationAnnotations = [ + OneToMany::class => true, + OneToOne::class => false, + ManyToMany::class => true, + ManyToOne::class => false, + ]; + + foreach ($associationAnnotations as $associationClass => $isMultiple) { + /** @var OneToMany|OneToOne|ManyToMany|ManyToOne|null $associationAnnotation */ + $associationAnnotation = $this->getAnnotation($reflector, $associationClass); + if (null !== $associationAnnotation) { + $target = $this->fullyQualifiedClassName($associationAnnotation->targetEntity, $reflectionClass->getNamespaceName()); + $type = $this->map->resolveType($target, ['type']); + + if ($type) { + $isMultiple = $associationAnnotations[get_class($associationAnnotation)]; + if ($isMultiple) { + return sprintf('[%s]!', $type); + } else { + $isNullable = false; + /** @var JoinColumn|null $joinColumn */ + $joinColumn = $this->getAnnotation($reflector, JoinColumn::class); + if (null !== $joinColumn) { + $isNullable = $joinColumn->nullable; + } + + return sprintf('%s%s', $type, $isNullable ? '' : '!'); + } + } else { + throw new TypeGuessingException(sprintf('Unable to auto-guess GraphQL type from Doctrine target class "%s" (check if the target class is a GraphQL type itself (with a @Metadata\Type metadata).', $target)); + } + } + } + throw new TypeGuessingException(sprintf('No Doctrine ORM annotation found.')); + } + + private function getAnnotation(Reflector $reflector, string $annotationClass): ?MappingAnnotation + { + $reader = $this->getAnnotationReader(); + $annotations = []; + switch (true) { + case $reflector instanceof ReflectionClass: $annotations = $reader->getClassAnnotations($reflector); break; + case $reflector instanceof ReflectionMethod: $annotations = $reader->getMethodAnnotations($reflector); break; + case $reflector instanceof ReflectionProperty: $annotations = $reader->getPropertyAnnotations($reflector); break; + } + foreach ($annotations as $annotation) { + if ($annotation instanceof $annotationClass) { + /** @var MappingAnnotation $annotation */ + return $annotation; + } + } + + return null; + } + + private function getAnnotationReader(): AnnotationReader + { + if (null === $this->annotationReader) { + if (!class_exists(AnnotationReader::class) || + !class_exists(AnnotationRegistry::class)) { + throw new RuntimeException('In order to use graphql annotation/attributes, you need to require doctrine annotations'); + } + + AnnotationRegistry::registerLoader('class_exists'); + $this->annotationReader = new AnnotationReader(); + } + + return $this->annotationReader; + } + + /** + * Resolve a FQN from classname and namespace. + * + * @internal + */ + public function fullyQualifiedClassName(string $className, string $namespace): string + { + if (false === strpos($className, '\\') && $namespace) { + return $namespace.'\\'.$className; + } + + return $className; + } + + /** + * Resolve a GraphQLType from a doctrine type. + */ + private function resolveTypeFromDoctrineType(string $doctrineType): ?string + { + if (isset($this->doctrineMapping[$doctrineType])) { + return $this->doctrineMapping[$doctrineType]; + } + + switch ($doctrineType) { + case 'integer': + case 'smallint': + case 'bigint': + return 'Int'; + case 'string': + case 'text': + return 'String'; + case 'bool': + case 'boolean': + return 'Boolean'; + case 'float': + case 'decimal': + return 'Float'; + default: + return null; + } + } +} diff --git a/src/Config/Parser/MetadataParser/TypeGuesser/TypeGuesser.php b/src/Config/Parser/MetadataParser/TypeGuesser/TypeGuesser.php new file mode 100644 index 000000000..3a1dfbffd --- /dev/null +++ b/src/Config/Parser/MetadataParser/TypeGuesser/TypeGuesser.php @@ -0,0 +1,23 @@ +map = $map; + } + + abstract public function getName(): string; + + abstract public function guessType(ReflectionClass $reflectionClass, Reflector $reflector, array $filterGraphQLTypes = []): ?string; +} diff --git a/src/Config/Parser/MetadataParser/TypeGuesser/TypeGuesserInterface.php b/src/Config/Parser/MetadataParser/TypeGuesser/TypeGuesserInterface.php new file mode 100644 index 000000000..fdb35fb90 --- /dev/null +++ b/src/Config/Parser/MetadataParser/TypeGuesser/TypeGuesserInterface.php @@ -0,0 +1,13 @@ +isDefaultValueAvailable(); + // no break + case $reflector instanceof ReflectionProperty: + /** @var ReflectionProperty $reflector */ + $type = $reflector->hasType() ? $reflector->getType() : null; + + break; + case $reflector instanceof ReflectionMethod: + /** @var ReflectionMethod $reflector */ + $type = $reflector->hasReturnType() ? $reflector->getReturnType() : null; + break; + } + /** @var ReflectionNamedType|null $type */ + if (!$type) { + throw new TypeGuessingException('No type-hint'); + } + + $sType = $type->getName(); + if ($type->isBuiltin()) { + $gqlType = $this->resolveTypeFromPhpType($sType); + if (null === $gqlType) { + throw new TypeGuessingException(sprintf('No corresponding GraphQL type found for builtin type "%s"', $sType)); + } + } else { + $gqlType = $this->map->resolveType($sType, $filterGraphQLTypes); + if (null === $gqlType) { + throw new TypeGuessingException(sprintf('No corresponding GraphQL %s found for class "%s"', $filterGraphQLTypes ? implode(',', $filterGraphQLTypes) : 'object', $sType)); + } + } + $nullable = $hasDefaultValue || $type->allowsNull(); + + return sprintf('%s%s', $gqlType, $nullable ? '' : '!'); + } + + /** + * Convert a PHP Builtin type to a GraphQL type. + */ + protected function resolveTypeFromPhpType(string $phpType): ?string + { + switch ($phpType) { + case 'boolean': + case 'bool': + return 'Boolean'; + case 'integer': + case 'int': + return 'Int'; + case 'float': + case 'double': + return 'Float'; + case 'string': + return 'String'; + default: + return null; + } + } +} diff --git a/src/Config/Parser/Annotation/GraphClass.php b/src/Config/Parser/MetadataParser/_GraphClass.php similarity index 95% rename from src/Config/Parser/Annotation/GraphClass.php rename to src/Config/Parser/MetadataParser/_GraphClass.php index fe44e0498..ceb0ff324 100644 --- a/src/Config/Parser/Annotation/GraphClass.php +++ b/src/Config/Parser/MetadataParser/_GraphClass.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Overblog\GraphQLBundle\Config\Parser\Annotation; +namespace Overblog\GraphQLBundle\Config\Parser\ClassParser; use Doctrine\Common\Annotations\AnnotationException; use Doctrine\Common\Annotations\AnnotationReader; @@ -61,7 +61,7 @@ public function getPropertiesExtended() * * @throws AnnotationException */ - public function getAnnotations(Reflector $from = null): array + public function getMetadatas(Reflector $from = null): array { switch (true) { case null === $from: diff --git a/src/DependencyInjection/Compiler/ConfigParserPass.php b/src/DependencyInjection/Compiler/ConfigParserPass.php index e6d80523b..6579a71cd 100644 --- a/src/DependencyInjection/Compiler/ConfigParserPass.php +++ b/src/DependencyInjection/Compiler/ConfigParserPass.php @@ -6,6 +6,7 @@ use InvalidArgumentException; use Overblog\GraphQLBundle\Config\Parser\AnnotationParser; +use Overblog\GraphQLBundle\Config\Parser\AttributeParser; use Overblog\GraphQLBundle\Config\Parser\GraphQLParser; use Overblog\GraphQLBundle\Config\Parser\PreParserInterface; use Overblog\GraphQLBundle\Config\Parser\XmlParser; @@ -41,6 +42,7 @@ class ConfigParserPass implements CompilerPassInterface 'xml' => 'xml', 'graphql' => '{graphql,graphqls}', 'annotation' => 'php', + 'attribute' => 'php', ]; public const PARSERS = [ @@ -48,6 +50,7 @@ class ConfigParserPass implements CompilerPassInterface 'xml' => XmlParser::class, 'graphql' => GraphQLParser::class, 'annotation' => AnnotationParser::class, + 'attribute' => AttributeParser::class, ]; private static array $defaultDefaultConfig = [ @@ -92,7 +95,8 @@ private function getConfigs(ContainerBuilder $container): array // treats mappings // Pre-parse all files - AnnotationParser::reset(); + AnnotationParser::reset($config); + AttributeParser::reset($config); $typesNeedPreParsing = $this->typesNeedPreParsing(); foreach ($typesMappings as $params) { if ($typesNeedPreParsing[$params['type']]) { diff --git a/tests/Config/Parser/AnnotationParserTest.php b/tests/Config/Parser/AnnotationParserTest.php index 3c391ecbe..7f7a809a3 100644 --- a/tests/Config/Parser/AnnotationParserTest.php +++ b/tests/Config/Parser/AnnotationParserTest.php @@ -4,538 +4,39 @@ namespace Overblog\GraphQLBundle\Tests\Config\Parser; -use Exception; use Overblog\GraphQLBundle\Config\Parser\AnnotationParser; -use RecursiveDirectoryIterator; -use RecursiveIteratorIterator; use SplFileInfo; -use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; -use function sprintf; -use function strpos; -use function substr; -class AnnotationParserTest extends TestCase +class AnnotationParserTest extends MetadataParserTest { - protected array $config = []; - - protected array $parserConfig = [ - 'definitions' => [ - 'schema' => [ - 'default' => ['query' => 'RootQuery', 'mutation' => 'RootMutation'], - 'second' => ['query' => 'RootQuery2', 'mutation' => 'RootMutation2'], - ], - ], - 'doctrine' => [ - 'types_mapping' => [ - 'text[]' => '[String]', - ], - ], - ]; - - public function setUp(): void - { - parent::setup(); - - $files = []; - $rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(__DIR__.'/fixtures/annotations/')); - foreach ($rii as $file) { - if (!$file->isDir() && '.php' === substr($file->getPathname(), -4) && false === strpos($file->getPathName(), 'Invalid')) { - $files[] = $file->getPathname(); - } - } - - AnnotationParser::reset(); - - foreach ($files as $file) { - AnnotationParser::preParse(new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); - } - - $this->config = []; - foreach ($files as $file) { - $this->config += self::cleanConfig(AnnotationParser::parse(new SplFileInfo($file), $this->containerBuilder, $this->parserConfig)); - } - } - - private function expect(string $name, string $type, array $config = []): void - { - $expected = [ - 'type' => $type, - 'config' => $config, - ]; - - $this->assertArrayHasKey($name, $this->config, sprintf("The GraphQL type '%s' doesn't exist", $name)); - $this->assertEquals($expected, $this->config[$name]); - } - - public function testExceptionIfRegisterSameType(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/^Failed to parse GraphQL annotations from file/'); - AnnotationParser::preParse(new SplFileInfo(__DIR__.'/fixtures/annotations/Type/Battle.php'), $this->containerBuilder, ['doctrine' => ['types_mapping' => []]]); - } - - public function testTypes(): void - { - // Test an interface - $this->expect('Character', 'interface', [ - 'description' => 'The character interface', - 'resolveType' => "@=resolver('character_type', [value])", - 'fields' => [ - 'name' => ['type' => 'String!', 'description' => 'The name of the character'], - 'friends' => ['type' => '[Character]', 'description' => 'The friends of the character', 'resolve' => "@=resolver('App\\\\MyResolver::getFriends')"], - ], - ]); - - // Test a type extending an interface - $this->expect('Hero', 'object', [ - 'description' => 'The Hero type', - 'interfaces' => ['Character'], - 'fields' => [ - 'name' => ['type' => 'String!', 'description' => 'The name of the character'], - 'friends' => ['type' => '[Character]', 'description' => 'The friends of the character', 'resolve' => "@=resolver('App\\\\MyResolver::getFriends')"], - 'race' => ['type' => 'Race'], - ], - ]); - - $this->expect('Droid', 'object', [ - 'description' => 'The Droid type', - 'interfaces' => ['Character'], - 'isTypeOf' => "@=isTypeOf('App\Entity\Droid')", - 'fields' => [ - 'name' => ['type' => 'String!', 'description' => 'The name of the character'], - 'friends' => ['type' => '[Character]', 'description' => 'The friends of the character', 'resolve' => "@=resolver('App\\\\MyResolver::getFriends')"], - 'memory' => ['type' => 'Int!'], - 'planet_allowedPlanets' => [ - 'type' => '[Planet]', - 'resolve' => '@=call(service(\'Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository\').getAllowedPlanetsForDroids, arguments({}, args))', - 'access' => '@=override_access', - 'public' => '@=default_public', - ], - 'planet_armorResistance' => [ - 'type' => 'Int!', - 'resolve' => '@=call(service(\'Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository\').getArmorResistance, arguments({}, args))', - 'access' => '@=default_access', - 'public' => '@=default_public', - ], - ], - ]); - - // Test a type with public/access on fields, methods as field - $this->expect('Sith', 'object', [ - 'description' => 'The Sith type', - 'interfaces' => ['Character'], - 'resolveField' => '@=value', - 'fieldsDefaultPublic' => '@=isAuthenticated()', - 'fieldsDefaultAccess' => '@=isAuthenticated()', - 'fields' => [ - 'name' => ['type' => 'String!', 'description' => 'The name of the character'], - 'friends' => ['type' => '[Character]', 'description' => 'The friends of the character', 'resolve' => "@=resolver('App\\\\MyResolver::getFriends')"], - 'realName' => ['type' => 'String!', 'access' => "@=hasRole('SITH_LORD')"], - 'location' => ['type' => 'String!', 'public' => "@=hasRole('SITH_LORD')"], - 'currentMaster' => ['type' => 'Sith', 'resolve' => "@=service('master_resolver').getMaster(value)"], - 'victims' => [ - 'type' => '[Character]', - 'args' => ['jediOnly' => ['type' => 'Boolean', 'description' => 'Only Jedi victims', 'defaultValue' => false]], - 'resolve' => '@=call(value.getVictims, arguments({jediOnly: "Boolean"}, args))', - ], - ], - ]); - - // Test a type with a field builder - $this->expect('Planet', 'object', [ - 'description' => 'The Planet type', - 'fields' => [ - 'name' => ['type' => 'String!'], - 'location' => ['type' => 'GalaxyCoordinates'], - 'population' => ['type' => 'Int!'], - 'notes' => [ - 'builder' => 'NoteFieldBuilder', - 'builderConfig' => ['option1' => 'value1'], - ], - 'closestPlanet' => [ - 'type' => 'Planet', - 'argsBuilder' => [ - 'builder' => 'PlanetFilterArgBuilder', - 'config' => ['option2' => 'value2'], - ], - 'resolve' => "@=resolver('closest_planet', [args['filter']])", - ], - ], - ]); - - // Test a type with a fields builder - $this->expect('Crystal', 'object', [ - 'fields' => [ - 'color' => ['type' => 'String!'], - ], - 'builders' => [['builder' => 'MyFieldsBuilder', 'builderConfig' => ['param1' => 'val1']]], - ]); - - // Test a type extending another type - $this->expect('Cat', 'object', [ - 'description' => 'The Cat type', - 'fields' => [ - 'name' => ['type' => 'String!', 'description' => 'The name of the animal'], - 'lives' => ['type' => 'Int!'], - ], - ]); - } - - public function testInput(): void - { - $this->expect('PlanetInput', 'input-object', [ - 'description' => 'Planet Input type description', - 'fields' => [ - 'name' => ['type' => 'String!'], - 'population' => ['type' => 'Int!'], - 'description' => ['type' => 'String!'], - 'diameter' => ['type' => 'Int'], - 'variable' => ['type' => 'Int!'], - 'tags' => ['type' => '[String]!'], - ], - ]); - } - - public function testEnum(): void - { - $this->expect('Race', 'enum', [ - 'description' => 'The list of races!', - 'values' => [ - 'HUMAIN' => ['value' => 1], - 'CHISS' => ['value' => '2', 'description' => 'The Chiss race'], - 'ZABRAK' => ['value' => '3', 'deprecationReason' => 'The Zabraks have been wiped out'], - 'TWILEK' => ['value' => '4'], - ], - ]); - } - - public function testUnion(): void - { - $this->expect('SearchResult', 'union', [ - 'description' => 'A search result', - 'types' => ['Hero', 'Droid', 'Sith'], - 'resolveType' => '@=value.getType()', - ]); - - $this->expect('SearchResult2', 'union', [ - 'types' => ['Hero', 'Droid', 'Sith'], - 'resolveType' => "@=call('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Union\\\\SearchResult2::resolveType', [service('overblog_graphql.type_resolver'), value], true)", - ]); - } - - public function testUnionAutoguessed(): void - { - $this->expect('Killable', 'union', [ - 'types' => ['Hero', 'Mandalorian', 'Sith'], - 'resolveType' => '@=value.getType()', - ]); - } - - public function testInterfaceAutoguessed(): void - { - $this->expect('Mandalorian', 'object', [ - 'interfaces' => ['Armored', 'Character'], - 'fields' => [ - 'name' => ['type' => 'String!', 'description' => 'The name of the character'], - 'friends' => ['type' => '[Character]', 'description' => 'The friends of the character', 'resolve' => "@=resolver('App\\\\MyResolver::getFriends')"], - 'planet_armorResistance' => [ - 'type' => 'Int!', - 'resolve' => '@=call(service(\'Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository\').getArmorResistance, arguments({}, args))', - 'access' => '@=default_access', - 'public' => '@=default_public', - ], - ], - ]); - } - - public function testScalar(): void - { - $this->expect('GalaxyCoordinates', 'custom-scalar', [ - 'serialize' => ['Overblog\GraphQLBundle\Tests\Config\Parser\fixtures\annotations\Scalar\GalaxyCoordinates', 'serialize'], - 'parseValue' => ['Overblog\GraphQLBundle\Tests\Config\Parser\fixtures\annotations\Scalar\GalaxyCoordinates', 'parseValue'], - 'parseLiteral' => ['Overblog\GraphQLBundle\Tests\Config\Parser\fixtures\annotations\Scalar\GalaxyCoordinates', 'parseLiteral'], - 'description' => 'The galaxy coordinates scalar', - ]); - } - - public function testProviders(): void + public function parser($method, ...$args) { - $this->expect('RootQuery', 'object', [ - 'fields' => [ - 'planet_searchPlanet' => [ - 'type' => '[Planet]', - 'args' => ['keyword' => ['type' => 'String!']], - 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository').searchPlanet, arguments({keyword: \"String!\"}, args))", - 'access' => '@=default_access', - 'public' => '@=default_public', - ], - 'planet_isPlanetDestroyed' => [ - 'type' => 'Boolean!', - 'args' => ['planetId' => ['type' => 'Int!']], - 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository').isPlanetDestroyed, arguments({planetId: \"Int!\"}, args))", - 'access' => '@=default_access', - 'public' => '@=default_public', - ], - 'countSecretWeapons' => [ - 'type' => 'Int!', - 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\WeaponRepository').countSecretWeapons, arguments({}, args))", - ], - ], - ]); - - $this->expect('RootMutation', 'object', [ - 'fields' => [ - 'planet_createPlanet' => [ - 'type' => 'Planet', - 'args' => ['planetInput' => ['type' => 'PlanetInput!']], - 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository').createPlanet, arguments({planetInput: \"PlanetInput!\"}, args))", - 'access' => '@=default_access', - 'public' => '@=override_public', - ], - 'planet_destroyPlanet' => [ - 'type' => 'Boolean!', - 'args' => ['planetId' => ['type' => 'Int!']], - 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository').destroyPlanet, arguments({planetId: \"Int!\"}, args))", - 'access' => '@=default_access', - 'public' => '@=default_public', - ], - ], - ]); - } - - public function testProvidersMultischema(): void - { - $this->expect('RootQuery2', 'object', [ - 'fields' => [ - 'planet_getPlanetSchema2' => [ - 'type' => 'Planet', - 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository').getPlanetSchema2, arguments({}, args))", - 'access' => '@=default_access', - 'public' => '@=default_public', - ], - 'planet_isPlanetDestroyed' => [ - 'type' => 'Boolean!', - 'args' => ['planetId' => ['type' => 'Int!']], - 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository').isPlanetDestroyed, arguments({planetId: \"Int!\"}, args))", - 'access' => '@=default_access', - 'public' => '@=default_public', - ], - 'hasSecretWeapons' => [ - 'type' => 'Boolean!', - 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\WeaponRepository').hasSecretWeapons, arguments({}, args))", - ], - ], - ]); - - $this->expect('RootMutation2', 'object', [ - 'fields' => [ - 'planet_createPlanetSchema2' => [ - 'type' => 'Planet', - 'args' => ['planetInput' => ['type' => 'PlanetInput!']], - 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository').createPlanetSchema2, arguments({planetInput: \"PlanetInput!\"}, args))", - 'access' => '@=default_access', - 'public' => '@=override_public', - ], - 'planet_destroyPlanet' => [ - 'type' => 'Boolean!', - 'args' => ['planetId' => ['type' => 'Int!']], - 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository').destroyPlanet, arguments({planetId: \"Int!\"}, args))", - 'access' => '@=default_access', - 'public' => '@=default_public', - ], - 'createLightsaber' => [ - 'type' => 'Boolean!', - 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\WeaponRepository').createLightsaber, arguments({}, args))", - ], - ], - ]); + return AnnotationParser::$method(...$args); } - public function testFullqualifiedName(): void + public function formatMetadata(string $metadata): string { - $this->assertEquals(self::class, AnnotationParser::fullyQualifiedClassName(self::class, 'Overblog\GraphQLBundle')); + return sprintf('@%s', $metadata); } - public function testDoctrineGuessing(): void + public function testLegacyNestedAnnotations(): void { - $this->expect('Lightsaber', 'object', [ + $this->config = self::cleanConfig($this->parser('parse', new SplFileInfo(__DIR__.'/fixtures/annotations/Deprecated/Deprecated.php'), $this->containerBuilder, ['doctrine' => ['types_mapping' => []]])); + $this->expect('Deprecated', 'object', [ 'fields' => [ 'color' => ['type' => 'String!'], - 'size' => ['type' => 'Int'], - 'holders' => ['type' => '[Hero]!'], - 'creator' => ['type' => 'Hero!'], - 'crystal' => ['type' => 'Crystal!'], - 'battles' => ['type' => '[Battle]!'], - 'currentHolder' => ['type' => 'Hero'], - 'tags' => ['type' => '[String]!', 'deprecationReason' => 'No more tags on lightsabers'], - ], - ]); - } - - public function testArgsAndReturnGuessing(): void - { - $this->expect('Battle', 'object', [ - 'fields' => [ - 'planet' => ['type' => 'Planet', 'complexity' => '@=100 + childrenComplexity'], - 'casualties' => [ - 'type' => 'Int', + 'getList' => [ 'args' => [ - 'areaId' => ['type' => 'Int!'], - 'raceId' => ['type' => 'String!'], - 'dayStart' => ['type' => 'Int', 'defaultValue' => null], - 'dayEnd' => ['type' => 'Int', 'defaultValue' => null], - 'nameStartingWith' => ['type' => 'String', 'defaultValue' => ''], - 'planet' => ['type' => 'PlanetInput', 'defaultValue' => null], - 'away' => ['type' => 'Boolean', 'defaultValue' => false], - 'maxDistance' => ['type' => 'Float', 'defaultValue' => null], + 'arg1' => ['type' => 'String!'], + 'arg2' => ['type' => 'Int!'], ], - 'resolve' => '@=call(value.getCasualties, arguments({areaId: "Int!", raceId: "String!", dayStart: "Int", dayEnd: "Int", nameStartingWith: "String", planet: "PlanetInput", away: "Boolean", maxDistance: "Float"}, args))', - 'complexity' => '@=childrenComplexity * 5', + 'resolve' => '@=call(value.getList, arguments({arg1: "String!", arg2: "Int!"}, args))', + 'type' => 'Boolean!', ], ], - ]); - } - - public function testRelayConnectionAuto(): void - { - $this->expect('EnemiesConnection', 'object', [ - 'builders' => [ - ['builder' => 'relay-connection', 'builderConfig' => ['edgeType' => 'EnemiesConnectionEdge']], - ], - ]); - - $this->expect('EnemiesConnectionEdge', 'object', [ - 'builders' => [ - ['builder' => 'relay-edge', 'builderConfig' => ['nodeType' => 'Character']], - ], - ]); - } - - public function testRelayConnectionEdge(): void - { - $this->expect('FriendsConnection', 'object', [ 'builders' => [ - ['builder' => 'relay-connection', 'builderConfig' => ['edgeType' => 'FriendsConnectionEdge']], + ['builder' => 'MyFieldsBuilder', 'builderConfig' => ['param1' => 'val1']], ], ]); - - $this->expect('FriendsConnectionEdge', 'object', [ - 'builders' => [ - ['builder' => 'relay-edge', 'builderConfig' => ['nodeType' => 'Character']], - ], - ]); - } - - public function testInvalidParamGuessing(): void - { - try { - $file = __DIR__.'/fixtures/annotations/Invalid/InvalidArgumentGuessing.php'; - AnnotationParser::parse(new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); - $this->fail('Missing type hint for auto-guessed argument should have raise an exception'); - } catch (Exception $e) { - $this->assertInstanceOf(InvalidArgumentException::class, $e); - $this->assertMatchesRegularExpression('/Argument n°1 "\$test"/', $e->getPrevious()->getMessage()); - } - } - - public function testInvalidReturnGuessing(): void - { - try { - $file = __DIR__.'/fixtures/annotations/Invalid/InvalidReturnTypeGuessing.php'; - AnnotationParser::parse(new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); - $this->fail('Missing type hint for auto-guessed return type should have raise an exception'); - } catch (Exception $e) { - $this->assertInstanceOf(InvalidArgumentException::class, $e); - $this->assertMatchesRegularExpression('/cannot be auto-guessed as there is not return type hint./', $e->getPrevious()->getMessage()); - } - } - - public function testInvalidDoctrineRelationGuessing(): void - { - try { - $file = __DIR__.'/fixtures/annotations/Invalid/InvalidDoctrineRelationGuessing.php'; - AnnotationParser::parse(new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); - $this->fail('Auto-guessing field type from doctrine relation on a non graphql entity should failed with an exception'); - } catch (Exception $e) { - $this->assertInstanceOf(InvalidArgumentException::class, $e); - $this->assertMatchesRegularExpression('/Unable to auto-guess GraphQL type from Doctrine target class/', $e->getPrevious()->getMessage()); - } - } - - public function testInvalidDoctrineTypeGuessing(): void - { - try { - $file = __DIR__.'/fixtures/annotations/Invalid/InvalidDoctrineTypeGuessing.php'; - AnnotationParser::parse(new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); - $this->fail('Auto-guessing field type from doctrine relation on a non graphql entity should failed with an exception'); - } catch (Exception $e) { - $this->assertInstanceOf(InvalidArgumentException::class, $e); - $this->assertMatchesRegularExpression('/Unable to auto-guess GraphQL type from Doctrine type "invalidType"/', $e->getPrevious()->getMessage()); - } - } - - public function testInvalidUnion(): void - { - try { - $file = __DIR__.'/fixtures/annotations/Invalid/InvalidUnion.php'; - AnnotationParser::parse(new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); - $this->fail('Union with missing resolve type shoud have raise an exception'); - } catch (Exception $e) { - $this->assertInstanceOf(InvalidArgumentException::class, $e); - $this->assertMatchesRegularExpression('/The annotation @Union has no "resolveType"/', $e->getPrevious()->getMessage()); - } - } - - public function testInvalidAccess(): void - { - try { - $file = __DIR__.'/fixtures/annotations/Invalid/InvalidAccess.php'; - AnnotationParser::parse(new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); - $this->fail('@Access annotation without a @Field annotation should raise an exception'); - } catch (Exception $e) { - $this->assertInstanceOf(InvalidArgumentException::class, $e); - $this->assertMatchesRegularExpression('/The annotations "@Access" and\/or "@Visible" defined on "field"/', $e->getPrevious()->getMessage()); - } - } - - public function testFieldOnPrivateProperty(): void - { - try { - $file = __DIR__.'/fixtures/annotations/Invalid/InvalidPrivateMethod.php'; - AnnotationParser::parse(new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); - $this->fail('@Access annotation without a @Field annotation should raise an exception'); - } catch (Exception $e) { - $this->assertInstanceOf(InvalidArgumentException::class, $e); - $this->assertMatchesRegularExpression('/The Annotation "@Field" can only be applied to public method/', $e->getPrevious()->getMessage()); - } - } - - public function testInvalidProviderQueryOnMutation(): void - { - $file = __DIR__.'/fixtures/annotations/Invalid/InvalidProvider.php'; - AnnotationParser::preParse(new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); - - try { - $mutationFile = __DIR__.'/fixtures/annotations/Type/RootMutation2.php'; - AnnotationParser::parse(new SplFileInfo($mutationFile), $this->containerBuilder, $this->parserConfig); - $this->fail('Using @Query targeting mutation type should raise an exception'); - } catch (Exception $e) { - $this->assertInstanceOf(InvalidArgumentException::class, $e); - $this->assertMatchesRegularExpression('/try to add a query field on type "RootMutation2"/', $e->getPrevious()->getMessage()); - } - } - - public function testInvalidProviderMutationOnQuery(): void - { - $file = __DIR__.'/fixtures/annotations/Invalid/InvalidProvider.php'; - AnnotationParser::preParse(new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); - try { - $queryFile = __DIR__.'/fixtures/annotations/Type/RootQuery2.php'; - AnnotationParser::parse(new SplFileInfo($queryFile), $this->containerBuilder, $this->parserConfig); - $this->fail('Using @Mutation targeting query type should raise an exception'); - } catch (Exception $e) { - $this->assertInstanceOf(InvalidArgumentException::class, $e); - $this->assertMatchesRegularExpression('/try to add a mutation on type "RootQuery2"/', $e->getPrevious()->getMessage()); - } } } diff --git a/tests/Config/Parser/AttributeParserTest.php b/tests/Config/Parser/AttributeParserTest.php new file mode 100644 index 000000000..cd0fb71fd --- /dev/null +++ b/tests/Config/Parser/AttributeParserTest.php @@ -0,0 +1,23 @@ + [ + 'schema' => [ + 'default' => ['query' => 'RootQuery', 'mutation' => 'RootMutation'], + 'second' => ['query' => 'RootQuery2', 'mutation' => 'RootMutation2'], + ], + ], + 'doctrine' => [ + 'types_mapping' => [ + 'text[]' => '[String]', + ], + ], + ]; + + abstract public function parser($method, ...$args); + + abstract public function formatMetadata(string $metadata): string; + + public function setUp(): void + { + parent::setup(); + + $files = []; + $rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(__DIR__.'/fixtures/annotations/')); + foreach ($rii as $file) { + if (!$file->isDir() && '.php' === substr($file->getPathname(), -4)) { + foreach ($this->ignoredPaths as $ignoredPath) { + if (false !== strpos($file->getPathName(), $ignoredPath)) { + continue 2; + } + } + + $files[] = $file->getPathname(); + } + } + $this->parser('reset', $this->parserConfig); + + foreach ($files as $file) { + $this->parser('preParse', new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); + } + + $this->config = []; + foreach ($files as $file) { + $this->config += self::cleanConfig($this->parser('parse', new SplFileInfo($file), $this->containerBuilder, $this->parserConfig)); + } + } + + protected function expect(string $name, string $type, array $config = []): void + { + $expected = [ + 'type' => $type, + 'config' => $config, + ]; + + $this->assertArrayHasKey($name, $this->config, sprintf("The GraphQL type '%s' doesn't exist", $name)); + $this->assertEquals($expected, $this->config[$name]); + } + + public function testExceptionIfRegisterSameType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/^Failed to parse GraphQL metadata from file/'); + $this->parser('preParse', new SplFileInfo(__DIR__.'/fixtures/annotations/Type/Battle.php'), $this->containerBuilder, ['doctrine' => ['types_mapping' => []]]); + } + + public function testTypes(): void + { + // Test an interface + $this->expect('Character', 'interface', [ + 'description' => 'The character interface', + 'resolveType' => "@=resolver('character_type', [value])", + 'fields' => [ + 'name' => ['type' => 'String!', 'description' => 'The name of the character'], + 'friends' => ['type' => '[Character]', 'description' => 'The friends of the character', 'resolve' => "@=resolver('App\\MyResolver::getFriends')"], + ], + ]); + + // Test a type extending an interface + $this->expect('Hero', 'object', [ + 'description' => 'The Hero type', + 'interfaces' => ['Character'], + 'fields' => [ + 'name' => ['type' => 'String!', 'description' => 'The name of the character'], + 'friends' => ['type' => '[Character]', 'description' => 'The friends of the character', 'resolve' => "@=resolver('App\\MyResolver::getFriends')"], + 'race' => ['type' => 'Race'], + ], + ]); + + $this->expect('Droid', 'object', [ + 'description' => 'The Droid type', + 'interfaces' => ['Character'], + 'isTypeOf' => "@=isTypeOf('App\Entity\Droid')", + 'fields' => [ + 'name' => ['type' => 'String!', 'description' => 'The name of the character'], + 'friends' => ['type' => '[Character]', 'description' => 'The friends of the character', 'resolve' => "@=resolver('App\\MyResolver::getFriends')"], + 'memory' => ['type' => 'Int!'], + 'planet_allowedPlanets' => [ + 'type' => '[Planet]', + 'resolve' => '@=call(service(\'Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository\').getAllowedPlanetsForDroids, arguments({}, args))', + 'access' => '@=override_access', + 'public' => '@=default_public', + ], + 'planet_armorResistance' => [ + 'type' => 'Int!', + 'resolve' => '@=call(service(\'Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository\').getArmorResistance, arguments({}, args))', + 'access' => '@=default_access', + 'public' => '@=default_public', + ], + ], + ]); + + // Test a type with public/access on fields, methods as field + $this->expect('Sith', 'object', [ + 'description' => 'The Sith type', + 'interfaces' => ['Character'], + 'resolveField' => '@=value', + 'fieldsDefaultPublic' => '@=isAuthenticated()', + 'fieldsDefaultAccess' => '@=isAuthenticated()', + 'fields' => [ + 'name' => ['type' => 'String!', 'description' => 'The name of the character'], + 'friends' => ['type' => '[Character]', 'description' => 'The friends of the character', 'resolve' => "@=resolver('App\\MyResolver::getFriends')"], + 'realName' => ['type' => 'String!', 'access' => "@=hasRole('SITH_LORD')"], + 'location' => ['type' => 'String!', 'public' => "@=hasRole('SITH_LORD')"], + 'currentMaster' => ['type' => 'Sith', 'resolve' => "@=service('master_resolver').getMaster(value)"], + 'victims' => [ + 'type' => '[Character]', + 'args' => ['jediOnly' => ['type' => 'Boolean', 'description' => 'Only Jedi victims', 'defaultValue' => false]], + 'resolve' => '@=call(value.getVictims, arguments({jediOnly: "Boolean"}, args))', + ], + ], + ]); + + // Test a type with a field builder + $this->expect('Planet', 'object', [ + 'description' => 'The Planet type', + 'fields' => [ + 'name' => ['type' => 'String!'], + 'location' => ['type' => 'GalaxyCoordinates'], + 'population' => ['type' => 'Int!'], + 'notes' => [ + 'builder' => 'NoteFieldBuilder', + 'builderConfig' => ['option1' => 'value1'], + ], + 'closestPlanet' => [ + 'type' => 'Planet', + 'argsBuilder' => [ + 'builder' => 'PlanetFilterArgBuilder', + 'config' => ['option2' => 'value2'], + ], + 'resolve' => "@=resolver('closest_planet', [args['filter']])", + ], + ], + ]); + + // Test a type with a fields builder + $this->expect('Crystal', 'object', [ + 'fields' => [ + 'color' => ['type' => 'String!'], + ], + 'builders' => [['builder' => 'MyFieldsBuilder', 'builderConfig' => ['param1' => 'val1']]], + ]); + + // Test a type extending another type + $this->expect('Cat', 'object', [ + 'description' => 'The Cat type', + 'fields' => [ + 'name' => ['type' => 'String!', 'description' => 'The name of the animal'], + 'lives' => ['type' => 'Int!'], + ], + ]); + } + + public function testInput(): void + { + $this->expect('PlanetInput', 'input-object', [ + 'description' => 'Planet Input type description', + 'fields' => [ + 'name' => ['type' => 'String!'], + 'population' => ['type' => 'Int!'], + 'description' => ['type' => 'String!'], + 'diameter' => ['type' => 'Int'], + 'variable' => ['type' => 'Int!'], + 'tags' => ['type' => '[String]!'], + ], + ]); + } + + public function testEnum(): void + { + $this->expect('Race', 'enum', [ + 'description' => 'The list of races!', + 'values' => [ + 'HUMAIN' => ['value' => 1], + 'CHISS' => ['value' => '2', 'description' => 'The Chiss race'], + 'ZABRAK' => ['value' => '3', 'deprecationReason' => 'The Zabraks have been wiped out'], + 'TWILEK' => ['value' => '4'], + ], + ]); + } + + public function testUnion(): void + { + $this->expect('SearchResult', 'union', [ + 'description' => 'A search result', + 'types' => ['Hero', 'Droid', 'Sith'], + 'resolveType' => '@=value.getType()', + ]); + + $this->expect('SearchResult2', 'union', [ + 'types' => ['Hero', 'Droid', 'Sith'], + 'resolveType' => "@=call('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Union\\\\SearchResult2::resolveType', [service('overblog_graphql.type_resolver'), value], true)", + ]); + } + + public function testUnionAutoguessed(): void + { + $this->expect('Killable', 'union', [ + 'types' => ['Hero', 'Mandalorian', 'Sith'], + 'resolveType' => '@=value.getType()', + ]); + } + + public function testInterfaceAutoguessed(): void + { + $this->expect('Mandalorian', 'object', [ + 'interfaces' => ['Armored', 'Character'], + 'fields' => [ + 'name' => ['type' => 'String!', 'description' => 'The name of the character'], + 'friends' => ['type' => '[Character]', 'description' => 'The friends of the character', 'resolve' => "@=resolver('App\\MyResolver::getFriends')"], + 'planet_armorResistance' => [ + 'type' => 'Int!', + 'resolve' => '@=call(service(\'Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository\').getArmorResistance, arguments({}, args))', + 'access' => '@=default_access', + 'public' => '@=default_public', + ], + ], + ]); + } + + public function testScalar(): void + { + $this->expect('GalaxyCoordinates', 'custom-scalar', [ + 'serialize' => ['Overblog\GraphQLBundle\Tests\Config\Parser\fixtures\\annotations\Scalar\GalaxyCoordinates', 'serialize'], + 'parseValue' => ['Overblog\GraphQLBundle\Tests\Config\Parser\fixtures\\annotations\Scalar\GalaxyCoordinates', 'parseValue'], + 'parseLiteral' => ['Overblog\GraphQLBundle\Tests\Config\Parser\fixtures\\annotations\Scalar\GalaxyCoordinates', 'parseLiteral'], + 'description' => 'The galaxy coordinates scalar', + ]); + } + + public function testProviders(): void + { + $this->expect('RootQuery', 'object', [ + 'fields' => [ + 'planet_searchPlanet' => [ + 'type' => '[Planet]', + 'args' => ['keyword' => ['type' => 'String!']], + 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository').searchPlanet, arguments({keyword: \"String!\"}, args))", + 'access' => '@=default_access', + 'public' => '@=default_public', + ], + 'planet_isPlanetDestroyed' => [ + 'type' => 'Boolean!', + 'args' => ['planetId' => ['type' => 'Int!']], + 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository').isPlanetDestroyed, arguments({planetId: \"Int!\"}, args))", + 'access' => '@=default_access', + 'public' => '@=default_public', + ], + 'countSecretWeapons' => [ + 'type' => 'Int!', + 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\WeaponRepository').countSecretWeapons, arguments({}, args))", + ], + 'planet_searchStar' => [ + 'type' => '[Planet]', + 'args' => ['distance' => ['type' => 'Int!']], + 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository').searchStar, arguments({distance: \"Int!\"}, args))", + 'access' => '@=default_access', + 'public' => '@=default_public', + ], + ], + ]); + + $this->expect('RootMutation', 'object', [ + 'fields' => [ + 'planet_createPlanet' => [ + 'type' => 'Planet', + 'args' => ['planetInput' => ['type' => 'PlanetInput!']], + 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository').createPlanet, arguments({planetInput: \"PlanetInput!\"}, args))", + 'access' => '@=default_access', + 'public' => '@=override_public', + ], + 'planet_destroyPlanet' => [ + 'type' => 'Boolean!', + 'args' => ['planetId' => ['type' => 'Int!']], + 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository').destroyPlanet, arguments({planetId: \"Int!\"}, args))", + 'access' => '@=default_access', + 'public' => '@=default_public', + ], + ], + ]); + } + + public function testProvidersMultischema(): void + { + $this->expect('RootQuery2', 'object', [ + 'fields' => [ + 'planet_getPlanetSchema2' => [ + 'type' => 'Planet', + 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository').getPlanetSchema2, arguments({}, args))", + 'access' => '@=default_access', + 'public' => '@=default_public', + ], + 'planet_isPlanetDestroyed' => [ + 'type' => 'Boolean!', + 'args' => ['planetId' => ['type' => 'Int!']], + 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository').isPlanetDestroyed, arguments({planetId: \"Int!\"}, args))", + 'access' => '@=default_access', + 'public' => '@=default_public', + ], + 'hasSecretWeapons' => [ + 'type' => 'Boolean!', + 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\WeaponRepository').hasSecretWeapons, arguments({}, args))", + ], + ], + ]); + + $this->expect('RootMutation2', 'object', [ + 'fields' => [ + 'planet_createPlanetSchema2' => [ + 'type' => 'Planet', + 'args' => ['planetInput' => ['type' => 'PlanetInput!']], + 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository').createPlanetSchema2, arguments({planetInput: \"PlanetInput!\"}, args))", + 'access' => '@=default_access', + 'public' => '@=override_public', + ], + 'planet_destroyPlanet' => [ + 'type' => 'Boolean!', + 'args' => ['planetId' => ['type' => 'Int!']], + 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository').destroyPlanet, arguments({planetId: \"Int!\"}, args))", + 'access' => '@=default_access', + 'public' => '@=default_public', + ], + 'createLightsaber' => [ + 'type' => 'Boolean!', + 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\WeaponRepository').createLightsaber, arguments({}, args))", + ], + ], + ]); + } + + public function testDoctrineGuessing(): void + { + $this->expect('Lightsaber', 'object', [ + 'fields' => [ + 'color' => ['type' => 'String!'], + 'size' => ['type' => 'Int'], + 'holders' => ['type' => '[Hero]!'], + 'creator' => ['type' => 'Hero!'], + 'crystal' => ['type' => 'Crystal!'], + 'battles' => ['type' => '[Battle]!'], + 'currentHolder' => ['type' => 'Hero'], + 'tags' => ['type' => '[String]!', 'deprecationReason' => 'No more tags on lightsabers'], + ], + ]); + } + + public function testArgsAndReturnGuessing(): void + { + $this->expect('Battle', 'object', [ + 'fields' => [ + 'planet' => ['type' => 'Planet', 'complexity' => '@=100 + childrenComplexity'], + 'casualties' => [ + 'type' => 'Int', + 'args' => [ + 'areaId' => ['type' => 'Int!'], + 'raceId' => ['type' => 'String!'], + 'dayStart' => ['type' => 'Int', 'defaultValue' => null], + 'dayEnd' => ['type' => 'Int', 'defaultValue' => null], + 'nameStartingWith' => ['type' => 'String', 'defaultValue' => ''], + 'planet' => ['type' => 'PlanetInput', 'defaultValue' => null], + 'away' => ['type' => 'Boolean', 'defaultValue' => false], + 'maxDistance' => ['type' => 'Float', 'defaultValue' => null], + ], + 'resolve' => '@=call(value.getCasualties, arguments({areaId: "Int!", raceId: "String!", dayStart: "Int", dayEnd: "Int", nameStartingWith: "String", planet: "PlanetInput", away: "Boolean", maxDistance: "Float"}, args))', + 'complexity' => '@=childrenComplexity * 5', + ], + ], + ]); + } + + public function testRelayConnectionAuto(): void + { + $this->expect('EnemiesConnection', 'object', [ + 'builders' => [ + ['builder' => 'relay-connection', 'builderConfig' => ['edgeType' => 'EnemiesConnectionEdge']], + ], + ]); + + $this->expect('EnemiesConnectionEdge', 'object', [ + 'builders' => [ + ['builder' => 'relay-edge', 'builderConfig' => ['nodeType' => 'Character']], + ], + ]); + } + + public function testRelayConnectionEdge(): void + { + $this->expect('FriendsConnection', 'object', [ + 'builders' => [ + ['builder' => 'relay-connection', 'builderConfig' => ['edgeType' => 'FriendsConnectionEdge']], + ], + ]); + + $this->expect('FriendsConnectionEdge', 'object', [ + 'builders' => [ + ['builder' => 'relay-edge', 'builderConfig' => ['nodeType' => 'Character']], + ], + ]); + } + + public function testInvalidParamGuessing(): void + { + try { + $file = __DIR__.'/fixtures/annotations/Invalid/InvalidArgumentGuessing.php'; + $this->parser('parse', new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); + $this->fail('Missing type hint for auto-guessed argument should have raise an exception'); + } catch (Exception $e) { + $this->assertInstanceOf(InvalidArgumentException::class, $e); + $this->assertMatchesRegularExpression('/Argument n°1 "\$test"/', $e->getPrevious()->getMessage()); + } + } + + public function testInvalidReturnGuessing(): void + { + try { + $file = __DIR__.'/fixtures/annotations/Invalid/InvalidReturnTypeGuessing.php'; + $this->parser('parse', new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); + $this->fail('Missing type hint for auto-guessed return type should have raise an exception'); + } catch (Exception $e) { + $this->assertInstanceOf(InvalidArgumentException::class, $e); + $this->assertMatchesRegularExpression('/is missing on method "guessFail" and cannot be auto-guessed from the following type guessers/', $e->getPrevious()->getMessage()); + } + } + + public function testInvalidDoctrineRelationGuessing(): void + { + try { + $file = __DIR__.'/fixtures/annotations/Invalid/InvalidDoctrineRelationGuessing.php'; + $this->parser('parse', new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); + $this->fail('Auto-guessing field type from doctrine relation on a non graphql entity should failed with an exception'); + } catch (Exception $e) { + $this->assertInstanceOf(InvalidArgumentException::class, $e); + $this->assertMatchesRegularExpression('/Unable to auto-guess GraphQL type from Doctrine target class/', $e->getPrevious()->getMessage()); + } + } + + public function testInvalidDoctrineTypeGuessing(): void + { + try { + $file = __DIR__.'/fixtures/annotations/Invalid/InvalidDoctrineTypeGuessing.php'; + $this->parser('parse', new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); + $this->fail('Auto-guessing field type from doctrine relation on a non graphql entity should failed with an exception'); + } catch (Exception $e) { + $this->assertInstanceOf(InvalidArgumentException::class, $e); + $this->assertMatchesRegularExpression('/Unable to auto-guess GraphQL type from Doctrine type "invalidType"/', $e->getPrevious()->getMessage()); + } + } + + public function testInvalidUnion(): void + { + try { + $file = __DIR__.'/fixtures/annotations/Invalid/InvalidUnion.php'; + $this->parser('parse', new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); + $this->fail('Union with missing resolve type shoud have raise an exception'); + } catch (Exception $e) { + $this->assertInstanceOf(InvalidArgumentException::class, $e); + $this->assertMatchesRegularExpression('/The metadata '.$this->formatMetadata('Union').' has no "resolveType"/', $e->getPrevious()->getMessage()); + } + } + + public function testInvalidAccess(): void + { + try { + $file = __DIR__.'/fixtures/annotations/Invalid/InvalidAccess.php'; + $this->parser('parse', new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); + $this->fail('@Access annotation without a @Field annotation should raise an exception'); + } catch (Exception $e) { + $this->assertInstanceOf(InvalidArgumentException::class, $e); + $this->assertMatchesRegularExpression('/The metadatas '.$this->formatMetadata('Access').' and\/or '.$this->formatMetadata('Visible').' defined on "field"/', $e->getPrevious()->getMessage()); + } + } + + public function testFieldOnPrivateProperty(): void + { + try { + $file = __DIR__.'/fixtures/annotations/Invalid/InvalidPrivateMethod.php'; + $this->parser('parse', new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); + $this->fail($this->formatMetadata('Access').' annotation without a '.$this->formatMetadata('Field').' annotation should raise an exception'); + } catch (Exception $e) { + $this->assertInstanceOf(InvalidArgumentException::class, $e); + $this->assertMatchesRegularExpression('/The metadata '.$this->formatMetadata('Field').' can only be applied to public method/', $e->getPrevious()->getMessage()); + } + } + + public function testInvalidProviderQueryOnMutation(): void + { + $file = __DIR__.'/fixtures/annotations/Invalid/InvalidProvider.php'; + $this->parser('preParse', new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); + + try { + $mutationFile = __DIR__.'/fixtures/annotations/Type/RootMutation2.php'; + $this->parser('parse', new SplFileInfo($mutationFile), $this->containerBuilder, $this->parserConfig); + $this->fail('Using @Query or #Query targeting mutation type should raise an exception'); + } catch (Exception $e) { + $this->assertInstanceOf(InvalidArgumentException::class, $e); + $this->assertMatchesRegularExpression('/try to add a query field on type "RootMutation2"/', $e->getPrevious()->getMessage()); + } + } + + public function testInvalidProviderMutationOnQuery(): void + { + $file = __DIR__.'/fixtures/annotations/Invalid/InvalidProvider.php'; + $this->parser('preParse', new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); + try { + $queryFile = __DIR__.'/fixtures/annotations/Type/RootQuery2.php'; + $this->parser('parse', new SplFileInfo($queryFile), $this->containerBuilder, $this->parserConfig); + $this->fail('Using @Mutation or #Mutation targeting query type should raise an exception'); + } catch (Exception $e) { + $this->assertInstanceOf(InvalidArgumentException::class, $e); + $this->assertMatchesRegularExpression('/try to add a mutation on type "RootQuery2"/', $e->getPrevious()->getMessage()); + } + } +} diff --git a/tests/Config/Parser/fixtures/annotations/Deprecated/Deprecated.php b/tests/Config/Parser/fixtures/annotations/Deprecated/Deprecated.php new file mode 100644 index 000000000..82c4eb3b3 --- /dev/null +++ b/tests/Config/Parser/fixtures/annotations/Deprecated/Deprecated.php @@ -0,0 +1,29 @@ + "val1"])] class Crystal { /** * @GQL\Field(type="String!") */ + #[GQL\Field(type: "String!")] protected string $color; } diff --git a/tests/Config/Parser/fixtures/annotations/Type/Droid.php b/tests/Config/Parser/fixtures/annotations/Type/Droid.php index d57816759..5ed95b297 100644 --- a/tests/Config/Parser/fixtures/annotations/Type/Droid.php +++ b/tests/Config/Parser/fixtures/annotations/Type/Droid.php @@ -10,10 +10,13 @@ * @GQL\Type(isTypeOf="@=isTypeOf('App\Entity\Droid')") * @GQL\Description("The Droid type") */ +#[GQL\Type(isTypeOf: "@=isTypeOf('App\Entity\Droid')")] +#[GQL\Description("The Droid type")] class Droid extends Character { /** * @GQL\Field */ + #[GQL\Field] protected int $memory; } diff --git a/tests/Config/Parser/fixtures/annotations/Type/Hero.php b/tests/Config/Parser/fixtures/annotations/Type/Hero.php index ce584212a..ae87b441c 100644 --- a/tests/Config/Parser/fixtures/annotations/Type/Hero.php +++ b/tests/Config/Parser/fixtures/annotations/Type/Hero.php @@ -12,10 +12,13 @@ * @GQL\Type(interfaces={"Character"}) * @GQL\Description("The Hero type") */ +#[GQL\Type(interfaces: ["Character"])] +#[GQL\Description("The Hero type")] class Hero extends Character implements Killable { /** * @GQL\Field(type="Race") */ + #[GQL\Field(type: "Race")] protected Race $race; } diff --git a/tests/Config/Parser/fixtures/annotations/Type/Lightsaber.php b/tests/Config/Parser/fixtures/annotations/Type/Lightsaber.php index 4a1d36823..f7f343125 100644 --- a/tests/Config/Parser/fixtures/annotations/Type/Lightsaber.php +++ b/tests/Config/Parser/fixtures/annotations/Type/Lightsaber.php @@ -11,18 +11,21 @@ * @GQL\Type * @ORM\Entity */ +#[GQL\Type] class Lightsaber { /** * @ORM\Column * @GQL\Field */ + #[GQL\Field] protected string $color; /** * @ORM\Column(type="integer", nullable=true) * @GQL\Field */ + #[GQL\Field] // @phpstan-ignore-next-line protected $size; @@ -30,6 +33,7 @@ class Lightsaber * @ORM\OneToMany(targetEntity="Hero") * @GQL\Field */ + #[GQL\Field] // @phpstan-ignore-next-line protected $holders; @@ -37,6 +41,7 @@ class Lightsaber * @ORM\ManyToOne(targetEntity="Hero") * @GQL\Field */ + #[GQL\Field] // @phpstan-ignore-next-line protected $creator; @@ -44,6 +49,7 @@ class Lightsaber * @ORM\OneToOne(targetEntity="Crystal") * @GQL\Field */ + #[GQL\Field] // @phpstan-ignore-next-line protected $crystal; @@ -51,6 +57,7 @@ class Lightsaber * @ORM\ManyToMany(targetEntity="Battle") * @GQL\Field */ + #[GQL\Field] // @phpstan-ignore-next-line protected $battles; @@ -59,6 +66,7 @@ class Lightsaber * @ORM\OneToOne(targetEntity="Hero") * @ORM\JoinColumn(nullable=true) */ + #[GQL\Field] // @phpstan-ignore-next-line protected $currentHolder; @@ -67,5 +75,7 @@ class Lightsaber * @ORM\Column(type="text[]") * @GQL\Deprecated("No more tags on lightsabers") */ + #[GQL\Field] + #[GQL\Deprecated("No more tags on lightsabers")] protected array $tags; } diff --git a/tests/Config/Parser/fixtures/annotations/Type/Mandalorian.php b/tests/Config/Parser/fixtures/annotations/Type/Mandalorian.php index 23eae7ea2..4d827ec79 100644 --- a/tests/Config/Parser/fixtures/annotations/Type/Mandalorian.php +++ b/tests/Config/Parser/fixtures/annotations/Type/Mandalorian.php @@ -10,6 +10,7 @@ /** * @GQL\Type */ +#[GQL\Type] class Mandalorian extends Character implements Killable, Armored { } diff --git a/tests/Config/Parser/fixtures/annotations/Type/Planet.php b/tests/Config/Parser/fixtures/annotations/Type/Planet.php index 7006e0f45..4212d35fd 100644 --- a/tests/Config/Parser/fixtures/annotations/Type/Planet.php +++ b/tests/Config/Parser/fixtures/annotations/Type/Planet.php @@ -11,26 +11,32 @@ * @GQL\Type * @GQL\Description("The Planet type") */ +#[GQL\Type] +#[GQL\Description("The Planet type")] class Planet { /** * @GQL\Field(type="String!") */ + #[GQL\Field(type: "String!")] protected string $name; /** * @GQL\Field(type="GalaxyCoordinates") */ + #[GQL\Field(type: "GalaxyCoordinates")] protected GalaxyCoordinates $location; /** * @GQL\Field(type="Int!") */ + #[GQL\Field(type: "Int!")] protected int $population; /** * @GQL\Field(fieldBuilder={"NoteFieldBuilder", {"option1": "value1"}}) */ + #[GQL\Field(fieldBuilder: ["NoteFieldBuilder", ["option1" => "value1"]])] public array $notes; /** @@ -40,5 +46,6 @@ class Planet * resolve="@=resolver('closest_planet', [args['filter']])" * ) */ + #[GQL\Field(type: "Planet", argsBuilder: ["PlanetFilterArgBuilder", ["option2" => "value2"]], resolve: "@=resolver('closest_planet', [args['filter']])")] public Planet $closestPlanet; } diff --git a/tests/Config/Parser/fixtures/annotations/Type/RootMutation.php b/tests/Config/Parser/fixtures/annotations/Type/RootMutation.php index b15c54a15..414934c73 100644 --- a/tests/Config/Parser/fixtures/annotations/Type/RootMutation.php +++ b/tests/Config/Parser/fixtures/annotations/Type/RootMutation.php @@ -9,6 +9,7 @@ /** * @GQL\Type */ +#[GQL\Type] class RootMutation { } diff --git a/tests/Config/Parser/fixtures/annotations/Type/RootMutation2.php b/tests/Config/Parser/fixtures/annotations/Type/RootMutation2.php index ed4510d83..afbb22b35 100644 --- a/tests/Config/Parser/fixtures/annotations/Type/RootMutation2.php +++ b/tests/Config/Parser/fixtures/annotations/Type/RootMutation2.php @@ -9,6 +9,7 @@ /** * @GQL\Type */ +#[GQL\Type] class RootMutation2 { } diff --git a/tests/Config/Parser/fixtures/annotations/Type/RootQuery.php b/tests/Config/Parser/fixtures/annotations/Type/RootQuery.php index 5f6ed29db..93326cf43 100644 --- a/tests/Config/Parser/fixtures/annotations/Type/RootQuery.php +++ b/tests/Config/Parser/fixtures/annotations/Type/RootQuery.php @@ -9,6 +9,7 @@ /** * @GQL\Type */ +#[GQL\Type] class RootQuery { } diff --git a/tests/Config/Parser/fixtures/annotations/Type/RootQuery2.php b/tests/Config/Parser/fixtures/annotations/Type/RootQuery2.php index fb052d224..0db5f20d2 100644 --- a/tests/Config/Parser/fixtures/annotations/Type/RootQuery2.php +++ b/tests/Config/Parser/fixtures/annotations/Type/RootQuery2.php @@ -9,6 +9,7 @@ /** * @GQL\Type */ +#[GQL\Type] class RootQuery2 { } diff --git a/tests/Config/Parser/fixtures/annotations/Type/Sith.php b/tests/Config/Parser/fixtures/annotations/Type/Sith.php index de50d7551..7be82be32 100644 --- a/tests/Config/Parser/fixtures/annotations/Type/Sith.php +++ b/tests/Config/Parser/fixtures/annotations/Type/Sith.php @@ -13,34 +13,40 @@ * @GQL\Access("isAuthenticated()") * @GQL\IsPublic("isAuthenticated()") */ +#[GQL\Type(interfaces: ["Character"], resolveField: "value")] +#[GQL\Description("The Sith type")] +#[GQL\Access("isAuthenticated()")] +#[GQL\IsPublic("isAuthenticated()")] class Sith extends Character implements Killable { /** * @GQL\Field(type="String!") * @GQL\Access("hasRole('SITH_LORD')") */ + #[GQL\Access("hasRole('SITH_LORD')")] + #[GQL\Field(type: "String!")] protected string $realName; /** * @GQL\Field(type="String!") * @GQL\IsPublic("hasRole('SITH_LORD')") */ + #[GQL\IsPublic("hasRole('SITH_LORD')")] + #[GQL\Field(type: "String!")] protected string $location; /** * @GQL\Field(type="Sith", resolve="service('master_resolver').getMaster(value)") */ + #[GQL\Field(type: "Sith", resolve: "service('master_resolver').getMaster(value)")] protected Sith $currentMaster; /** - * @GQL\Field( - * type="[Character]", - * name="victims", - * args={ - * @GQL\Arg(name="jediOnly", type="Boolean", description="Only Jedi victims", default=false) - * } - * ) + * @GQL\Field(type="[Character]", name="victims") + * @GQL\Arg(name="jediOnly", type="Boolean", description="Only Jedi victims", default=false) */ + #[GQL\Field(type: "[Character]", name: "victims")] + #[GQL\Arg(name: "jediOnly", type: "Boolean", description: "Only Jedi victims", default: false)] public function getVictims(bool $jediOnly = false): array { return []; diff --git a/tests/Config/Parser/fixtures/annotations/Union/Killable.php b/tests/Config/Parser/fixtures/annotations/Union/Killable.php index 4bd724738..9b46e1740 100644 --- a/tests/Config/Parser/fixtures/annotations/Union/Killable.php +++ b/tests/Config/Parser/fixtures/annotations/Union/Killable.php @@ -9,6 +9,7 @@ /** * @GQL\Union(resolveType="value.getType()") */ +#[GQL\Union(resolveType: "value.getType()")] interface Killable { } diff --git a/tests/Config/Parser/fixtures/annotations/Union/SearchResult.php b/tests/Config/Parser/fixtures/annotations/Union/SearchResult.php index 0e2969185..882000a3d 100644 --- a/tests/Config/Parser/fixtures/annotations/Union/SearchResult.php +++ b/tests/Config/Parser/fixtures/annotations/Union/SearchResult.php @@ -10,6 +10,8 @@ * @GQL\Union(types={"Hero", "Droid", "Sith"}, resolveType="value.getType()") * @GQL\Description("A search result") */ +#[GQL\Union(types: ["Hero", "Droid", "Sith"], resolveType: "value.getType()")] +#[GQL\Description("A search result")] class SearchResult { } diff --git a/tests/Config/Parser/fixtures/annotations/Union/SearchResult2.php b/tests/Config/Parser/fixtures/annotations/Union/SearchResult2.php index fbd715570..b65d894ad 100644 --- a/tests/Config/Parser/fixtures/annotations/Union/SearchResult2.php +++ b/tests/Config/Parser/fixtures/annotations/Union/SearchResult2.php @@ -11,6 +11,7 @@ /** * @GQL\Union(types={"Hero", "Droid", "Sith"}) */ +#[GQL\Union(types: ["Hero", "Droid", "Sith"])] class SearchResult2 { public static function resolveType(TypeResolver $typeResolver, bool $value): ?Type From 64a2b576ba297d1bfedaa3d08912c4334c24dd06 Mon Sep 17 00:00:00 2001 From: Vincent Date: Mon, 4 Jan 2021 10:13:43 +0100 Subject: [PATCH 02/23] Add Doc Block type guesser & fix cs --- composer.json | 1 + src/Annotation/Field.php | 4 + src/Annotation/Mutation.php | 4 + src/Annotation/Provider.php | 4 + src/Annotation/Query.php | 4 + src/Config/Parser/AttributeParser.php | 2 +- .../Parser/MetadataParser/ClassesTypesMap.php | 2 +- .../Parser/MetadataParser/MetadataParser.php | 5 + .../TypeGuesser/DocBlockTypeGuesser.php | 129 ++++++++++++++++++ .../TypeGuesser/DoctrineTypeGuesser.php | 8 ++ .../TypeGuesser/PhpTypeGuesser.php | 30 ++++ .../TypeGuesser/TypeGuesser.php | 2 + .../TypeGuesser/TypeHintTypeGuesser.php | 32 ++--- tests/Config/Parser/AnnotationParserTest.php | 2 +- tests/Config/Parser/AttributeParserTest.php | 2 +- .../TypeGuesser/DocBlockTypeGuesserTest.php | 86 ++++++++++++ tests/Config/Parser/MetadataParserTest.php | 8 +- .../Invalid/InvalidReturnTypeGuessing.php | 1 + .../Parser/fixtures/annotations/Type/Cat.php | 8 ++ 19 files changed, 306 insertions(+), 28 deletions(-) create mode 100644 src/Config/Parser/MetadataParser/TypeGuesser/DocBlockTypeGuesser.php create mode 100644 src/Config/Parser/MetadataParser/TypeGuesser/PhpTypeGuesser.php create mode 100644 tests/Config/Parser/MetadataParser/TypeGuesser/DocBlockTypeGuesserTest.php diff --git a/composer.json b/composer.json index e97b67939..a7cc3ac3c 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,7 @@ "php": ">=7.4", "ext-json": "*", "murtukov/php-code-generator": "^0.1.4", + "phpdocumentor/reflection-docblock": "^5.2", "psr/log": "^1.0", "symfony/config": "^4.4 || ^5.0", "symfony/dependency-injection": "^4.4 || ^5.0", diff --git a/src/Annotation/Field.php b/src/Annotation/Field.php index 653e800bc..7d0380f23 100644 --- a/src/Annotation/Field.php +++ b/src/Annotation/Field.php @@ -67,6 +67,10 @@ class Field implements NamedArgumentConstructorAnnotation, Annotation */ public ?string $complexity; + /** + * @param string|string[]|null $argsBuilder + * @param string|string[]|null $fieldBuilder + */ public function __construct( ?string $name = null, ?string $type = null, diff --git a/src/Annotation/Mutation.php b/src/Annotation/Mutation.php index 7f5176182..2af678d27 100644 --- a/src/Annotation/Mutation.php +++ b/src/Annotation/Mutation.php @@ -23,6 +23,10 @@ final class Mutation extends Field implements NamedArgumentConstructorAnnotation */ public array $targetTypes; + /** + * @param string|string[]|null $targetTypes + * @param string|string[]|null $targetType + */ public function __construct( ?string $name = null, ?string $type = null, diff --git a/src/Annotation/Provider.php b/src/Annotation/Provider.php index c23433704..ecd1436d6 100644 --- a/src/Annotation/Provider.php +++ b/src/Annotation/Provider.php @@ -37,6 +37,10 @@ final class Provider implements NamedArgumentConstructorAnnotation, Annotation */ public ?array $targetMutationTypes; + /** + * @param string|string[]|null $targetQueryTypes + * @param string|string[]|null $targetMutationTypes + */ public function __construct(?string $prefix = null, $targetQueryTypes = null, $targetMutationTypes = null) { $this->prefix = $prefix; diff --git a/src/Annotation/Query.php b/src/Annotation/Query.php index 05a874753..3edbb5259 100644 --- a/src/Annotation/Query.php +++ b/src/Annotation/Query.php @@ -23,6 +23,10 @@ final class Query extends Field implements NamedArgumentConstructorAnnotation */ public ?array $targetTypes; + /** + * @param string|string[]|null $targetTypes + * @param string|string[]|null $targetType + */ public function __construct( ?string $name = null, ?string $type = null, diff --git a/src/Config/Parser/AttributeParser.php b/src/Config/Parser/AttributeParser.php index b928f92f1..ce6d32c50 100644 --- a/src/Config/Parser/AttributeParser.php +++ b/src/Config/Parser/AttributeParser.php @@ -25,7 +25,7 @@ public static function getMetadatas(Reflector $reflector): array case $reflector instanceof ReflectionProperty: $attributes = $reflector->getAttributes(); } - + // @phpstan-ignore-line return array_map(fn (ReflectionAttribute $attribute) => $attribute->newInstance(), $attributes); } diff --git a/src/Config/Parser/MetadataParser/ClassesTypesMap.php b/src/Config/Parser/MetadataParser/ClassesTypesMap.php index af477d7de..a54096e02 100644 --- a/src/Config/Parser/MetadataParser/ClassesTypesMap.php +++ b/src/Config/Parser/MetadataParser/ClassesTypesMap.php @@ -33,7 +33,7 @@ public function resolveType(string $className, array $filteredTypes = []): ?stri { foreach ($this->classesMap as $gqlType => $config) { if ($config['class'] === $className) { - if (in_array($config['type'], $filteredTypes)) { + if (empty($filteredTypes) || in_array($config['type'], $filteredTypes)) { return $gqlType; } } diff --git a/src/Config/Parser/MetadataParser/MetadataParser.php b/src/Config/Parser/MetadataParser/MetadataParser.php index 55578ca51..8f7827ce5 100644 --- a/src/Config/Parser/MetadataParser/MetadataParser.php +++ b/src/Config/Parser/MetadataParser/MetadataParser.php @@ -7,6 +7,7 @@ use Doctrine\Common\Annotations\AnnotationException; use Overblog\GraphQLBundle\Annotation\Annotation as Meta; use Overblog\GraphQLBundle\Annotation as Metadata; +use Overblog\GraphQLBundle\Config\Parser\MetadataParser\TypeGuesser\DocBlockTypeGuesser; use Overblog\GraphQLBundle\Config\Parser\MetadataParser\TypeGuesser\DoctrineTypeGuesser; use Overblog\GraphQLBundle\Config\Parser\MetadataParser\TypeGuesser\TypeGuessingException; use Overblog\GraphQLBundle\Config\Parser\MetadataParser\TypeGuesser\TypeHintTypeGuesser; @@ -90,6 +91,7 @@ public static function reset(array $configs): void { self::$map = new ClassesTypesMap(); self::$typeGuessers = [ + new DocBlockTypeGuesser(self::$map), new TypeHintTypeGuesser(self::$map), new DoctrineTypeGuesser(self::$map, $configs['doctrine']['types_mapping']), ]; @@ -876,6 +878,9 @@ private static function guessType(ReflectionClass $reflectionClass, Reflector $r { $errors = []; foreach (self::$typeGuessers as $typeGuesser) { + if (!$typeGuesser->supports($reflector)) { + continue; + } try { $type = $typeGuesser->guessType($reflectionClass, $reflector, $filterGraphQLTypes); diff --git a/src/Config/Parser/MetadataParser/TypeGuesser/DocBlockTypeGuesser.php b/src/Config/Parser/MetadataParser/TypeGuesser/DocBlockTypeGuesser.php new file mode 100644 index 000000000..97f69150e --- /dev/null +++ b/src/Config/Parser/MetadataParser/TypeGuesser/DocBlockTypeGuesser.php @@ -0,0 +1,129 @@ +createFromReflector($reflectionClass); + try { + $docBlock = $this->getParser()->create($reflector->getDocComment(), $context); + } catch (Exception $e) { + throw new TypeGuessingException(sprintf('Doc Block parsing failed with error: %s', $e->getMessage())); + } + $tagName = $reflector instanceof ReflectionProperty ? 'var' : 'return'; + $tags = $docBlock->getTagsByName($tagName); + $tag = $tags[0] ?? null; + if (!$tag || !$tag instanceof TagWithType) { + throw new TypeGuessingException(sprintf('No @%s tag found in doc block or tag has no type', $tagName)); + } + $type = $tag->getType(); + $isNullable = false; + $isList = false; + $isListNullable = false; + $exceptionPrefix = sprintf('Tag @%s found', $tagName); + + if ($type instanceof Compound) { + $type = $this->resolveCompound($type); + if (!$type) { + throw new TypeGuessingException(sprintf('%s, but composite types are only allowed with null. Ex: string|null.', $exceptionPrefix)); + } + $isNullable = true; + } elseif ($type instanceof Nullable) { + $isNullable = true; + $type = $type->getActualType(); + } + + if ($type instanceof AbstractList) { + $isList = true; + $isListNullable = $isNullable; + $isNullable = false; + $type = $type->getValueType(); + if ($type instanceof Compound) { + $type = $this->resolveCompound($type); + if (!$type) { + throw new TypeGuessingException(sprintf('%s, but composite types in array or iterable are only allowed with null. Ex: string|null.', $exceptionPrefix)); + } + $isNullable = true; + } elseif ($type instanceof Mixed_) { + throw new TypeGuessingException(sprintf('%s, but the array values cannot be mixed type', $exceptionPrefix)); + } + } + + if ($type instanceof Object_) { + $className = $type->getFqsen(); + if (!$className) { + throw new TypeGuessingException(sprintf('%s, but type "object" is too generic.', $exceptionPrefix, $className)); + } + // Remove first '\' from returned class name + $className = substr((string) $className, 1); + $gqlType = $this->map->resolveType((string) $className, $filterGraphQLTypes); + if (!$gqlType) { + throw new TypeGuessingException(sprintf('%s, but target object "%s" is not a GraphQL Type class.', $exceptionPrefix, $className)); + } + } else { + $gqlType = $this->resolveTypeFromPhpType((string) $type); + if (!$gqlType) { + throw new TypeGuessingException(sprintf('%s, but unable to resolve type "%s" to a GraphQL scalar.', $exceptionPrefix, (string) $type)); + } + } + + return $isList ? sprintf('[%s%s]%s', $gqlType, $isNullable ? '' : '!', $isListNullable ? '' : '!') : sprintf('%s%s', $gqlType, $isNullable ? '' : '!'); + } + + protected function resolveCompound(Compound $compound): ?Type + { + $typeNull = new Null_(); + if ($compound->getIterator()->count() > 2 || !$compound->contains($typeNull)) { + return null; + } + $type = current(array_filter(iterator_to_array($compound->getIterator(), false), fn (Type $type) => (string) $type !== (string) $typeNull)); + + return $type; + } + + private function getParser(): DocBlockFactory + { + if (!isset($this->factory)) { + $this->factory = DocBlockFactory::createInstance(); + } + + return $this->factory; + } +} diff --git a/src/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php b/src/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php index b2671d565..f1957a62e 100644 --- a/src/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php +++ b/src/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php @@ -36,6 +36,14 @@ public function getName(): string return 'Doctrine annotations '; } + public function supports(Reflector $reflector): bool + { + return $reflector instanceof ReflectionProperty; + } + + /** + * @param ReflectionProperty $reflector + */ public function guessType(ReflectionClass $reflectionClass, Reflector $reflector, array $filterGraphQLTypes = []): ?string { if (!$reflector instanceof ReflectionProperty) { diff --git a/src/Config/Parser/MetadataParser/TypeGuesser/PhpTypeGuesser.php b/src/Config/Parser/MetadataParser/TypeGuesser/PhpTypeGuesser.php new file mode 100644 index 000000000..b00a0f191 --- /dev/null +++ b/src/Config/Parser/MetadataParser/TypeGuesser/PhpTypeGuesser.php @@ -0,0 +1,30 @@ +map = $map; } + abstract public function supports(Reflector $reflector): bool; + abstract public function getName(): string; abstract public function guessType(ReflectionClass $reflectionClass, Reflector $reflector, array $filterGraphQLTypes = []): ?string; diff --git a/src/Config/Parser/MetadataParser/TypeGuesser/TypeHintTypeGuesser.php b/src/Config/Parser/MetadataParser/TypeGuesser/TypeHintTypeGuesser.php index ef31d5688..83d1bb95a 100644 --- a/src/Config/Parser/MetadataParser/TypeGuesser/TypeHintTypeGuesser.php +++ b/src/Config/Parser/MetadataParser/TypeGuesser/TypeHintTypeGuesser.php @@ -11,13 +11,21 @@ use ReflectionProperty; use Reflector; -class TypeHintTypeGuesser extends TypeGuesser +class TypeHintTypeGuesser extends PhpTypeGuesser { public function getName(): string { return 'Type Hint'; } + public function supports(Reflector $reflector): bool + { + return $reflector instanceof ReflectionProperty || $reflector instanceof ReflectionParameter || $reflector instanceof ReflectionMethod; + } + + /** + * @param ReflectionProperty|ReflectionParameter|ReflectionMethod $reflector + */ public function guessType(ReflectionClass $reflectionClass, Reflector $reflector, array $filterGraphQLTypes = []): ?string { $type = null; @@ -59,26 +67,4 @@ public function guessType(ReflectionClass $reflectionClass, Reflector $reflector return sprintf('%s%s', $gqlType, $nullable ? '' : '!'); } - - /** - * Convert a PHP Builtin type to a GraphQL type. - */ - protected function resolveTypeFromPhpType(string $phpType): ?string - { - switch ($phpType) { - case 'boolean': - case 'bool': - return 'Boolean'; - case 'integer': - case 'int': - return 'Int'; - case 'float': - case 'double': - return 'Float'; - case 'string': - return 'String'; - default: - return null; - } - } } diff --git a/tests/Config/Parser/AnnotationParserTest.php b/tests/Config/Parser/AnnotationParserTest.php index 7f7a809a3..835bb557c 100644 --- a/tests/Config/Parser/AnnotationParserTest.php +++ b/tests/Config/Parser/AnnotationParserTest.php @@ -9,7 +9,7 @@ class AnnotationParserTest extends MetadataParserTest { - public function parser($method, ...$args) + public function parser(string $method, ...$args) { return AnnotationParser::$method(...$args); } diff --git a/tests/Config/Parser/AttributeParserTest.php b/tests/Config/Parser/AttributeParserTest.php index cd0fb71fd..5bf01a19a 100644 --- a/tests/Config/Parser/AttributeParserTest.php +++ b/tests/Config/Parser/AttributeParserTest.php @@ -11,7 +11,7 @@ */ class AttributeParserTest extends MetadataParserTest { - public function parser($method, ...$args) + public function parser(string $method, ...$args) { return AttributeParser::$method(...$args); } diff --git a/tests/Config/Parser/MetadataParser/TypeGuesser/DocBlockTypeGuesserTest.php b/tests/Config/Parser/MetadataParser/TypeGuesser/DocBlockTypeGuesserTest.php new file mode 100644 index 000000000..aa4522b98 --- /dev/null +++ b/tests/Config/Parser/MetadataParser/TypeGuesser/DocBlockTypeGuesserTest.php @@ -0,0 +1,86 @@ + 'var', + ReflectionMethod::class => 'return', + ]; + + public function testGuess(): void + { + foreach ($this->reflectors as $reflectorClass => $tag) { + $this->doTest('string', 'String!', null, $reflectorClass); + $this->doTest('?string', 'String', null, $reflectorClass); + $this->doTest('string|null', 'String', null, $reflectorClass); + $this->doTest('string[]', '[String!]!', null, $reflectorClass); + $this->doTest('array', '[String!]!', null, $reflectorClass); + $this->doTest('array|null', '[String!]', null, $reflectorClass); + $this->doTest('array|null', '[String]', null, $reflectorClass); + $this->doTest('int', 'Int!', null, $reflectorClass); + $this->doTest('integer', 'Int!', null, $reflectorClass); + $this->doTest('boolean', 'Boolean!', null, $reflectorClass); + $this->doTest('bool', 'Boolean!', null, $reflectorClass); + $this->doTest('float', 'Float!', null, $reflectorClass); + $this->doTest('double', 'Float!', null, $reflectorClass); + $this->doTest('iterable', '[String!]!', null, $reflectorClass); + + $this->doTestError('int|float', $reflectorClass, 'Tag @'.$tag.' found, but composite types are only allowed with null'); + $this->doTestError('array', $reflectorClass, 'Tag @'.$tag.' found, but composite types in array or iterable are only allowed with null'); + $this->doTestError('UnknownClass', $reflectorClass, 'Tag @'.$tag.' found, but target object "Overblog\GraphQLBundle\Tests\Config\Parser\UnknownClass" is not a GraphQL Type class'); + $this->doTestError('object', $reflectorClass, 'Tag @'.$tag.' found, but type "object" is too generic'); + $this->doTestError('mixed[]', $reflectorClass, 'Tag @'.$tag.' found, but the array values cannot be mixed type'); + $this->doTestError('array', $reflectorClass, 'Tag @'.$tag.' found, but the array values cannot be mixed type'); + $this->doTestError('', $reflectorClass, 'No @'.$tag.' tag found in doc block or tag has no type'); + $this->doTestError('[]', $reflectorClass, 'Doc Block parsing failed'); + + $map = new ClassesTypesMap(); + $map->addClassType('GQLType1', 'Fake\Class1', 'object'); + $map->addClassType('GQLType2', 'Fake\Class2', 'object'); + $map->addClassType('Foo', ClassesTypesMap::class, 'object'); + + $this->doTest('\Fake\Class1[]', '[GQLType1!]!', $map); + $this->doTest('ClassesTypesMap|null', 'Foo', $map); + } + } + + protected function doTest(string $docType, string $gqlType, ClassesTypesMap $map = null, string $reflectorClass = ReflectionProperty::class) + { + $docBlockGuesser = new DocBlockTypeGuesser($map ?: new ClassesTypesMap()); + $this->assertEquals($gqlType, $docBlockGuesser->guessType(new ReflectionClass(__CLASS__), $this->getMockedReflector($docType, $reflectorClass))); + } + + protected function doTestError(string $docType, string $reflectorClass, string $match) + { + $docBlockGuesser = new DocBlockTypeGuesser(new ClassesTypesMap()); + try { + $docBlockGuesser->guessType(new ReflectionClass(__CLASS__), $this->getMockedReflector($docType, $reflectorClass)); + $this->fail(sprintf('The @var "%s" should resolve to GraphQL type "%s"', $docType, $match)); + } catch (Exception $e) { + $this->assertInstanceOf(TypeGuessingException::class, $e); + $this->assertStringContainsString($match, $e->getMessage()); + } + } + + protected function getMockedReflector(string $type, string $className = ReflectionProperty::class) + { + $mock = $this->createMock($className); + $mock->method('getDocComment') + ->willReturn(sprintf('/** @%s %s **/', $this->reflectors[$className], $type)); + + return $mock; + } +} diff --git a/tests/Config/Parser/MetadataParserTest.php b/tests/Config/Parser/MetadataParserTest.php index 1c40b5251..360068352 100644 --- a/tests/Config/Parser/MetadataParserTest.php +++ b/tests/Config/Parser/MetadataParserTest.php @@ -32,7 +32,12 @@ abstract class MetadataParserTest extends TestCase ], ]; - abstract public function parser($method, ...$args); + /** + * @param array $args + * + * @return mixed + */ + abstract public function parser(string $method, ...$args); abstract public function formatMetadata(string $metadata): string; @@ -186,6 +191,7 @@ public function testTypes(): void 'fields' => [ 'name' => ['type' => 'String!', 'description' => 'The name of the animal'], 'lives' => ['type' => 'Int!'], + 'toys' => ['type' => '[String!]!'], ], ]); } diff --git a/tests/Config/Parser/fixtures/annotations/Invalid/InvalidReturnTypeGuessing.php b/tests/Config/Parser/fixtures/annotations/Invalid/InvalidReturnTypeGuessing.php index e9a4f5459..cc89a1797 100644 --- a/tests/Config/Parser/fixtures/annotations/Invalid/InvalidReturnTypeGuessing.php +++ b/tests/Config/Parser/fixtures/annotations/Invalid/InvalidReturnTypeGuessing.php @@ -17,6 +17,7 @@ class InvalidReturnTypeGuessing * @phpstan-ignore-next-line */ #[GQL\Field(name: "guessFailed")] + // @phpstan-ignore-next-line public function guessFail(int $test) { return 12; diff --git a/tests/Config/Parser/fixtures/annotations/Type/Cat.php b/tests/Config/Parser/fixtures/annotations/Type/Cat.php index 88cb42bda..6111edc1b 100644 --- a/tests/Config/Parser/fixtures/annotations/Type/Cat.php +++ b/tests/Config/Parser/fixtures/annotations/Type/Cat.php @@ -19,4 +19,12 @@ class Cat extends Animal */ #[GQL\Field(type: "Int!")] protected int $lives; + + /** + * @GQL\Field + * + * @var string[] + */ + #[GQL\Field] + protected array $toys; } From a70e57c7bb43362221f20f28c566d9ca3ed8d544 Mon Sep 17 00:00:00 2001 From: Vincent Date: Mon, 4 Jan 2021 11:27:47 +0100 Subject: [PATCH 03/23] Add @FieldBuilder & @ArgsBuilder & deprecated @Field properties --- src/Annotation/ArgsBuilder.php | 40 +++++++++++++++++++ src/Annotation/Field.php | 4 ++ src/Annotation/FieldBuilder.php | 40 +++++++++++++++++++ .../Parser/MetadataParser/MetadataParser.php | 15 +++++-- .../TypeGuesser/DocBlockTypeGuesser.php | 7 +++- .../TypeGuesser/DocBlockTypeGuesserTest.php | 25 ++++++++++-- tests/Config/Parser/MetadataParserTest.php | 12 ++++++ .../fixtures/annotations/Type/Planet.php | 23 ++++++++++- 8 files changed, 156 insertions(+), 10 deletions(-) create mode 100644 src/Annotation/ArgsBuilder.php create mode 100644 src/Annotation/FieldBuilder.php diff --git a/src/Annotation/ArgsBuilder.php b/src/Annotation/ArgsBuilder.php new file mode 100644 index 000000000..5b1f63cdd --- /dev/null +++ b/src/Annotation/ArgsBuilder.php @@ -0,0 +1,40 @@ +value = $value; + $this->config = $config; + } +} diff --git a/src/Annotation/Field.php b/src/Annotation/Field.php index 7d0380f23..012253981 100644 --- a/src/Annotation/Field.php +++ b/src/Annotation/Field.php @@ -50,6 +50,8 @@ class Field implements NamedArgumentConstructorAnnotation, Annotation * Args builder. * * @var mixed + * + * @deprecated */ public $argsBuilder; @@ -57,6 +59,8 @@ class Field implements NamedArgumentConstructorAnnotation, Annotation * Field builder. * * @var mixed + * + * @deprecated */ public $fieldBuilder; diff --git a/src/Annotation/FieldBuilder.php b/src/Annotation/FieldBuilder.php new file mode 100644 index 000000000..825b9915d --- /dev/null +++ b/src/Annotation/FieldBuilder.php @@ -0,0 +1,40 @@ +value = $value; + $this->config = $config; + } +} diff --git a/src/Config/Parser/MetadataParser/MetadataParser.php b/src/Config/Parser/MetadataParser/MetadataParser.php index 8f7827ce5..d24e281bc 100644 --- a/src/Config/Parser/MetadataParser/MetadataParser.php +++ b/src/Config/Parser/MetadataParser/MetadataParser.php @@ -560,9 +560,12 @@ private static function getTypeFieldConfigurationFromReflector(ReflectionClass $ } } - if ($fieldMetadata->argsBuilder) { + $argsBuilder = self::getFirstMetadataMatching($metadatas, Metadata\ArgsBuilder::class); + if ($argsBuilder) { + $fieldConfiguration['argsBuilder'] = ['builder' => $argsBuilder->value, 'config' => $argsBuilder->config]; + } elseif ($fieldMetadata->argsBuilder) { if (is_string($fieldMetadata->argsBuilder)) { - $fieldConfiguration['argsBuilder'] = $fieldMetadata->argsBuilder; + $fieldConfiguration['argsBuilder'] = ['builder' => $fieldMetadata->argsBuilder, 'config' => []]; } elseif (is_array($fieldMetadata->argsBuilder)) { list($builder, $builderConfig) = $fieldMetadata->argsBuilder; $fieldConfiguration['argsBuilder'] = ['builder' => $builder, 'config' => $builderConfig]; @@ -570,10 +573,14 @@ private static function getTypeFieldConfigurationFromReflector(ReflectionClass $ throw new InvalidArgumentException(sprintf('The attribute "argsBuilder" on metadata %s defined on "%s" must be a string or an array where first index is the builder name and the second is the config.', static::formatMetadata($fieldMetadataName), $reflector->getName())); } } - - if ($fieldMetadata->fieldBuilder) { + $fieldBuilder = self::getFirstMetadataMatching($metadatas, Metadata\FieldBuilder::class); + if ($fieldBuilder) { + $fieldConfiguration['builder'] = $fieldBuilder->value; + $fieldConfiguration['builderConfig'] = $fieldBuilder->config; + } elseif ($fieldMetadata->fieldBuilder) { if (is_string($fieldMetadata->fieldBuilder)) { $fieldConfiguration['builder'] = $fieldMetadata->fieldBuilder; + $fieldConfiguration['builderConfig'] = []; } elseif (is_array($fieldMetadata->fieldBuilder)) { list($builder, $builderConfig) = $fieldMetadata->fieldBuilder; $fieldConfiguration['builder'] = $builder; diff --git a/src/Config/Parser/MetadataParser/TypeGuesser/DocBlockTypeGuesser.php b/src/Config/Parser/MetadataParser/TypeGuesser/DocBlockTypeGuesser.php index 97f69150e..268b3d27d 100644 --- a/src/Config/Parser/MetadataParser/TypeGuesser/DocBlockTypeGuesser.php +++ b/src/Config/Parser/MetadataParser/TypeGuesser/DocBlockTypeGuesser.php @@ -42,8 +42,13 @@ public function guessType(ReflectionClass $reflectionClass, Reflector $reflector { $contextFactory = new ContextFactory(); $context = $contextFactory->createFromReflector($reflectionClass); + $docBlockComment = $reflector->getDocComment(); + if (!$docBlockComment) { + throw new TypeGuessingException(sprintf('Doc Block not found')); + } + try { - $docBlock = $this->getParser()->create($reflector->getDocComment(), $context); + $docBlock = $this->getParser()->create($docBlockComment, $context); } catch (Exception $e) { throw new TypeGuessingException(sprintf('Doc Block parsing failed with error: %s', $e->getMessage())); } diff --git a/tests/Config/Parser/MetadataParser/TypeGuesser/DocBlockTypeGuesserTest.php b/tests/Config/Parser/MetadataParser/TypeGuesser/DocBlockTypeGuesserTest.php index aa4522b98..65719a97a 100644 --- a/tests/Config/Parser/MetadataParser/TypeGuesser/DocBlockTypeGuesserTest.php +++ b/tests/Config/Parser/MetadataParser/TypeGuesser/DocBlockTypeGuesserTest.php @@ -15,7 +15,7 @@ class DocBlockTypeGuesserTest extends TestCase { - protected $reflectors = [ + protected array $reflectors = [ ReflectionProperty::class => 'var', ReflectionMethod::class => 'return', ]; @@ -57,13 +57,27 @@ public function testGuess(): void } } - protected function doTest(string $docType, string $gqlType, ClassesTypesMap $map = null, string $reflectorClass = ReflectionProperty::class) + public function testMissingDocBlock(): void + { + $docBlockGuesser = new DocBlockTypeGuesser(new ClassesTypesMap()); + $mock = $this->createMock(ReflectionProperty::class); + $mock->method('getDocComment')->willReturn(false); + + try { + $docBlockGuesser->guessType(new ReflectionClass(__CLASS__), $mock); + } catch (Exception $e) { + $this->assertInstanceOf(TypeGuessingException::class, $e); + $this->assertEquals('Doc Block not found', $e->getMessage()); + } + } + + protected function doTest(string $docType, string $gqlType, ClassesTypesMap $map = null, string $reflectorClass = ReflectionProperty::class): void { $docBlockGuesser = new DocBlockTypeGuesser($map ?: new ClassesTypesMap()); $this->assertEquals($gqlType, $docBlockGuesser->guessType(new ReflectionClass(__CLASS__), $this->getMockedReflector($docType, $reflectorClass))); } - protected function doTestError(string $docType, string $reflectorClass, string $match) + protected function doTestError(string $docType, string $reflectorClass, string $match): void { $docBlockGuesser = new DocBlockTypeGuesser(new ClassesTypesMap()); try { @@ -75,12 +89,17 @@ protected function doTestError(string $docType, string $reflectorClass, string $ } } + /** + * @return ReflectionProperty|ReflectionMethod + */ protected function getMockedReflector(string $type, string $className = ReflectionProperty::class) { + // @phpstan-ignore-next-line $mock = $this->createMock($className); $mock->method('getDocComment') ->willReturn(sprintf('/** @%s %s **/', $this->reflectors[$className], $type)); + /** @var ReflectionProperty|ReflectionMethod $mock */ return $mock; } } diff --git a/tests/Config/Parser/MetadataParserTest.php b/tests/Config/Parser/MetadataParserTest.php index 360068352..b25c95f5c 100644 --- a/tests/Config/Parser/MetadataParserTest.php +++ b/tests/Config/Parser/MetadataParserTest.php @@ -174,6 +174,18 @@ public function testTypes(): void ], 'resolve' => "@=resolver('closest_planet', [args['filter']])", ], + 'notesDeprecated' => [ + 'builder' => 'NoteFieldBuilder', + 'builderConfig' => ['option1' => 'value1'], + ], + 'closestPlanetDeprecated' => [ + 'type' => 'Planet', + 'argsBuilder' => [ + 'builder' => 'PlanetFilterArgBuilder', + 'config' => ['option2' => 'value2'], + ], + 'resolve' => "@=resolver('closest_planet', [args['filter']])", + ], ], ]); diff --git a/tests/Config/Parser/fixtures/annotations/Type/Planet.php b/tests/Config/Parser/fixtures/annotations/Type/Planet.php index 4212d35fd..0c9b6a747 100644 --- a/tests/Config/Parser/fixtures/annotations/Type/Planet.php +++ b/tests/Config/Parser/fixtures/annotations/Type/Planet.php @@ -33,11 +33,30 @@ class Planet #[GQL\Field(type: "Int!")] protected int $population; + /** + * @GQL\Field + * @GQL\FieldBuilder(value="NoteFieldBuilder", config={"option1"="value1"}) + */ + #[GQL\Field] + #[GQL\FieldBuilder("NoteFieldBuilder", ["option1" => "value1"])] + public array $notes; + + /** + * @GQL\Field( + * type="Planet", + * resolve="@=resolver('closest_planet', [args['filter']])" + * ) + * @GQL\ArgsBuilder(value="PlanetFilterArgBuilder", config={"option2"="value2"}) + */ + #[GQL\Field(type: "Planet", resolve: "@=resolver('closest_planet', [args['filter']])")] + #[GQL\ArgsBuilder("PlanetFilterArgBuilder", ["option2" => "value2"])] + public Planet $closestPlanet; + /** * @GQL\Field(fieldBuilder={"NoteFieldBuilder", {"option1": "value1"}}) */ #[GQL\Field(fieldBuilder: ["NoteFieldBuilder", ["option1" => "value1"]])] - public array $notes; + public array $notesDeprecated; /** * @GQL\Field( @@ -47,5 +66,5 @@ class Planet * ) */ #[GQL\Field(type: "Planet", argsBuilder: ["PlanetFilterArgBuilder", ["option2" => "value2"]], resolve: "@=resolver('closest_planet', [args['filter']])")] - public Planet $closestPlanet; + public Planet $closestPlanetDeprecated; } From 2dc3e5142bcf730d41a1a7a31e8845a1034432d3 Mon Sep 17 00:00:00 2001 From: Vincent Date: Wed, 6 Jan 2021 09:15:52 +0100 Subject: [PATCH 04/23] Handle annotation shortcuts & fix stan --- phpstan-baseline.neon | 15 ++++--- src/Annotation/Access.php | 2 +- src/Annotation/Annotation.php | 9 +++- src/Annotation/Arg.php | 9 ++-- src/Annotation/ArgsBuilder.php | 26 +---------- src/Annotation/Builder.php | 40 +++++++++++++++++ src/Annotation/Deprecated.php | 2 +- src/Annotation/Description.php | 2 +- src/Annotation/Enum.php | 10 +++-- src/Annotation/EnumValue.php | 11 +++-- src/Annotation/Field.php | 29 +++++++------ src/Annotation/FieldBuilder.php | 25 +---------- src/Annotation/FieldsBuilder.php | 25 +---------- src/Annotation/Input.php | 10 +++-- src/Annotation/IsPublic.php | 2 +- src/Annotation/Mutation.php | 7 +-- src/Annotation/Provider.php | 2 +- src/Annotation/Query.php | 7 +-- src/Annotation/Scalar.php | 10 +++-- src/Annotation/Type.php | 11 +++-- src/Annotation/TypeInterface.php | 10 +++-- src/Annotation/Union.php | 10 +++-- src/Config/Parser/AttributeParser.php | 2 +- .../Parser/MetadataParser/ClassesTypesMap.php | 2 +- .../Parser/MetadataParser/MetadataParser.php | 12 ++++-- .../TypeGuesser/DocBlockTypeGuesser.php | 12 ++++-- tests/Config/Parser/MetadataParserTest.php | 43 ++++++++++++++++++- .../annotations/Deprecated/Deprecated.php | 2 +- .../Parser/fixtures/annotations/Enum/Pet.php | 34 +++++++++++++++ .../Parser/fixtures/annotations/Enum/Race.php | 4 +- .../fixtures/annotations/Input/Star.php | 20 +++++++++ .../fixtures/annotations/Scalar/MyScalar3.php | 15 +++++++ .../fixtures/annotations/Type/Armored.php | 4 +- .../Parser/fixtures/annotations/Type/Cat.php | 8 +++- .../fixtures/annotations/Type/Crystal.php | 4 +- .../Parser/fixtures/annotations/Type/Dog.php | 35 +++++++++++++++ .../annotations/Union/SearchResult.php | 4 +- 37 files changed, 330 insertions(+), 145 deletions(-) create mode 100644 src/Annotation/Builder.php create mode 100644 tests/Config/Parser/fixtures/annotations/Enum/Pet.php create mode 100644 tests/Config/Parser/fixtures/annotations/Input/Star.php create mode 100644 tests/Config/Parser/fixtures/annotations/Scalar/MyScalar3.php create mode 100644 tests/Config/Parser/fixtures/annotations/Type/Dog.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 4be45af27..95c2242eb 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -33,17 +33,17 @@ parameters: - message: "#^Parameter \\#1 \\$resource of class Symfony\\\\Component\\\\Config\\\\Resource\\\\FileResource constructor expects string, string\\|false given\\.$#" count: 1 - path: src/Config/Parser/AnnotationParser.php + path: src/Config/Parser/MetadataParser/MetadataParser.php - message: "#^Parameter \\#1 \\$filename of function file_get_contents expects string, string\\|false given\\.$#" count: 1 - path: src/Config/Parser/AnnotationParser.php + path: src/Config/Parser/MetadataParser/MetadataParser.php - message: "#^Parameter \\#2 \\$subject of function preg_match expects string, string\\|false given\\.$#" count: 1 - path: src/Config/Parser/AnnotationParser.php + path: src/Config/Parser/MetadataParser/MetadataParser.php - message: "#^Access to an undefined property GraphQL\\\\Language\\\\AST\\\\Node\\:\\:\\$description\\.$#" @@ -126,7 +126,7 @@ parameters: path: src/DependencyInjection/Compiler/AliasedPass.php - - message: "#^Parameter \\#1 \\$function of function call_user_func expects callable\\(\\)\\: mixed, array\\('Overblog…'\\|'Overblog…'\\|'Overblog…'\\|'Overblog…', 'parse'\\|'preParse'\\) given\\.$#" + message: "#^Parameter \\#1 \\$function of function call_user_func expects callable\\(\\)\\: mixed, array\\('Overblog…'\\|'Overblog…'\\|'Overblog…'\\|'Overblog…'\\|'Overblog…', 'parse'\\|'preParse'\\) given\\.$#" count: 1 path: src/DependencyInjection/Compiler/ConfigParserPass.php @@ -443,4 +443,9 @@ parameters: - message: "#^Array \\(array\\) does not accept Symfony\\\\Component\\\\Validator\\\\Mapping\\\\MetadataInterface\\.$#" count: 1 - path: src/Validator/InputValidator.php \ No newline at end of file + path: src/Validator/InputValidator.php + + - + message: "#^Call to an undefined method ReflectionClass|ReflectionMethod|ReflectionProperty::getAttributes().$#" + count: 1 + path: src/Config/Parser/AttributeParser.php \ No newline at end of file diff --git a/src/Annotation/Access.php b/src/Annotation/Access.php index a5e42daba..4802856f8 100644 --- a/src/Annotation/Access.php +++ b/src/Annotation/Access.php @@ -14,7 +14,7 @@ * @Target({"CLASS", "PROPERTY", "METHOD"}) */ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::TARGET_PROPERTY)] -final class Access implements NamedArgumentConstructorAnnotation, Annotation +final class Access extends Annotation implements NamedArgumentConstructorAnnotation { /** * Field access. diff --git a/src/Annotation/Annotation.php b/src/Annotation/Annotation.php index b788c0a58..432faeb08 100644 --- a/src/Annotation/Annotation.php +++ b/src/Annotation/Annotation.php @@ -4,6 +4,13 @@ namespace Overblog\GraphQLBundle\Annotation; -interface Annotation +use Doctrine\Common\Annotations\AnnotationException; + +abstract class Annotation { + protected function cumulatedAttributesException(string $attribute, string $value, string $attributeValue): void + { + $annotationName = str_replace('Overblog\GraphQLBundle\Annotation\\', '', get_class($this)); + throw new AnnotationException(sprintf('The @%s %s is defined by both the default attribute "%s" and the %s attribute "%s". Pick one.', $annotationName, $attribute, $value, $attribute, $attributeValue)); + } } diff --git a/src/Annotation/Arg.php b/src/Annotation/Arg.php index 8c6362397..6a3f74cbf 100644 --- a/src/Annotation/Arg.php +++ b/src/Annotation/Arg.php @@ -14,7 +14,7 @@ * @Target({"ANNOTATION","PROPERTY","METHOD"}) */ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] -final class Arg implements NamedArgumentConstructorAnnotation, Annotation +final class Arg extends Annotation implements NamedArgumentConstructorAnnotation { /** * Argument name. @@ -51,9 +51,12 @@ final class Arg implements NamedArgumentConstructorAnnotation, Annotation /** * @param mixed|null $default */ - public function __construct(string $name, string $type, ?string $description = null, $default = null) + public function __construct(string $name, string $type, ?string $description = null, $default = null, ?string $value = null) { - $this->name = $name; + if ($value && $name) { + $this->cumulatedAttributesException('name', $value, $name); + } + $this->name = $value ?: $name; $this->description = $description; $this->type = $type; $this->default = $default; diff --git a/src/Annotation/ArgsBuilder.php b/src/Annotation/ArgsBuilder.php index 5b1f63cdd..5e6f3369d 100644 --- a/src/Annotation/ArgsBuilder.php +++ b/src/Annotation/ArgsBuilder.php @@ -4,8 +4,7 @@ namespace Overblog\GraphQLBundle\Annotation; -use \Attribute; -use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; +use Attribute; /** * Annotation for GraphQL args builders. @@ -14,27 +13,6 @@ * @Target({"PROPERTY", "METHOD"}) */ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD)] -final class ArgsBuilder implements NamedArgumentConstructorAnnotation, Annotation +final class ArgsBuilder extends Builder { - /** - * Builder name. - * - * @Required - * - * @var string - */ - public string $value; - - /** - * The builder config. - * - * @var mixed - */ - public $config = []; - - public function __construct(string $value, array $config = []) - { - $this->value = $value; - $this->config = $config; - } } diff --git a/src/Annotation/Builder.php b/src/Annotation/Builder.php new file mode 100644 index 000000000..8f28b2ca2 --- /dev/null +++ b/src/Annotation/Builder.php @@ -0,0 +1,40 @@ +value = $value; + $this->config = $config; + } +} diff --git a/src/Annotation/Deprecated.php b/src/Annotation/Deprecated.php index eae3a47e9..f2ffbf388 100644 --- a/src/Annotation/Deprecated.php +++ b/src/Annotation/Deprecated.php @@ -14,7 +14,7 @@ * @Target({"METHOD", "PROPERTY"}) */ #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER | Attribute::TARGET_CLASS_CONSTANT)] -final class Deprecated implements NamedArgumentConstructorAnnotation, Annotation +final class Deprecated extends Annotation implements NamedArgumentConstructorAnnotation { /** * The deprecation reason. diff --git a/src/Annotation/Description.php b/src/Annotation/Description.php index e659e6c04..54114c923 100644 --- a/src/Annotation/Description.php +++ b/src/Annotation/Description.php @@ -14,7 +14,7 @@ * @Target({"CLASS", "METHOD", "PROPERTY"}) */ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER | Attribute::TARGET_CLASS_CONSTANT)] -final class Description implements NamedArgumentConstructorAnnotation, Annotation +final class Description extends Annotation implements NamedArgumentConstructorAnnotation { /** * The object description. diff --git a/src/Annotation/Enum.php b/src/Annotation/Enum.php index 5eabe5201..3496a38b1 100644 --- a/src/Annotation/Enum.php +++ b/src/Annotation/Enum.php @@ -5,6 +5,7 @@ namespace Overblog\GraphQLBundle\Annotation; use \Attribute; +use Doctrine\Common\Annotations\AnnotationException; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** @@ -14,7 +15,7 @@ * @Target("CLASS") */ #[Attribute(Attribute::TARGET_CLASS)] -final class Enum implements NamedArgumentConstructorAnnotation, Annotation +final class Enum extends Annotation implements NamedArgumentConstructorAnnotation { /** * Enum name. @@ -30,9 +31,12 @@ final class Enum implements NamedArgumentConstructorAnnotation, Annotation */ public array $values; - public function __construct(?string $name = null, array $values = []) + public function __construct(?string $name = null, array $values = [], ?string $value = null) { - $this->name = $name; + if ($name && $value) { + $this->cumulatedAttributesException('name', $value, $name); + } + $this->name = $value ?: $name; $this->values = $values; } } diff --git a/src/Annotation/EnumValue.php b/src/Annotation/EnumValue.php index 651926e78..af36e30ef 100644 --- a/src/Annotation/EnumValue.php +++ b/src/Annotation/EnumValue.php @@ -13,8 +13,8 @@ * @Annotation * @Target({"ANNOTATION", "CLASS"}) */ -#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_CLASS_CONSTANT | Attribute::IS_REPEATABLE)] -final class EnumValue implements NamedArgumentConstructorAnnotation, Annotation +#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] +final class EnumValue extends Annotation implements NamedArgumentConstructorAnnotation { /** * @var string @@ -31,9 +31,12 @@ final class EnumValue implements NamedArgumentConstructorAnnotation, Annotation */ public ?string $deprecationReason; - public function __construct(?string $name = null, ?string $description = null, ?string $deprecationReason = null) + public function __construct(?string $name = null, ?string $description = null, ?string $deprecationReason = null, ?string $value = null) { - $this->name = $name; + if ($name && $value) { + $this->cumulatedAttributesException('name', $value, $name); + } + $this->name = $value ?: $name; $this->description = $description; $this->deprecationReason = $deprecationReason; } diff --git a/src/Annotation/Field.php b/src/Annotation/Field.php index 012253981..7f5441f5e 100644 --- a/src/Annotation/Field.php +++ b/src/Annotation/Field.php @@ -4,7 +4,8 @@ namespace Overblog\GraphQLBundle\Annotation; -use \Attribute; +use Attribute; +use Doctrine\Common\Annotations\AnnotationException; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** @@ -14,18 +15,18 @@ * @Target({"PROPERTY", "METHOD"}) */ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD)] -class Field implements NamedArgumentConstructorAnnotation, Annotation +class Field extends Annotation implements NamedArgumentConstructorAnnotation { /** * The field name. - * + * * @var string */ public ?string $name; /** * Field Type. - * + * * @var string */ public ?string $type; @@ -34,14 +35,14 @@ class Field implements NamedArgumentConstructorAnnotation, Annotation * Field arguments. * * @var array<\Overblog\GraphQLBundle\Annotation\Arg> - * + * * @deprecated */ public array $args = []; /** * Resolver for this property. - * + * * @var string */ public ?string $resolve; @@ -50,7 +51,7 @@ class Field implements NamedArgumentConstructorAnnotation, Annotation * Args builder. * * @var mixed - * + * * @deprecated */ public $argsBuilder; @@ -59,7 +60,7 @@ class Field implements NamedArgumentConstructorAnnotation, Annotation * Field builder. * * @var mixed - * + * * @deprecated */ public $fieldBuilder; @@ -72,8 +73,8 @@ class Field implements NamedArgumentConstructorAnnotation, Annotation public ?string $complexity; /** - * @param string|string[]|null $argsBuilder - * @param string|string[]|null $fieldBuilder + * @param string|string[]|null $argsBuilder + * @param string|string[]|null $fieldBuilder */ public function __construct( ?string $name = null, @@ -82,9 +83,13 @@ public function __construct( ?string $resolve = null, $argsBuilder = null, $fieldBuilder = null, - ?string $complexity = null + ?string $complexity = null, + ?string $value = null ) { - $this->name = $name; + if ($name && $value) { + $this->cumulatedAttributesException('name', $value, $name); + } + $this->name = $value ?: $name; $this->type = $type; $this->args = $args; $this->resolve = $resolve; diff --git a/src/Annotation/FieldBuilder.php b/src/Annotation/FieldBuilder.php index 825b9915d..0ba3d151b 100644 --- a/src/Annotation/FieldBuilder.php +++ b/src/Annotation/FieldBuilder.php @@ -4,7 +4,7 @@ namespace Overblog\GraphQLBundle\Annotation; -use \Attribute; +use Attribute; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** @@ -14,27 +14,6 @@ * @Target({"PROPERTY", "METHOD"}) */ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD)] -final class FieldBuilder implements NamedArgumentConstructorAnnotation, Annotation +final class FieldBuilder extends Builder implements NamedArgumentConstructorAnnotation { - /** - * Builder name. - * - * @Required - * - * @var string - */ - public string $value; - - /** - * The builder config. - * - * @var mixed - */ - public $config = []; - - public function __construct(string $value, array $config = []) - { - $this->value = $value; - $this->config = $config; - } } diff --git a/src/Annotation/FieldsBuilder.php b/src/Annotation/FieldsBuilder.php index 1810aa424..50e2eb284 100644 --- a/src/Annotation/FieldsBuilder.php +++ b/src/Annotation/FieldsBuilder.php @@ -4,7 +4,7 @@ namespace Overblog\GraphQLBundle\Annotation; -use \Attribute; +use Attribute; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** @@ -14,27 +14,6 @@ * @Target({"ANNOTATION", "CLASS"}) */ #[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] -final class FieldsBuilder implements NamedArgumentConstructorAnnotation, Annotation +final class FieldsBuilder extends Builder implements NamedArgumentConstructorAnnotation { - /** - * Builder name. - * - * @Required - * - * @var string - */ - public string $builder; - - /** - * The builder config. - * - * @var mixed - */ - public $builderConfig = []; - - public function __construct(string $builder, array $builderConfig = []) - { - $this->builder = $builder; - $this->builderConfig = $builderConfig; - } } diff --git a/src/Annotation/Input.php b/src/Annotation/Input.php index 8ba6fd722..7e1fc9d17 100644 --- a/src/Annotation/Input.php +++ b/src/Annotation/Input.php @@ -5,6 +5,7 @@ namespace Overblog\GraphQLBundle\Annotation; use \Attribute; +use Doctrine\Common\Annotations\AnnotationException; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** @@ -14,7 +15,7 @@ * @Target("CLASS") */ #[Attribute(Attribute::TARGET_CLASS)] -final class Input implements NamedArgumentConstructorAnnotation, Annotation +final class Input extends Annotation implements NamedArgumentConstructorAnnotation { /** * Type name. @@ -30,9 +31,12 @@ final class Input implements NamedArgumentConstructorAnnotation, Annotation */ public bool $isRelay = false; - public function __construct(?string $name = null, bool $isRelay = false) + public function __construct(?string $name = null, bool $isRelay = false, ?string $value = null) { - $this->name = $name; + if ($name && $value) { + $this->cumulatedAttributesException('name', $value, $name); + } + $this->name = $value ?: $name; $this->isRelay = $isRelay; } } diff --git a/src/Annotation/IsPublic.php b/src/Annotation/IsPublic.php index 8a1a4d308..cc84e9579 100644 --- a/src/Annotation/IsPublic.php +++ b/src/Annotation/IsPublic.php @@ -14,7 +14,7 @@ * @Target({"CLASS", "METHOD", "PROPERTY"}) */ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::TARGET_PROPERTY)] -final class IsPublic implements NamedArgumentConstructorAnnotation, Annotation +final class IsPublic extends Annotation implements NamedArgumentConstructorAnnotation { /** * Field publicity. diff --git a/src/Annotation/Mutation.php b/src/Annotation/Mutation.php index 2af678d27..f732b8165 100644 --- a/src/Annotation/Mutation.php +++ b/src/Annotation/Mutation.php @@ -14,7 +14,7 @@ * @Target({"METHOD"}) */ #[Attribute(Attribute::TARGET_METHOD)] -final class Mutation extends Field implements NamedArgumentConstructorAnnotation +final class Mutation extends Field { /** * The target types to attach this mutation to (useful when multiple schemas are allowed). @@ -36,9 +36,10 @@ public function __construct( $fieldBuilder = null, ?string $complexity = null, $targetTypes = null, - $targetType = null + $targetType = null, + ?string $value = null ) { - parent::__construct($name, $type, $args, $resolve, $argsBuilder, $fieldBuilder, $complexity); + parent::__construct($name, $type, $args, $resolve, $argsBuilder, $fieldBuilder, $complexity, $value); if ($targetTypes) { $this->targetTypes = is_string($targetTypes) ? [$targetTypes] : $targetTypes; } elseif ($targetType) { diff --git a/src/Annotation/Provider.php b/src/Annotation/Provider.php index ecd1436d6..e7b735ae4 100644 --- a/src/Annotation/Provider.php +++ b/src/Annotation/Provider.php @@ -14,7 +14,7 @@ * @Target({"CLASS"}) */ #[Attribute(Attribute::TARGET_CLASS)] -final class Provider implements NamedArgumentConstructorAnnotation, Annotation +final class Provider extends Annotation implements NamedArgumentConstructorAnnotation { /** * Optionnal prefix for provider fields. diff --git a/src/Annotation/Query.php b/src/Annotation/Query.php index 3edbb5259..d10d49048 100644 --- a/src/Annotation/Query.php +++ b/src/Annotation/Query.php @@ -14,7 +14,7 @@ * @Target({"METHOD"}) */ #[Attribute(Attribute::TARGET_METHOD)] -final class Query extends Field implements NamedArgumentConstructorAnnotation +final class Query extends Field { /** * The target types to attach this query to. @@ -36,9 +36,10 @@ public function __construct( $fieldBuilder = null, ?string $complexity = null, $targetTypes = null, - $targetType = null + $targetType = null, + ?string $value = null ) { - parent::__construct($name, $type, $args, $resolve, $argsBuilder, $fieldBuilder, $complexity); + parent::__construct($name, $type, $args, $resolve, $argsBuilder, $fieldBuilder, $complexity, $value); if ($targetTypes) { $this->targetTypes = is_string($targetTypes) ? [$targetTypes] : $targetTypes; } elseif ($targetType) { diff --git a/src/Annotation/Scalar.php b/src/Annotation/Scalar.php index edc59c622..1b819bc5d 100644 --- a/src/Annotation/Scalar.php +++ b/src/Annotation/Scalar.php @@ -5,6 +5,7 @@ namespace Overblog\GraphQLBundle\Annotation; use \Attribute; +use Doctrine\Common\Annotations\AnnotationException; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** @@ -14,7 +15,7 @@ * @Target("CLASS") */ #[Attribute(Attribute::TARGET_CLASS)] -final class Scalar implements NamedArgumentConstructorAnnotation, Annotation +final class Scalar extends Annotation implements NamedArgumentConstructorAnnotation { /** * @var string @@ -26,9 +27,12 @@ final class Scalar implements NamedArgumentConstructorAnnotation, Annotation */ public ?string $scalarType; - public function __construct(?string $name = null, ?string $scalarType = null) + public function __construct(?string $name = null, ?string $scalarType = null, ?string $value = null) { - $this->name = $name; + if ($name && $value) { + $this->cumulatedAttributesException('name', $value, $name); + } + $this->name = $value ?: $name; $this->scalarType = $scalarType; } } diff --git a/src/Annotation/Type.php b/src/Annotation/Type.php index 01003382b..68dcc4b84 100644 --- a/src/Annotation/Type.php +++ b/src/Annotation/Type.php @@ -5,6 +5,7 @@ namespace Overblog\GraphQLBundle\Annotation; use \Attribute; +use Doctrine\Common\Annotations\AnnotationException; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** @@ -14,7 +15,7 @@ * @Target("CLASS") */ #[Attribute(Attribute::TARGET_CLASS)] -class Type implements NamedArgumentConstructorAnnotation, Annotation +class Type extends Annotation implements NamedArgumentConstructorAnnotation { /** * Type name. @@ -66,9 +67,13 @@ public function __construct( bool $isRelay = false, ?string $resolveField = null, array $builders = [], - ?string $isTypeOf = null + ?string $isTypeOf = null, + ?string $value = null ) { - $this->name = $name; + if ($name && $value) { + $this->cumulatedAttributesException('name', $value, $name); + } + $this->name = $value ?: $name; $this->interfaces = $interfaces; $this->isRelay = $isRelay; $this->resolveField = $resolveField; diff --git a/src/Annotation/TypeInterface.php b/src/Annotation/TypeInterface.php index f6ef76456..4570c507e 100644 --- a/src/Annotation/TypeInterface.php +++ b/src/Annotation/TypeInterface.php @@ -5,6 +5,7 @@ namespace Overblog\GraphQLBundle\Annotation; use \Attribute; +use Doctrine\Common\Annotations\AnnotationException; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** @@ -14,7 +15,7 @@ * @Target("CLASS") */ #[Attribute(Attribute::TARGET_CLASS)] -final class TypeInterface implements NamedArgumentConstructorAnnotation, Annotation +final class TypeInterface extends Annotation implements NamedArgumentConstructorAnnotation { /** * Interface name. @@ -32,9 +33,12 @@ final class TypeInterface implements NamedArgumentConstructorAnnotation, Annotat */ public string $resolveType; - public function __construct(?string $name = null, string $resolveType) + public function __construct(?string $name = null, string $resolveType, ?string $value = null) { - $this->name = $name; + if ($name && $value) { + $this->cumulatedAttributesException('name', $value, $name); + } + $this->name = $value ?: $name; $this->resolveType = $resolveType; } } diff --git a/src/Annotation/Union.php b/src/Annotation/Union.php index 5bd5c38f3..d5cd80a47 100644 --- a/src/Annotation/Union.php +++ b/src/Annotation/Union.php @@ -5,6 +5,7 @@ namespace Overblog\GraphQLBundle\Annotation; use \Attribute; +use Doctrine\Common\Annotations\AnnotationException; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** @@ -14,7 +15,7 @@ * @Target("CLASS") */ #[Attribute(Attribute::TARGET_CLASS)] -final class Union implements NamedArgumentConstructorAnnotation, Annotation +final class Union extends Annotation implements NamedArgumentConstructorAnnotation { /** * Union name. @@ -37,9 +38,12 @@ final class Union implements NamedArgumentConstructorAnnotation, Annotation */ public ?string $resolveType; - public function __construct(?string $name = null, array $types = [], ?string $resolveType = null) + public function __construct(?string $name = null, array $types = [], ?string $resolveType = null, ?string $value = null) { - $this->name = $name; + if ($name && $value) { + $this->cumulatedAttributesException('name', $value, $name); + } + $this->name = $value ?: $name; $this->types = $types; $this->resolveType = $resolveType; } diff --git a/src/Config/Parser/AttributeParser.php b/src/Config/Parser/AttributeParser.php index ce6d32c50..98d4cf6c7 100644 --- a/src/Config/Parser/AttributeParser.php +++ b/src/Config/Parser/AttributeParser.php @@ -26,7 +26,7 @@ public static function getMetadatas(Reflector $reflector): array $attributes = $reflector->getAttributes(); } - // @phpstan-ignore-line + // @phpstan-ignore-next-line return array_map(fn (ReflectionAttribute $attribute) => $attribute->newInstance(), $attributes); } } diff --git a/src/Config/Parser/MetadataParser/ClassesTypesMap.php b/src/Config/Parser/MetadataParser/ClassesTypesMap.php index a54096e02..1249914dc 100644 --- a/src/Config/Parser/MetadataParser/ClassesTypesMap.php +++ b/src/Config/Parser/MetadataParser/ClassesTypesMap.php @@ -47,7 +47,7 @@ public function resolveType(string $className, array $filteredTypes = []): ?stri */ public function resolveClass(string $typeName): ?string { - return isset($this->classesMap[$typeName]) ? $this->classesMap[$typeName]['class'] : null; + return $this->classesMap[$typeName]['class'] ?? null; } /** diff --git a/src/Config/Parser/MetadataParser/MetadataParser.php b/src/Config/Parser/MetadataParser/MetadataParser.php index d24e281bc..0c3acf9c9 100644 --- a/src/Config/Parser/MetadataParser/MetadataParser.php +++ b/src/Config/Parser/MetadataParser/MetadataParser.php @@ -115,6 +115,7 @@ private static function processFile(SplFileInfo $file, ContainerBuilder $contain } $gqlTypes = []; + /** @phpstan-ignore-next-line */ $reflectionClass = self::getClassReflection($className); foreach (static::getMetadatas($reflectionClass) as $classMetadata) { @@ -246,6 +247,7 @@ private static function classMetadatasToGQLConfiguration( /** * @throws ReflectionException + * @phpstan-param class-string $className */ private static function getClassReflection(string $className): ReflectionClass { @@ -338,7 +340,7 @@ private static function graphQLTypeConfigFromAnnotation(ReflectionClass $reflect $buildersAnnotations = array_merge(self::getMetadataMatching($metadatas, Metadata\FieldsBuilder::class), $typeAnnotation->builders); if (!empty($buildersAnnotations)) { $typeConfiguration['builders'] = array_map(function ($fieldsBuilderAnnotation) { - return ['builder' => $fieldsBuilderAnnotation->builder, 'builderConfig' => $fieldsBuilderAnnotation->builderConfig]; + return ['builder' => $fieldsBuilderAnnotation->value, 'builderConfig' => $fieldsBuilderAnnotation->config]; }, $buildersAnnotations); } @@ -524,6 +526,7 @@ private static function getTypeFieldConfigurationFromReflector(ReflectionClass $ $args = []; + /** @var Metadata\Arg[] $argAnnotations */ $argAnnotations = array_merge(self::getMetadataMatching($metadatas, Metadata\Arg::class), $fieldMetadata->args); foreach ($argAnnotations as $arg) { @@ -718,7 +721,7 @@ private static function getGraphQLFieldsFromProviders(ReflectionClass $reflectio } // TODO: Remove old property check in 1.1 - $metadataTargets = $metadata->targetTypes ?? $metadata->targetType ?? null; + $metadataTargets = $metadata->targetTypes ?? null; if (null === $metadataTargets) { if ($metadata instanceof Metadata\Mutation && isset($providerMetadata->targetMutationTypes)) { @@ -926,7 +929,10 @@ private static function guessArgs(ReflectionClass $reflectionClass, ReflectionMe return $arguments; } - private static function getClassProperties(ReflectionClass $reflectionClass) + /** + * @return ReflectionProperty[] + */ + private static function getClassProperties(ReflectionClass $reflectionClass): array { $properties = []; do { diff --git a/src/Config/Parser/MetadataParser/TypeGuesser/DocBlockTypeGuesser.php b/src/Config/Parser/MetadataParser/TypeGuesser/DocBlockTypeGuesser.php index 268b3d27d..21b49f3c4 100644 --- a/src/Config/Parser/MetadataParser/TypeGuesser/DocBlockTypeGuesser.php +++ b/src/Config/Parser/MetadataParser/TypeGuesser/DocBlockTypeGuesser.php @@ -94,7 +94,7 @@ public function guessType(ReflectionClass $reflectionClass, Reflector $reflector if ($type instanceof Object_) { $className = $type->getFqsen(); if (!$className) { - throw new TypeGuessingException(sprintf('%s, but type "object" is too generic.', $exceptionPrefix, $className)); + throw new TypeGuessingException(sprintf('%s, but type "object" is too generic.', $exceptionPrefix)); } // Remove first '\' from returned class name $className = substr((string) $className, 1); @@ -115,12 +115,16 @@ public function guessType(ReflectionClass $reflectionClass, Reflector $reflector protected function resolveCompound(Compound $compound): ?Type { $typeNull = new Null_(); - if ($compound->getIterator()->count() > 2 || !$compound->contains($typeNull)) { + if (2 !== $compound->getIterator()->count() || !$compound->contains($typeNull)) { return null; } - $type = current(array_filter(iterator_to_array($compound->getIterator(), false), fn (Type $type) => (string) $type !== (string) $typeNull)); + foreach ($compound as $type) { + if (!$type instanceof Null_) { + return $type; + } + } - return $type; + return null; } private function getParser(): DocBlockFactory diff --git a/tests/Config/Parser/MetadataParserTest.php b/tests/Config/Parser/MetadataParserTest.php index b25c95f5c..6d0682069 100644 --- a/tests/Config/Parser/MetadataParserTest.php +++ b/tests/Config/Parser/MetadataParserTest.php @@ -204,8 +204,22 @@ public function testTypes(): void 'name' => ['type' => 'String!', 'description' => 'The name of the animal'], 'lives' => ['type' => 'Int!'], 'toys' => ['type' => '[String!]!'], + 'shortcut' => ['type' => 'String', 'resolve' => '@=value.field'], ], ]); + + // Test type with shortcut annotation + $this->expect('Doggy', 'object', [ + 'fields' => [ + 'toys' => ['type' => '[String!]!'], + 'catFights' => [ + 'type' => 'Int!', + 'resolve' => '@=call(value.getCountCatFights, arguments({}, args))', + 'argsBuilder' => ['builder' => 'MyArgsBuilder'], + ], + ], + 'builders' => [['builder' => 'MyFieldsBuilder']], + ]); } public function testInput(): void @@ -221,6 +235,20 @@ public function testInput(): void 'tags' => ['type' => '[String]!'], ], ]); + + $this->expect('StarPlanet', 'input-object', [ + 'fields' => [ + 'distance' => ['type' => 'Int!'], + ], + ]); + } + + public function testInterfaces(): void + { + $this->expect('WithArmor', 'interface', [ + 'description' => 'The armored interface', + 'resolveType' => '@=resolver(\'character_type\', [value])', + ]); } public function testEnum(): void @@ -234,11 +262,18 @@ public function testEnum(): void 'TWILEK' => ['value' => '4'], ], ]); + + $this->expect('Pets', 'enum', [ + 'values' => [ + 'DOGS' => ['value' => 'dog'], + 'CATS' => ['value' => 'cat'], + ], + ]); } public function testUnion(): void { - $this->expect('SearchResult', 'union', [ + $this->expect('ResultSearch', 'union', [ 'description' => 'A search result', 'types' => ['Hero', 'Droid', 'Sith'], 'resolveType' => '@=value.getType()', @@ -261,7 +296,7 @@ public function testUnionAutoguessed(): void public function testInterfaceAutoguessed(): void { $this->expect('Mandalorian', 'object', [ - 'interfaces' => ['Armored', 'Character'], + 'interfaces' => ['Character', 'WithArmor'], 'fields' => [ 'name' => ['type' => 'String!', 'description' => 'The name of the character'], 'friends' => ['type' => '[Character]', 'description' => 'The friends of the character', 'resolve' => "@=resolver('App\\MyResolver::getFriends')"], @@ -283,6 +318,10 @@ public function testScalar(): void 'parseLiteral' => ['Overblog\GraphQLBundle\Tests\Config\Parser\fixtures\\annotations\Scalar\GalaxyCoordinates', 'parseLiteral'], 'description' => 'The galaxy coordinates scalar', ]); + + $this->expect('ShortScalar', 'custom-scalar', [ + 'scalarType' => '@=type', + ]); } public function testProviders(): void diff --git a/tests/Config/Parser/fixtures/annotations/Deprecated/Deprecated.php b/tests/Config/Parser/fixtures/annotations/Deprecated/Deprecated.php index 82c4eb3b3..18e444e71 100644 --- a/tests/Config/Parser/fixtures/annotations/Deprecated/Deprecated.php +++ b/tests/Config/Parser/fixtures/annotations/Deprecated/Deprecated.php @@ -7,7 +7,7 @@ use Overblog\GraphQLBundle\Annotation as GQL; /** - * @GQL\Type(builders={@GQL\FieldsBuilder(builder="MyFieldsBuilder", builderConfig={"param1": "val1"})}) + * @GQL\Type(builders={@GQL\FieldsBuilder(value="MyFieldsBuilder", config={"param1": "val1"})}) */ class Deprecated { diff --git a/tests/Config/Parser/fixtures/annotations/Enum/Pet.php b/tests/Config/Parser/fixtures/annotations/Enum/Pet.php new file mode 100644 index 000000000..f701818ee --- /dev/null +++ b/tests/Config/Parser/fixtures/annotations/Enum/Pet.php @@ -0,0 +1,34 @@ +value = $value; + } +} diff --git a/tests/Config/Parser/fixtures/annotations/Enum/Race.php b/tests/Config/Parser/fixtures/annotations/Enum/Race.php index a4f8958c0..4a83de04d 100644 --- a/tests/Config/Parser/fixtures/annotations/Enum/Race.php +++ b/tests/Config/Parser/fixtures/annotations/Enum/Race.php @@ -9,12 +9,12 @@ /** * @GQL\Enum - * @GQL\EnumValue(name="CHISS", description="The Chiss race") + * @GQL\EnumValue("CHISS", description="The Chiss race") * @GQL\EnumValue(name="ZABRAK", deprecationReason="The Zabraks have been wiped out") * @GQL\Description("The list of races!") */ #[GQL\Enum] -#[GQL\EnumValue(name: "CHISS", description: "The Chiss race")] +#[GQL\EnumValue("CHISS", description: "The Chiss race")] #[GQL\EnumValue(name: "ZABRAK", deprecationReason: "The Zabraks have been wiped out")] #[GQL\Description("The list of races!")] class Race diff --git a/tests/Config/Parser/fixtures/annotations/Input/Star.php b/tests/Config/Parser/fixtures/annotations/Input/Star.php new file mode 100644 index 000000000..4cf61c442 --- /dev/null +++ b/tests/Config/Parser/fixtures/annotations/Input/Star.php @@ -0,0 +1,20 @@ + "val1"])] +#[GQL\FieldsBuilder(value: "MyFieldsBuilder", config: ["param1" => "val1"])] class Crystal { /** diff --git a/tests/Config/Parser/fixtures/annotations/Type/Dog.php b/tests/Config/Parser/fixtures/annotations/Type/Dog.php new file mode 100644 index 000000000..22aea2b86 --- /dev/null +++ b/tests/Config/Parser/fixtures/annotations/Type/Dog.php @@ -0,0 +1,35 @@ + Date: Fri, 8 Jan 2021 14:43:40 +0100 Subject: [PATCH 05/23] Fix type hint --- src/Annotation/Builder.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Annotation/Builder.php b/src/Annotation/Builder.php index 8f28b2ca2..f6b156e14 100644 --- a/src/Annotation/Builder.php +++ b/src/Annotation/Builder.php @@ -28,9 +28,9 @@ abstract class Builder extends Annotation implements NamedArgumentConstructorAnn /** * The builder config. * - * @var mixed + * @var array */ - public $config = []; + public array $config = []; public function __construct(string $value, array $config = []) { From 7fc2d4f8fe54daa59fe54ba95b845d70cab69536 Mon Sep 17 00:00:00 2001 From: Vincent Date: Sun, 10 Jan 2021 11:44:58 +0100 Subject: [PATCH 06/23] Update documentation --- README.md | 2 +- UPGRADE-1.0.md | 74 ++++++++- docs/annotations/annotations-reference.md | 189 ++++++++++++++++------ docs/annotations/index.md | 80 ++++++++- 4 files changed, 285 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index a9e951b5a..dce4541f1 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Documentation - [Data fetching](docs/data-fetching/index.md) - [Query batching](docs/data-fetching/batching.md) - [Promise](docs/data-fetching/promise.md) -- [Annotations](docs/annotations/index.md) +- [Annotations & PHP 8 Attributes](docs/annotations/index.md) - [Validation](docs/validation/index.md) - [Security](docs/security/index.md) - [Handle CORS](docs/security/handle-cors.md) diff --git a/UPGRADE-1.0.md b/UPGRADE-1.0.md index 8700fd2cb..43873cb95 100644 --- a/UPGRADE-1.0.md +++ b/UPGRADE-1.0.md @@ -3,9 +3,13 @@ UPGRADE FROM 0.13 to 1.0 # Table of Contents -- [Customize the cursor encoder of the edges of a connection](#customize-the-cursor-encoder-of-the-edges-of-a-connection) -- [Change arguments of `TypeGenerator`](#change-arguments-of-typegenerator) -- [Add magic `__get` method to `ArgumentInterface` implementors](#add-magic-__get-method-to-argumentinterface-implementors) +- [UPGRADE FROM 0.13 to 1.0](#upgrade-from-013-to-10) +- [Table of Contents](#table-of-contents) + - [Customize the cursor encoder of the edges of a connection](#customize-the-cursor-encoder-of-the-edges-of-a-connection) + - [Change arguments of `TypeGenerator` class](#change-arguments-of-typegenerator-class) + - [Add magic `__get` method to `ArgumentInterface` implementors](#add-magic-__get-method-to-argumentinterface-implementors) + - [Annotations - Flattened annotations](#annotations---flattened-annotations) + - [Annotations - Attributes changed](#annotations---attributes-changed) ### Customize the cursor encoder of the edges of a connection @@ -86,3 +90,67 @@ class Argument implements ArgumentInterface } ``` If you use your own class for resolver arguments, then it should have a `__get` method as well. + + +### Annotations - Flattened annotations + +In order to prepare to PHP 8 attributes (they don't support nested attributes at the moment. @see https://github.com/symfony/symfony/issues/38503), the following annotations have been flattened: `@FieldsBuilder`, `@FieldBuilder`, `@ArgsBuilder`, `@Arg` and `@EnumValue`. + +Before: +```php +/** + * @GQL\Type + */ +class MyType { + /** + * @GQL\Field(args={ + * @GQL\Arg(name="arg1", type="String"), + * @GQL\Arg(name="arg2", type="Int") + * }) + */ + public function myFields(?string $arg1, ?int $arg2) {..} +} + +``` + +After: +```php +/** + * @GQL\Type + */ +class MyType { + /** + * @GQL\Field + * @GQL\Arg(name="arg1", type="String"), + * @GQL\Arg(name="arg2", type="Int") + */ + public function myFields(?string $arg1, ?int $arg2) {..} +} + +``` + +### Annotations - Attributes changed + +Change the attributes name of `@FieldsBuilder` annotation from `builder` and `builderConfig` to `value` and `config`. + +Before: +```php +/** + * @GQL\Type(name="MyType", builders={@GQL\FieldsBuilder(builder="Timestamped", builderConfig={opt1: "val1"})}) + */ +class MyType { + +} +``` + +After: +```php +/** + * @GQL\Type("MyType") + * @GQL\FieldsBuilder(value="Timestamped", config={opt1: "val1"}) + */ +class MyType { + +} +``` + diff --git a/docs/annotations/annotations-reference.md b/docs/annotations/annotations-reference.md index 8dabff70b..46d5c4e22 100644 --- a/docs/annotations/annotations-reference.md +++ b/docs/annotations/annotations-reference.md @@ -38,12 +38,27 @@ class Coordinates { } ``` +## Annotations shortcuts + +When using annotations or attributes, you can use a shortened version of the syntax. +Example without: +`@GQL\Field(name="MyField")` + +Exampel with shortcut: +`@GQL\Field("MyField")` + +When using the shortened version, it is the attribute describe as `default attribute` in the following list that will be set if attribute name is not specified. + + + ## Index [@Access](#access) [@Arg](#arg) +[@ArgsBuilder](#argsBuilder) + [@Deprecated](#deprecated) [@Description](#description) @@ -54,6 +69,8 @@ class Coordinates { [@Field](#field) +[@FieldBuilder](#fieldbuilder) + [@FieldsBuilder](#fieldsbuilder) [@Input](#input) @@ -110,11 +127,11 @@ class Hero { ## @Arg -This annotation is used in the `args` attribute of a `@Field` or `@Query` or `@Mutation` to define an argument. +This annotation is used in conjonction with a `@Field`, a `@Query` or `@Mutation` to define an argument. Required attributes: -- **name** : The GraphQL name of the field argument (default to class name) +- **name** (default attribute): The GraphQL name of the field argument (default to class name). - **type** : The GraphQL type of the field argument Optional attributes: @@ -131,23 +148,52 @@ Example: */ class Hero { /** - * @GQL\Field(fieldBuilder={"GenericIdBuilder", {"name": "heroId"}}) + * @GQL\Field + * @GQL\FieldBuilder("GenericIdBuilder", config={"name": "heroId"}) */ public $id; /** - * @GQL\Field(type="[Hero]", - * args={ - * @GQL\Arg(name="droidsOnly", type="Boolean", description="Retrieve only droids heroes"), - * @GQL\Arg(name="nameStartsWith", type="String", description="Retrieve only heroes with name starting with") - * }, - * resolve="resolver('hero_friends', [args['droidsOnly'], args['nameStartsWith']])" - * ) + * @GQL\Field(type="[Hero]", resolve="resolver('hero_friends', [args['droidsOnly'], args['nameStartsWith']])") + * @GQL\Arg(name="droidsOnly", type="Boolean", description="Retrieve only droids heroes"), + * @GQL\Arg(name="nameStartsWith", type="String", description="Retrieve only heroes with name starting with") */ public $friends; } ``` +## @ArgsBuilder + +This annotation is used in conjonction with a `@Field`, a `@Query` or `@Mutation` to generate the field arguments. +It is used to set a arguments builder for a field (see [Args builders](../definitions/builders/args.md))) + +Required attributes: + +- **value** (default attribute): The name of the args builder + +Optional attributes: + +- **config** : The configuration to pass to the args builder + +Example: + +```php +getFriends(); + } +} +``` + ## @Deprecated This annotation is used in conjunction with `@Field` to mark it as deprecated with the specified reason. @@ -203,7 +249,9 @@ In order to add more meta on the values (like description or deprecated reason), Optional attributes: -- **name** : The GraphQL name of the enum (default to the class name without namespace) +- **name** (default attribute): The GraphQL name of the enum (default to the class name without namespace) + +Deprecated attributes: - **values** : An array of `@EnumValue`to define description or deprecated reason of enum values The class will also be used by the `Arguments Transformer` service when an `Enum` is encoutered in a Mutation or Query Input. A property accessor will try to populate a property name `value`. @@ -214,10 +262,9 @@ Example: ' - **type** : The GraphqL type of the field. This attribute can sometimes be guessed automatically from Doctrine ORM annotations -- **name** : The GraphQL name of the field (default to the property name). If you don't specify a `resolve` attribute while changing the `name`, the default one will be '@=value.' -- **args** : An array of `@Arg` - **resolve** : A resolution expression + +Deprecated attributes (use flat annotations instead): + +- **args** : An array of `@Arg` - **fieldBuilder** : A field builder to use. Either as string (will be the field builder name), or as an array, first index will the name of the builder and second one will be the config. - **argsBuilder** : An args builder to use. Either as string (will be the args builder name), or as an array, first index will the name of the builder and second one will be the config. @@ -278,16 +331,17 @@ Example on properties: */ class Hero { /** - * @GQL\Field(fieldBuilder={"GenericIdBuilder", {"name": "heroId"}}) + * @GQL\Field + * @GQL\FieldBuilder("GenericIdBuilder", config={"name": "heroId"}) */ public $id; /** * @GQL\Field( * type="[Hero]", - * argsBuilder="Pager" * resolve="resolver('hero_friends', [value, args['page']])" * ) + * @GQL\ArgsBuilder("Pager") */ public $friends; } @@ -303,11 +357,8 @@ Example on methods: */ class Hero { /** - * @GQL\Field( - * name="friends", - * type="[Hero]", - * args={@GQL\Arg(name="limit", type="Int")} - * ) + * @GQL\Field(name="friends", type="[Hero]") + * @GQL\Arg("limit", type="Int") */ public function getFriends(int $limit) { return array_slice($this->friends, 0, $limit); @@ -315,18 +366,49 @@ class Hero { } ``` +## @FieldBuilder + +This annotation is used with `@Field`, `@Query` or `@Mutation` to use a builder to generate the field. +It is used to set a field builder for a field (see [Field builders](../definitions/builders/field.md))) + +Required attributes: + +- **value** (default attribute): The name of the field builder + +Optional attributes: + +- **config** : The configuration to pass to the field builder + +Example: + +```php +repository->find($id); @@ -481,17 +563,21 @@ This annotation is used on _class_ to define a GraphQL Type. Optional attributes: -- **name** : The GraphQL name of the type (default to the class name without namespace) +- **name** (default attribute) : The GraphQL name of the type (default to the class name without namespace) - **interfaces** : An array of GraphQL interface this type inherits from (can be auto-guessed. See interface documentation). - **isRelay** : Set to true to have a Relay compatible type (ie. A `clientMutationId` will be added). -- **builders** : An array of `@FieldsBuilder` annotations - **isTypeOf** : Is type of resolver for interface implementation +Deprecated attributes: + +- **builders** : An array of `@FieldsBuilder` annotations + ```php Date: Tue, 12 Jan 2021 18:14:29 +0100 Subject: [PATCH 07/23] Add PHP 8.0 to Ci --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48e04715c..159e66934 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,9 @@ jobs: - php-version: 7.4 symfony-version: "5.2.*" + + - php-version: 8.0 + symfony-version: "5.2.*" name: "PHP ${{ matrix.php-version }} / Symfony ${{ matrix.symfony-version }} Test" From 2303d6564b3985cf734bb1a5e628bbf15026dfa4 Mon Sep 17 00:00:00 2001 From: Vincent Date: Tue, 12 Jan 2021 22:39:33 +0100 Subject: [PATCH 08/23] Update php-code-generator dependency --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index a7cc3ac3c..1446d4f7d 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "require": { "php": ">=7.4", "ext-json": "*", - "murtukov/php-code-generator": "^0.1.4", + "murtukov/php-code-generator": "^0.1.5", "phpdocumentor/reflection-docblock": "^5.2", "psr/log": "^1.0", "symfony/config": "^4.4 || ^5.0", From 6e1e14668ec38e6c1f9254bef349275dfeebefef Mon Sep 17 00:00:00 2001 From: Timur Murtukov Date: Tue, 12 Jan 2021 23:15:13 +0100 Subject: [PATCH 09/23] Update AccessTest.php Fix test --- tests/Functional/Security/AccessTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Functional/Security/AccessTest.php b/tests/Functional/Security/AccessTest.php index 1dfcbefaf..58e00a4a9 100644 --- a/tests/Functional/Security/AccessTest.php +++ b/tests/Functional/Security/AccessTest.php @@ -67,7 +67,7 @@ public function setUp(): void public function testCustomClassLoaderNotRegister(): void { $this->expectException(Error::class); - $this->expectExceptionMessage('Class \'Overblog\GraphQLBundle\Access\__DEFINITIONS__\RootQueryType\' not found'); + $this->expectExceptionMessage('Class "Overblog\GraphQLBundle\Access\__DEFINITIONS__\RootQueryType" not found'); spl_autoload_unregister($this->loader); $this->assertResponse($this->userNameQuery, [], static::ANONYMOUS_USER, 'access'); } From 6444177966233bb4752797ba928f27d3897d570c Mon Sep 17 00:00:00 2001 From: Vincent Date: Tue, 12 Jan 2021 23:21:50 +0100 Subject: [PATCH 10/23] Fix tests & static analysis --- src/Generator/TypeBuilder.php | 3 +-- tests/Functional/Security/AccessTest.php | 6 +++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Generator/TypeBuilder.php b/src/Generator/TypeBuilder.php index d4ccf8fa7..86039452c 100644 --- a/src/Generator/TypeBuilder.php +++ b/src/Generator/TypeBuilder.php @@ -16,7 +16,6 @@ use Murtukov\PHPCodeGenerator\Closure; use Murtukov\PHPCodeGenerator\Config; use Murtukov\PHPCodeGenerator\ConverterInterface; -use Murtukov\PHPCodeGenerator\DependencyAwareGenerator; use Murtukov\PHPCodeGenerator\Exception\UnrecognizedValueTypeException; use Murtukov\PHPCodeGenerator\GeneratorInterface; use Murtukov\PHPCodeGenerator\Instance; @@ -177,7 +176,7 @@ protected function buildType(string $typeDefinition) * * @param mixed $typeNode * - * @return DependencyAwareGenerator|string + * @return Literal|string */ protected function wrapTypeRecursive($typeNode, bool &$isReference) { diff --git a/tests/Functional/Security/AccessTest.php b/tests/Functional/Security/AccessTest.php index 58e00a4a9..8a9adb121 100644 --- a/tests/Functional/Security/AccessTest.php +++ b/tests/Functional/Security/AccessTest.php @@ -67,7 +67,11 @@ public function setUp(): void public function testCustomClassLoaderNotRegister(): void { $this->expectException(Error::class); - $this->expectExceptionMessage('Class "Overblog\GraphQLBundle\Access\__DEFINITIONS__\RootQueryType" not found'); + if ((int) phpversion() <= 7) { + $this->expectExceptionMessage('Class \'Overblog\GraphQLBundle\Access\__DEFINITIONS__\RootQueryType\' not found'); + } else { + $this->expectExceptionMessage('Class "Overblog\GraphQLBundle\Access\__DEFINITIONS__\RootQueryType" not found'); + } spl_autoload_unregister($this->loader); $this->assertResponse($this->userNameQuery, [], static::ANONYMOUS_USER, 'access'); } From c7b8cab8e316d74dcc72ba2839862ad2589d9187 Mon Sep 17 00:00:00 2001 From: Vincent Date: Wed, 13 Jan 2021 10:45:02 +0100 Subject: [PATCH 11/23] Fix doc & code quality --- docs/annotations/annotations-reference.md | 12 +++--- docs/annotations/index.md | 15 ++++---- src/Annotation/Description.php | 2 +- .../Parser/MetadataParser/ClassesTypesMap.php | 3 ++ .../Parser/MetadataParser/MetadataParser.php | 38 ++++++++++++++++--- 5 files changed, 50 insertions(+), 20 deletions(-) diff --git a/docs/annotations/annotations-reference.md b/docs/annotations/annotations-reference.md index 46d5c4e22..c87abc587 100644 --- a/docs/annotations/annotations-reference.md +++ b/docs/annotations/annotations-reference.md @@ -44,11 +44,11 @@ When using annotations or attributes, you can use a shortened version of the syn Example without: `@GQL\Field(name="MyField")` -Exampel with shortcut: +Example with shortcut: `@GQL\Field("MyField")` -When using the shortened version, it is the attribute describe as `default attribute` in the following list that will be set if attribute name is not specified. - +When the short version is used (ie. the attribute name is not specified), the attribute describe as `default attribute` in the annotation's attributes list will be use. +In the above example, the attribute `name` on `@GQL\Field` is the default attribute. ## Index @@ -127,7 +127,7 @@ class Hero { ## @Arg -This annotation is used in conjonction with a `@Field`, a `@Query` or `@Mutation` to define an argument. +This annotation is used in conjunction with a `@Field`, a `@Query` or `@Mutation` to define an argument. Required attributes: @@ -164,8 +164,8 @@ class Hero { ## @ArgsBuilder -This annotation is used in conjonction with a `@Field`, a `@Query` or `@Mutation` to generate the field arguments. -It is used to set a arguments builder for a field (see [Args builders](../definitions/builders/args.md))) +This annotation is used in conjunction with a `@Field`, a `@Query` or `@Mutation` to generate the field arguments. +It is used to set an arguments builder for a field (see [Args builders](../definitions/builders/args.md))) Required attributes: diff --git a/docs/annotations/index.md b/docs/annotations/index.md index 94a7299ad..44cd83e27 100644 --- a/docs/annotations/index.md +++ b/docs/annotations/index.md @@ -162,16 +162,17 @@ In the previous example, the generated `resolve` config of the `something` field ## Type & Args auto-guessing -When no explicit `type` is defined on a `@Field` (`@Query`, or `@Mutation`), the bundle will try to guess it using the following methods: -- **Doc Block** The guess will be made from `@var` or `@return` in Doc block -- **Type hint** The guess will be made from type hint -- **Doctrine annotations** The guess will be made from doctrine annotations +If the `type` option is not defined explicitly on the `@Field`, `@Query` or `@Mutation`, the bundle will try to guess it from other DocBlock annotations or from the PHP type-hint, in the following order: -The system will try every method, in the above order and return a type as soon as one of the guessing method return one. +1. `@var` and `@return` annotations +2. type-hint +3. Doctrine annotations -### @Field type auto-guessing from Dock Block +It will stop on the first successful guess. -The type of the `@Field` annotation can be auto-guessed if its Dock Block describe a known type. It is the more precise auto-guessing as it supports collection of object type. +### @Field type auto-guessing from DockBlock + +The `type` option of the `@Field` annotation can be guessed if its DocBlock describes a known type. It is a more precise guessing as it supports collections of objects, e.g. `User[]` or `array`. For example: diff --git a/src/Annotation/Description.php b/src/Annotation/Description.php index 54114c923..dbd045148 100644 --- a/src/Annotation/Description.php +++ b/src/Annotation/Description.php @@ -8,7 +8,7 @@ use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** - * Annotation for GraphQL to mark a field as deprecated. + * Annotation for GraphQL to set a type or field description. * * @Annotation * @Target({"CLASS", "METHOD", "PROPERTY"}) diff --git a/src/Config/Parser/MetadataParser/ClassesTypesMap.php b/src/Config/Parser/MetadataParser/ClassesTypesMap.php index 1249914dc..89562d82c 100644 --- a/src/Config/Parser/MetadataParser/ClassesTypesMap.php +++ b/src/Config/Parser/MetadataParser/ClassesTypesMap.php @@ -6,6 +6,9 @@ class ClassesTypesMap { + /** + * @var array + */ protected array $classesMap = []; public function hasType(string $gqlType): bool diff --git a/src/Config/Parser/MetadataParser/MetadataParser.php b/src/Config/Parser/MetadataParser/MetadataParser.php index 0c3acf9c9..140e0cc71 100644 --- a/src/Config/Parser/MetadataParser/MetadataParser.php +++ b/src/Config/Parser/MetadataParser/MetadataParser.php @@ -136,6 +136,9 @@ private static function processFile(SplFileInfo $file, ContainerBuilder $contain } } + /** + * @return array + */ private static function classMetadatasToGQLConfiguration( ReflectionClass $reflectionClass, Meta $classMetadata, @@ -304,6 +307,9 @@ private static function typeMetadataToGQLConfiguration( return $gqlConfiguration; } + /** + * @return array{type: 'relay-mutation-payload'|'object', config: array} + */ private static function graphQLTypeConfigFromAnnotation(ReflectionClass $reflectionClass, Metadata\Type $typeAnnotation, string $currentValue): array { $typeConfiguration = []; @@ -363,6 +369,8 @@ private static function graphQLTypeConfigFromAnnotation(ReflectionClass $reflect /** * Create a GraphQL Interface type configuration from metadatas on properties. + * + * @return array{type: 'interface', config: array} */ private static function typeInterfaceMetadataToGQLConfiguration(ReflectionClass $reflectionClass, Metadata\TypeInterface $interfaceAnnotation): array { @@ -381,6 +389,8 @@ private static function typeInterfaceMetadataToGQLConfiguration(ReflectionClass /** * Create a GraphQL Input type configuration from metadatas on properties. + * + * @return array{type: 'relay-mutation-input'|'input-object', config: array} */ private static function inputMetadataToGQLConfiguration(ReflectionClass $reflectionClass, Metadata\Input $inputAnnotation): array { @@ -393,6 +403,8 @@ private static function inputMetadataToGQLConfiguration(ReflectionClass $reflect /** * Get a GraphQL scalar configuration from given scalar metadata. + * + * @return array{type: 'custom-scalar', config: array} */ private static function scalarMetadataToGQLConfiguration(ReflectionClass $reflectionClass, Metadata\Scalar $scalarAnnotation): array { @@ -415,6 +427,8 @@ private static function scalarMetadataToGQLConfiguration(ReflectionClass $reflec /** * Get a GraphQL Enum configuration from given enum metadata. + * + * @return array{type: 'enum', config: array} */ private static function enumMetadataToGQLConfiguration(ReflectionClass $reflectionClass, Metadata\Enum $enumMetadata): array { @@ -424,16 +438,18 @@ private static function enumMetadataToGQLConfiguration(ReflectionClass $reflecti $values = []; foreach ($reflectionClass->getConstants() as $name => $value) { - $valueAnnotation = current(array_filter($enumValues, fn ($enumValueAnnotation) => $enumValueAnnotation->name === $name)); + $enumValueAnnotation = current(array_filter($enumValues, fn ($enumValueAnnotation) => $enumValueAnnotation->name === $name)); $valueConfig = []; $valueConfig['value'] = $value; - if ($valueAnnotation && isset($valueAnnotation->description)) { - $valueConfig['description'] = $valueAnnotation->description; - } + if (false !== $enumValueAnnotation) { + if (isset($enumValueAnnotation->description)) { + $valueConfig['description'] = $enumValueAnnotation->description; + } - if ($valueAnnotation && isset($valueAnnotation->deprecationReason)) { - $valueConfig['deprecationReason'] = $valueAnnotation->deprecationReason; + if (isset($enumValueAnnotation->deprecationReason)) { + $valueConfig['deprecationReason'] = $enumValueAnnotation->deprecationReason; + } } $values[$name] = $valueConfig; @@ -447,6 +463,8 @@ private static function enumMetadataToGQLConfiguration(ReflectionClass $reflecti /** * Get a GraphQL Union configuration from given union metadata. + * + * @return array{type: 'union', config: array} */ private static function unionMetadataToGQLConfiguration(ReflectionClass $reflectionClass, Metadata\Union $unionMetadata): array { @@ -493,6 +511,8 @@ private static function unionMetadataToGQLConfiguration(ReflectionClass $reflect * @phpstan-param class-string $fieldMetadataName * * @throws AnnotationException + * + * @return array */ private static function getTypeFieldConfigurationFromReflector(ReflectionClass $reflectionClass, Reflector $reflector, string $fieldMetadataName, string $currentValue = 'value'): array { @@ -624,6 +644,8 @@ private static function getTypeFieldConfigurationFromReflector(ReflectionClass $ * @param ReflectionProperty[] $reflectors * * @throws AnnotationException + * + * @return array */ private static function getGraphQLInputFieldsFromMetadatas(ReflectionClass $reflectionClass, array $reflectors): array { @@ -699,6 +721,8 @@ private static function getGraphQLTypeFieldsFromAnnotations(ReflectionClass $ref * * Return fields config from Provider methods. * Loop through configured provider and extract fields targeting the targetType. + * + * @return array */ private static function getGraphQLFieldsFromProviders(ReflectionClass $reflectionClass, string $expectedMetadata, string $targetType, bool $isDefaultTarget = false): array { @@ -782,6 +806,8 @@ private static function getGraphQLFieldsFromProviders(ReflectionClass $reflectio /** * Get the config for description & deprecation reason. + * + * @return array<'description'|'deprecationReason',string> */ private static function getDescriptionConfiguration(array $metadatas, bool $withDeprecation = false): array { From 7224b18d6e871fdb27433f29fc65790fe69f5c51 Mon Sep 17 00:00:00 2001 From: Vincent Date: Wed, 13 Jan 2021 22:22:41 +0100 Subject: [PATCH 12/23] Revert short syntax for @annotation and better deprecation handling --- docs/annotations/annotations-reference.md | 37 ++++++---------- src/Annotation/Access.php | 4 -- src/Annotation/Arg.php | 24 ++++------- src/Annotation/Builder.php | 18 ++++---- src/Annotation/Description.php | 6 +-- src/Annotation/Enum.php | 21 +++++----- src/Annotation/EnumValue.php | 14 +++---- src/Annotation/Field.php | 42 +++++++++++-------- src/Annotation/FieldsBuilder.php | 10 +++++ src/Annotation/Input.php | 14 ++----- src/Annotation/IsPublic.php | 8 +--- src/Annotation/Mutation.php | 13 +++--- src/Annotation/Provider.php | 13 +++--- src/Annotation/Query.php | 21 +++++----- src/Annotation/Relay/Connection.php | 6 +-- src/Annotation/Relay/Edge.php | 6 +-- src/Annotation/Scalar.php | 20 ++++----- src/Annotation/Type.php | 41 +++++++++--------- src/Annotation/TypeInterface.php | 20 ++++----- src/Annotation/Union.php | 23 ++++------ src/Config/Parser/AttributeParser.php | 2 + .../Parser/MetadataParser/MetadataParser.php | 11 +++-- tests/Config/Parser/AnnotationParserTest.php | 17 +++++++- tests/Config/Parser/MetadataParserTest.php | 31 -------------- .../DeprecatedBuilderAttributes.php | 19 +++++++++ ...ed.php => DeprecatedNestedAnnotations.php} | 4 +- .../Parser/fixtures/annotations/Enum/Pet.php | 34 --------------- .../Parser/fixtures/annotations/Enum/Race.php | 8 ++-- .../fixtures/annotations/Input/Star.php | 20 --------- .../fixtures/annotations/Scalar/MyScalar3.php | 15 ------- .../fixtures/annotations/Type/Animal.php | 2 +- .../fixtures/annotations/Type/Armored.php | 2 +- .../Parser/fixtures/annotations/Type/Cat.php | 6 --- .../fixtures/annotations/Type/Crystal.php | 4 +- .../Parser/fixtures/annotations/Type/Dog.php | 35 ---------------- .../fixtures/annotations/Type/Planet.php | 4 +- .../annotations/Union/SearchResult.php | 2 +- 37 files changed, 211 insertions(+), 366 deletions(-) create mode 100644 tests/Config/Parser/fixtures/annotations/Deprecated/DeprecatedBuilderAttributes.php rename tests/Config/Parser/fixtures/annotations/Deprecated/{Deprecated.php => DeprecatedNestedAnnotations.php} (79%) delete mode 100644 tests/Config/Parser/fixtures/annotations/Enum/Pet.php delete mode 100644 tests/Config/Parser/fixtures/annotations/Input/Star.php delete mode 100644 tests/Config/Parser/fixtures/annotations/Scalar/MyScalar3.php delete mode 100644 tests/Config/Parser/fixtures/annotations/Type/Dog.php diff --git a/docs/annotations/annotations-reference.md b/docs/annotations/annotations-reference.md index c87abc587..c6eb33faa 100644 --- a/docs/annotations/annotations-reference.md +++ b/docs/annotations/annotations-reference.md @@ -38,19 +38,6 @@ class Coordinates { } ``` -## Annotations shortcuts - -When using annotations or attributes, you can use a shortened version of the syntax. -Example without: -`@GQL\Field(name="MyField")` - -Example with shortcut: -`@GQL\Field("MyField")` - -When the short version is used (ie. the attribute name is not specified), the attribute describe as `default attribute` in the annotation's attributes list will be use. -In the above example, the attribute `name` on `@GQL\Field` is the default attribute. - - ## Index [@Access](#access) @@ -131,7 +118,7 @@ This annotation is used in conjunction with a `@Field`, a `@Query` or `@Mutation Required attributes: -- **name** (default attribute): The GraphQL name of the field argument (default to class name). +- **name** : The GraphQL name of the field argument (default to class name). - **type** : The GraphQL type of the field argument Optional attributes: @@ -169,7 +156,7 @@ It is used to set an arguments builder for a field (see [Args builders](../defin Required attributes: -- **value** (default attribute): The name of the args builder +- **value** : The name of the args builder Optional attributes: @@ -249,7 +236,7 @@ In order to add more meta on the values (like description or deprecated reason), Optional attributes: -- **name** (default attribute): The GraphQL name of the enum (default to the class name without namespace) +- **name** : The GraphQL name of the enum (default to the class name without namespace) Deprecated attributes: - **values** : An array of `@EnumValue`to define description or deprecated reason of enum values @@ -288,7 +275,7 @@ The attribute `name` must match a constant name on the class. Required attributes: -- **name** (default attribute): The name of the targeted enum value +- **name** : The name of the targeted enum value Optional attributes: @@ -311,7 +298,7 @@ If it is defined on a _method_ of the Root Query or the Root mutation : Optional attributes: -- **name** (default attribute) : The GraphQL name of the field (default to the property name). If you don't specify a `resolve` attribute while changing the `name`, the default one will be '@=value.' +- **name** : The GraphQL name of the field (default to the property name). If you don't specify a `resolve` attribute while changing the `name`, the default one will be '@=value.' - **type** : The GraphqL type of the field. This attribute can sometimes be guessed automatically from Doctrine ORM annotations - **resolve** : A resolution expression @@ -373,7 +360,7 @@ It is used to set a field builder for a field (see [Field builders](../definitio Required attributes: -- **value** (default attribute): The name of the field builder +- **value** : The name of the field builder Optional attributes: @@ -404,7 +391,7 @@ It is used to add fields builder to types (see [Fields builders](../definitions/ Required attributes: -- **value** (default attribute) : The name of the fields builder +- **value** : The name of the fields builder Optional attributes: @@ -433,7 +420,7 @@ An Input type is pretty much the same as an input, except: Optional attributes: -- **name** (default attribute) : The GraphQL name of the input field (default to classnameInput ) +- **name** : The GraphQL name of the input field (default to classnameInput ) - **isRelay** : Set to true if you want your input to be relay compatible (ie. An extra field `clientMutationId` will be added to the input) The corresponding class will also be used by the `Arguments Transformer` service. An instance of the corresponding class will be use as the `input` value if it is an argument of a query or mutation. (see [The Arguments Transformer documentation](arguments-transformer.md)). @@ -563,7 +550,7 @@ This annotation is used on _class_ to define a GraphQL Type. Optional attributes: -- **name** (default attribute) : The GraphQL name of the type (default to the class name without namespace) +- **name** : The GraphQL name of the type (default to the class name without namespace) - **interfaces** : An array of GraphQL interface this type inherits from (can be auto-guessed. See interface documentation). - **isRelay** : Set to true to have a Relay compatible type (ie. A `clientMutationId` will be added). - **isTypeOf** : Is type of resolver for interface implementation @@ -597,7 +584,7 @@ Required attributes: Optional attributes: -- **name** (default attribute) : The GraphQL name of the interface (default to the class name without namespace) +- **name** : The GraphQL name of the interface (default to the class name without namespace) ## @Scalar @@ -605,7 +592,7 @@ This annotation is used on a _class_ to define a custom scalar. Optional attributes: -- **name** (default attribute) : The GraphQL name of the interface (default to the class name without namespace) +- **name** : The GraphQL name of the interface (default to the class name without namespace) - **scalarType** : An expression to reuse an other scalar type Example: @@ -663,7 +650,7 @@ Required attributes: Optional attributes: -- **name** (default attribute) : The GraphQL name of the union (default to the class name without namespace) +- **name** : The GraphQL name of the union (default to the class name without namespace) - **resolveType** : Expression to resolve an object type. By default, it'll use a static method `resolveType` on the related class and call it with the `type resolver` as first argument and then the `value`. Example: diff --git a/src/Annotation/Access.php b/src/Annotation/Access.php index 4802856f8..4a3391424 100644 --- a/src/Annotation/Access.php +++ b/src/Annotation/Access.php @@ -18,10 +18,6 @@ final class Access extends Annotation implements NamedArgumentConstructorAnnotat { /** * Field access. - * - * @Required - * - * @var string */ public string $value; diff --git a/src/Annotation/Arg.php b/src/Annotation/Arg.php index 6a3f74cbf..2ab8c4e3c 100644 --- a/src/Annotation/Arg.php +++ b/src/Annotation/Arg.php @@ -4,7 +4,7 @@ namespace Overblog\GraphQLBundle\Annotation; -use \Attribute; +use Attribute; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** @@ -18,26 +18,16 @@ final class Arg extends Annotation implements NamedArgumentConstructorAnnotation { /** * Argument name. - * - * @Required - * - * @var string */ public string $name; /** * Argument description. - * - * @var string */ public ?string $description; /** * Argument type. - * - * @Required - * - * @var string */ public string $type; @@ -49,14 +39,14 @@ final class Arg extends Annotation implements NamedArgumentConstructorAnnotation public $default; /** - * @param mixed|null $default + * @param string $name The name of the argument + * @param string $type The type of the argument + * @param string|null $description The description of the argument + * @param mixed|null $default Default value of the argument */ - public function __construct(string $name, string $type, ?string $description = null, $default = null, ?string $value = null) + public function __construct(string $name, string $type, ?string $description = null, $default = null) { - if ($value && $name) { - $this->cumulatedAttributesException('name', $value, $name); - } - $this->name = $value ?: $name; + $this->name = $name; $this->description = $description; $this->type = $type; $this->default = $default; diff --git a/src/Annotation/Builder.php b/src/Annotation/Builder.php index f6b156e14..a27104651 100644 --- a/src/Annotation/Builder.php +++ b/src/Annotation/Builder.php @@ -4,7 +4,7 @@ namespace Overblog\GraphQLBundle\Annotation; -use \Attribute; +use Attribute; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** @@ -18,23 +18,21 @@ abstract class Builder extends Annotation implements NamedArgumentConstructorAnn { /** * Builder name. - * - * @Required - * - * @var string */ - public string $value; + public string $name; /** * The builder config. - * - * @var array */ public array $config = []; - public function __construct(string $value, array $config = []) + /** + * @param string|null $name The name of the builder + * @param array $config The builder configuration array + */ + public function __construct(string $name = null, array $config = []) { - $this->value = $value; + $this->name = $name; $this->config = $config; } } diff --git a/src/Annotation/Description.php b/src/Annotation/Description.php index dbd045148..be9d72bdb 100644 --- a/src/Annotation/Description.php +++ b/src/Annotation/Description.php @@ -4,7 +4,7 @@ namespace Overblog\GraphQLBundle\Annotation; -use \Attribute; +use Attribute; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** @@ -18,10 +18,6 @@ final class Description extends Annotation implements NamedArgumentConstructorAn { /** * The object description. - * - * @Required - * - * @var string */ public string $value; diff --git a/src/Annotation/Enum.php b/src/Annotation/Enum.php index 3496a38b1..817d98b99 100644 --- a/src/Annotation/Enum.php +++ b/src/Annotation/Enum.php @@ -4,8 +4,7 @@ namespace Overblog\GraphQLBundle\Annotation; -use \Attribute; -use Doctrine\Common\Annotations\AnnotationException; +use Attribute; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** @@ -19,24 +18,26 @@ final class Enum extends Annotation implements NamedArgumentConstructorAnnotatio { /** * Enum name. - * - * @var string */ public ?string $name; /** * @var array<\Overblog\GraphQLBundle\Annotation\EnumValue> - * + * * @deprecated */ public array $values; - public function __construct(?string $name = null, array $values = [], ?string $value = null) + /** + * @param string|null $name The GraphQL name of the enum + * @param array $values An array of @GQL\EnumValue @deprecated + */ + public function __construct(?string $name = null, array $values = []) { - if ($name && $value) { - $this->cumulatedAttributesException('name', $value, $name); - } - $this->name = $value ?: $name; + $this->name = $name; $this->values = $values; + if (!empty($values)) { + @trigger_error('The attributes "values" on annotation @GQL\Enum is deprecated as of 0.14 and will be removed in 1.0. Use the @GQL\EnumValue annotation on the class itself instead.', E_USER_DEPRECATED); + } } } diff --git a/src/Annotation/EnumValue.php b/src/Annotation/EnumValue.php index af36e30ef..f546fe8b2 100644 --- a/src/Annotation/EnumValue.php +++ b/src/Annotation/EnumValue.php @@ -4,7 +4,6 @@ namespace Overblog\GraphQLBundle\Annotation; -use Attribute; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** @@ -13,7 +12,6 @@ * @Annotation * @Target({"ANNOTATION", "CLASS"}) */ -#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] final class EnumValue extends Annotation implements NamedArgumentConstructorAnnotation { /** @@ -31,12 +29,14 @@ final class EnumValue extends Annotation implements NamedArgumentConstructorAnno */ public ?string $deprecationReason; - public function __construct(?string $name = null, ?string $description = null, ?string $deprecationReason = null, ?string $value = null) + /** + * @param string|null $name The constant name to attach description or deprecation reason to + * @param string|null $description The description of the enum value + * @param string|null $deprecationReason The deprecation reason of the enum value + */ + public function __construct(?string $name = null, ?string $description = null, ?string $deprecationReason = null) { - if ($name && $value) { - $this->cumulatedAttributesException('name', $value, $name); - } - $this->name = $value ?: $name; + $this->name = $name; $this->description = $description; $this->deprecationReason = $deprecationReason; } diff --git a/src/Annotation/Field.php b/src/Annotation/Field.php index 7f5441f5e..1e63b7a5d 100644 --- a/src/Annotation/Field.php +++ b/src/Annotation/Field.php @@ -5,7 +5,6 @@ namespace Overblog\GraphQLBundle\Annotation; use Attribute; -use Doctrine\Common\Annotations\AnnotationException; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** @@ -19,15 +18,11 @@ class Field extends Annotation implements NamedArgumentConstructorAnnotation { /** * The field name. - * - * @var string */ public ?string $name; /** * Field Type. - * - * @var string */ public ?string $type; @@ -42,8 +37,6 @@ class Field extends Annotation implements NamedArgumentConstructorAnnotation /** * Resolver for this property. - * - * @var string */ public ?string $resolve; @@ -73,28 +66,41 @@ class Field extends Annotation implements NamedArgumentConstructorAnnotation public ?string $complexity; /** - * @param string|string[]|null $argsBuilder - * @param string|string[]|null $fieldBuilder + * @param string|null $name The GraphQL name of the field + * @param string|null $type The GraphQL type of the field + * @param array $args An array of @GQL\Arg to describe arguments @deprecated + * @param string|null $resolve A expression resolver to resolve the field value + * @param mixed|null $argsBuilder A @GQL\ArgsBuilder to generate arguments @deprecated + * @param mixed|null $fieldBuilder A @GQL\FieldBuilder to generate the field @deprecated + * @param string|null $complexity A complexity expression */ public function __construct( - ?string $name = null, - ?string $type = null, + string $name = null, + string $type = null, array $args = [], - ?string $resolve = null, + string $resolve = null, $argsBuilder = null, $fieldBuilder = null, - ?string $complexity = null, - ?string $value = null + string $complexity = null ) { - if ($name && $value) { - $this->cumulatedAttributesException('name', $value, $name); - } - $this->name = $value ?: $name; + $this->name = $name; $this->type = $type; $this->args = $args; $this->resolve = $resolve; $this->argsBuilder = $argsBuilder; $this->fieldBuilder = $fieldBuilder; $this->complexity = $complexity; + + if (null !== $argsBuilder) { + @trigger_error('The attributes "argsBuilder" on annotation @GQL\Field is deprecated as of 0.14 and will be removed in 1.0. Use a @ArgsBuilder annotation on the property or method instead.', E_USER_DEPRECATED); + } + + if (null !== $fieldBuilder) { + @trigger_error('The attributes "fieldBuilder" on annotation @GQL\Field is deprecated as of 0.14 and will be removed in 1.0. Use a @FieldBuilder annotation on the property or method instead.', E_USER_DEPRECATED); + } + + if (!empty($args)) { + @trigger_error('The attributes "args" on annotation @GQL\Field is deprecated as of 0.14 and will be removed in 1.0. Use the @Arg annotation on the property or method instead.', E_USER_DEPRECATED); + } } } diff --git a/src/Annotation/FieldsBuilder.php b/src/Annotation/FieldsBuilder.php index 50e2eb284..f831eb8b5 100644 --- a/src/Annotation/FieldsBuilder.php +++ b/src/Annotation/FieldsBuilder.php @@ -16,4 +16,14 @@ #[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] final class FieldsBuilder extends Builder implements NamedArgumentConstructorAnnotation { + public function __construct(string $name = null, array $config = null, string $builder = null, array $builderConfig = null) + { + parent::__construct($name ?: $builder, $config ?: $builderConfig ?: []); + if (null !== $builder) { + @trigger_error('The attributes "builder" on annotation @GQL\FieldsBuilder is deprecated as of 0.14 and will be removed in 1.0. Use "name" attribute instead.', E_USER_DEPRECATED); + } + if (null !== $builderConfig) { + @trigger_error('The attributes "builderConfig" on annotation @GQL\FieldsBuilder is deprecated as of 0.14 and will be removed in 1.0. Use "config" attribute instead.', E_USER_DEPRECATED); + } + } } diff --git a/src/Annotation/Input.php b/src/Annotation/Input.php index 7e1fc9d17..2dcdc2d99 100644 --- a/src/Annotation/Input.php +++ b/src/Annotation/Input.php @@ -4,8 +4,7 @@ namespace Overblog\GraphQLBundle\Annotation; -use \Attribute; -use Doctrine\Common\Annotations\AnnotationException; +use Attribute; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** @@ -19,24 +18,17 @@ final class Input extends Annotation implements NamedArgumentConstructorAnnotati { /** * Type name. - * - * @var string */ public ?string $name; /** * Is the type a relay input. - * - * @var boolean */ public bool $isRelay = false; - public function __construct(?string $name = null, bool $isRelay = false, ?string $value = null) + public function __construct(string $name = null, bool $isRelay = false) { - if ($name && $value) { - $this->cumulatedAttributesException('name', $value, $name); - } - $this->name = $value ?: $name; + $this->name = $name; $this->isRelay = $isRelay; } } diff --git a/src/Annotation/IsPublic.php b/src/Annotation/IsPublic.php index cc84e9579..8663e882c 100644 --- a/src/Annotation/IsPublic.php +++ b/src/Annotation/IsPublic.php @@ -4,7 +4,7 @@ namespace Overblog\GraphQLBundle\Annotation; -use \Attribute; +use Attribute; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** @@ -16,12 +16,8 @@ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::TARGET_PROPERTY)] final class IsPublic extends Annotation implements NamedArgumentConstructorAnnotation { - /** + /** * Field publicity. - * - * @Required - * - * @var string */ public string $value; diff --git a/src/Annotation/Mutation.php b/src/Annotation/Mutation.php index f732b8165..72cfa0725 100644 --- a/src/Annotation/Mutation.php +++ b/src/Annotation/Mutation.php @@ -5,7 +5,6 @@ namespace Overblog\GraphQLBundle\Annotation; use Attribute; -use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** * Annotation for GraphQL mutation. @@ -24,8 +23,10 @@ final class Mutation extends Field public array $targetTypes; /** - * @param string|string[]|null $targetTypes - * @param string|string[]|null $targetType + * {@inheritdoc} + * + * @param string|string[]|null $targetTypes + * @param string|string[]|null $targetType @deprecated */ public function __construct( ?string $name = null, @@ -36,14 +37,14 @@ public function __construct( $fieldBuilder = null, ?string $complexity = null, $targetTypes = null, - $targetType = null, - ?string $value = null + $targetType = null ) { - parent::__construct($name, $type, $args, $resolve, $argsBuilder, $fieldBuilder, $complexity, $value); + parent::__construct($name, $type, $args, $resolve, $argsBuilder, $fieldBuilder, $complexity); if ($targetTypes) { $this->targetTypes = is_string($targetTypes) ? [$targetTypes] : $targetTypes; } elseif ($targetType) { $this->targetTypes = is_string($targetType) ? [$targetType] : $targetType; + @trigger_error('The attributes "targetType" on annotation @GQL\Mutation is deprecated as of 0.14 and will be removed in 1.0. Use the "targetTypes" attributes instead.', E_USER_DEPRECATED); } } } diff --git a/src/Annotation/Provider.php b/src/Annotation/Provider.php index e7b735ae4..7ff8bd5fd 100644 --- a/src/Annotation/Provider.php +++ b/src/Annotation/Provider.php @@ -4,7 +4,7 @@ namespace Overblog\GraphQLBundle\Annotation; -use \Attribute; +use Attribute; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** @@ -18,8 +18,6 @@ final class Provider extends Annotation implements NamedArgumentConstructorAnnot { /** * Optionnal prefix for provider fields. - * - * @var string */ public ?string $prefix; @@ -36,12 +34,13 @@ final class Provider extends Annotation implements NamedArgumentConstructorAnnot * @var array */ public ?array $targetMutationTypes; - + /** - * @param string|string[]|null $targetQueryTypes - * @param string|string[]|null $targetMutationTypes + * @param string $prefix A prefix to apply to the name of fields generated by this provider + * @param string|string[]|null $targetQueryTypes A list of GraphQL types to add the resolver queries to + * @param string|string[]|null $targetMutationTypes A list of GraphQL types to add the resolver mutations to */ - public function __construct(?string $prefix = null, $targetQueryTypes = null, $targetMutationTypes = null) + public function __construct(string $prefix = null, $targetQueryTypes = null, $targetMutationTypes = null) { $this->prefix = $prefix; $this->targetQueryTypes = is_string($targetQueryTypes) ? [$targetQueryTypes] : $targetQueryTypes; diff --git a/src/Annotation/Query.php b/src/Annotation/Query.php index d10d49048..d6985ca5e 100644 --- a/src/Annotation/Query.php +++ b/src/Annotation/Query.php @@ -5,7 +5,6 @@ namespace Overblog\GraphQLBundle\Annotation; use Attribute; -use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** * Annotation for GraphQL query. @@ -24,26 +23,28 @@ final class Query extends Field public ?array $targetTypes; /** - * @param string|string[]|null $targetTypes - * @param string|string[]|null $targetType + * {@inheritdoc} + * + * @param string|string[]|null $targetTypes + * @param string|string[]|null $targetType */ public function __construct( - ?string $name = null, - ?string $type = null, + string $name = null, + string $type = null, array $args = [], - ?string $resolve = null, + string $resolve = null, $argsBuilder = null, $fieldBuilder = null, - ?string $complexity = null, + string $complexity = null, $targetTypes = null, - $targetType = null, - ?string $value = null + $targetType = null ) { - parent::__construct($name, $type, $args, $resolve, $argsBuilder, $fieldBuilder, $complexity, $value); + parent::__construct($name, $type, $args, $resolve, $argsBuilder, $fieldBuilder, $complexity); if ($targetTypes) { $this->targetTypes = is_string($targetTypes) ? [$targetTypes] : $targetTypes; } elseif ($targetType) { $this->targetTypes = is_string($targetType) ? [$targetType] : $targetType; + @trigger_error('The attributes "targetType" on annotation @GQL\Query is deprecated as of 0.14 and will be removed in 1.0. Use the "targetTypes" attributes instead.', E_USER_DEPRECATED); } } } diff --git a/src/Annotation/Relay/Connection.php b/src/Annotation/Relay/Connection.php index d3656f774..8f79648f4 100644 --- a/src/Annotation/Relay/Connection.php +++ b/src/Annotation/Relay/Connection.php @@ -4,7 +4,7 @@ namespace Overblog\GraphQLBundle\Annotation\Relay; -use \Attribute; +use Attribute; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; use Overblog\GraphQLBundle\Annotation\Annotation; use Overblog\GraphQLBundle\Annotation\Type; @@ -20,15 +20,11 @@ final class Connection extends Type implements NamedArgumentConstructorAnnotatio { /** * Connection Edge type. - * - * @var string */ public ?string $edge; /** * Connection Node type. - * - * @var string */ public ?string $node; diff --git a/src/Annotation/Relay/Edge.php b/src/Annotation/Relay/Edge.php index 8d6dff21e..95299f4bd 100644 --- a/src/Annotation/Relay/Edge.php +++ b/src/Annotation/Relay/Edge.php @@ -4,7 +4,7 @@ namespace Overblog\GraphQLBundle\Annotation\Relay; -use \Attribute; +use Attribute; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; use Overblog\GraphQLBundle\Annotation\Annotation; use Overblog\GraphQLBundle\Annotation\Type; @@ -20,10 +20,6 @@ final class Edge extends Type implements NamedArgumentConstructorAnnotation { /** * Edge Node type. - * - * @Required - * - * @var string */ public string $node; diff --git a/src/Annotation/Scalar.php b/src/Annotation/Scalar.php index 1b819bc5d..43c29a59f 100644 --- a/src/Annotation/Scalar.php +++ b/src/Annotation/Scalar.php @@ -4,8 +4,7 @@ namespace Overblog\GraphQLBundle\Annotation; -use \Attribute; -use Doctrine\Common\Annotations\AnnotationException; +use Attribute; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** @@ -17,22 +16,17 @@ #[Attribute(Attribute::TARGET_CLASS)] final class Scalar extends Annotation implements NamedArgumentConstructorAnnotation { - /** - * @var string - */ public ?string $name; + public ?string $scalarType; + /** - * @var string + * @param string|null $name The GraphQL name of the Scalar + * @param string|null $scalarType Expression to reuse an other scalar type */ - public ?string $scalarType; - - public function __construct(?string $name = null, ?string $scalarType = null, ?string $value = null) + public function __construct(string $name = null, string $scalarType = null) { - if ($name && $value) { - $this->cumulatedAttributesException('name', $value, $name); - } - $this->name = $value ?: $name; + $this->name = $name; $this->scalarType = $scalarType; } } diff --git a/src/Annotation/Type.php b/src/Annotation/Type.php index 68dcc4b84..3e504267f 100644 --- a/src/Annotation/Type.php +++ b/src/Annotation/Type.php @@ -4,8 +4,7 @@ namespace Overblog\GraphQLBundle\Annotation; -use \Attribute; -use Doctrine\Common\Annotations\AnnotationException; +use Attribute; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** @@ -19,8 +18,6 @@ class Type extends Annotation implements NamedArgumentConstructorAnnotation { /** * Type name. - * - * @var string */ public ?string $name; @@ -33,15 +30,11 @@ class Type extends Annotation implements NamedArgumentConstructorAnnotation /** * Is the type a relay payload. - * - * @var boolean */ public bool $isRelay = false; /** * Expression to a target fields resolver. - * - * @var string */ public ?string $resolveField; @@ -49,35 +42,41 @@ class Type extends Annotation implements NamedArgumentConstructorAnnotation * List of fields builder. * * @var array<\Overblog\GraphQLBundle\Annotation\FieldsBuilder> - * + * * @deprecated */ public array $builders = []; /** * Expression to resolve type for interfaces. - * - * @var string */ public ?string $isTypeOf; + /** + * @param string|null $name The GraphQL name of the type + * @param string[] $interfaces List of GraphQL interfaces implemented by the type + * @param bool $isRelay Set to true to make the type compatible with relay + * @param string|null $resolveField An expression to resolve the field value + * @param array $builders A list of fields builder to use @deprecated + * @param string|null $isTypeOf An expression to resolve if the field is of given type + */ public function __construct( - ?string $name = null, + string $name = null, array $interfaces = [], bool $isRelay = false, - ?string $resolveField = null, - array $builders = [], - ?string $isTypeOf = null, - ?string $value = null + string $resolveField = null, + string $isTypeOf = null, + array $builders = [] ) { - if ($name && $value) { - $this->cumulatedAttributesException('name', $value, $name); - } - $this->name = $value ?: $name; + $this->name = $name; $this->interfaces = $interfaces; $this->isRelay = $isRelay; $this->resolveField = $resolveField; - $this->builders = $builders; $this->isTypeOf = $isTypeOf; + $this->builders = $builders; + + if (!empty($builders)) { + @trigger_error('The attributes "builders" on annotation @GQL\Type is deprecated as of 0.14 and will be removed in 1.0. Use the @FieldsBuilder directly on the class itself.', E_USER_DEPRECATED); + } } } diff --git a/src/Annotation/TypeInterface.php b/src/Annotation/TypeInterface.php index 4570c507e..3cf9b61f9 100644 --- a/src/Annotation/TypeInterface.php +++ b/src/Annotation/TypeInterface.php @@ -4,8 +4,7 @@ namespace Overblog\GraphQLBundle\Annotation; -use \Attribute; -use Doctrine\Common\Annotations\AnnotationException; +use Attribute; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** @@ -19,26 +18,21 @@ final class TypeInterface extends Annotation implements NamedArgumentConstructor { /** * Interface name. - * - * @var string */ public ?string $name; /** * Resolver type for interface. - * - * @Required - * - * @var string */ public string $resolveType; - public function __construct(?string $name = null, string $resolveType, ?string $value = null) + /** + * @param string|null $name The GraphQL name of the interface + * @param string $resolveType The express resolve type + */ + public function __construct(string $name = null, string $resolveType) { - if ($name && $value) { - $this->cumulatedAttributesException('name', $value, $name); - } - $this->name = $value ?: $name; + $this->name = $name; $this->resolveType = $resolveType; } } diff --git a/src/Annotation/Union.php b/src/Annotation/Union.php index d5cd80a47..500ad747f 100644 --- a/src/Annotation/Union.php +++ b/src/Annotation/Union.php @@ -4,8 +4,7 @@ namespace Overblog\GraphQLBundle\Annotation; -use \Attribute; -use Doctrine\Common\Annotations\AnnotationException; +use Attribute; use Doctrine\Common\Annotations\NamedArgumentConstructorAnnotation; /** @@ -19,31 +18,27 @@ final class Union extends Annotation implements NamedArgumentConstructorAnnotati { /** * Union name. - * - * @var string */ public ?string $name; /** * Union types. - * - * @var array */ public array $types = []; /** * Resolver type for union. - * - * @var string */ public ?string $resolveType; - - public function __construct(?string $name = null, array $types = [], ?string $resolveType = null, ?string $value = null) + + /** + * @param string|null $name The GraphQL name of the union + * @param string[] $types List of types included in the union + * @param string|null $resolveType The resolve type expression + */ + public function __construct(string $name = null, array $types = [], ?string $resolveType = null) { - if ($name && $value) { - $this->cumulatedAttributesException('name', $value, $name); - } - $this->name = $value ?: $name; + $this->name = $name; $this->types = $types; $this->resolveType = $resolveType; } diff --git a/src/Config/Parser/AttributeParser.php b/src/Config/Parser/AttributeParser.php index 98d4cf6c7..1b0fde019 100644 --- a/src/Config/Parser/AttributeParser.php +++ b/src/Config/Parser/AttributeParser.php @@ -7,6 +7,7 @@ use Overblog\GraphQLBundle\Config\Parser\MetadataParser\MetadataParser; use ReflectionAttribute; use ReflectionClass; +use ReflectionClassConstant; use ReflectionMethod; use ReflectionProperty; use Reflector; @@ -23,6 +24,7 @@ public static function getMetadatas(Reflector $reflector): array case $reflector instanceof ReflectionClass: case $reflector instanceof ReflectionMethod: case $reflector instanceof ReflectionProperty: + case $reflector instanceof ReflectionClassConstant: $attributes = $reflector->getAttributes(); } diff --git a/src/Config/Parser/MetadataParser/MetadataParser.php b/src/Config/Parser/MetadataParser/MetadataParser.php index 140e0cc71..3d186e4f7 100644 --- a/src/Config/Parser/MetadataParser/MetadataParser.php +++ b/src/Config/Parser/MetadataParser/MetadataParser.php @@ -15,6 +15,7 @@ use Overblog\GraphQLBundle\Relay\Connection\ConnectionInterface; use Overblog\GraphQLBundle\Relay\Connection\EdgeInterface; use ReflectionClass; +use ReflectionClassConstant; use ReflectionException; use ReflectionMethod; use ReflectionProperty; @@ -346,7 +347,7 @@ private static function graphQLTypeConfigFromAnnotation(ReflectionClass $reflect $buildersAnnotations = array_merge(self::getMetadataMatching($metadatas, Metadata\FieldsBuilder::class), $typeAnnotation->builders); if (!empty($buildersAnnotations)) { $typeConfiguration['builders'] = array_map(function ($fieldsBuilderAnnotation) { - return ['builder' => $fieldsBuilderAnnotation->value, 'builderConfig' => $fieldsBuilderAnnotation->config]; + return ['builder' => $fieldsBuilderAnnotation->name, 'builderConfig' => $fieldsBuilderAnnotation->config]; }, $buildersAnnotations); } @@ -438,8 +439,10 @@ private static function enumMetadataToGQLConfiguration(ReflectionClass $reflecti $values = []; foreach ($reflectionClass->getConstants() as $name => $value) { + $reflectionConstant = new ReflectionClassConstant($reflectionClass->getName(), $name); + $valueConfig = self::getDescriptionConfiguration(static::getMetadatas($reflectionConstant), true); + $enumValueAnnotation = current(array_filter($enumValues, fn ($enumValueAnnotation) => $enumValueAnnotation->name === $name)); - $valueConfig = []; $valueConfig['value'] = $value; if (false !== $enumValueAnnotation) { @@ -585,7 +588,7 @@ private static function getTypeFieldConfigurationFromReflector(ReflectionClass $ $argsBuilder = self::getFirstMetadataMatching($metadatas, Metadata\ArgsBuilder::class); if ($argsBuilder) { - $fieldConfiguration['argsBuilder'] = ['builder' => $argsBuilder->value, 'config' => $argsBuilder->config]; + $fieldConfiguration['argsBuilder'] = ['builder' => $argsBuilder->name, 'config' => $argsBuilder->config]; } elseif ($fieldMetadata->argsBuilder) { if (is_string($fieldMetadata->argsBuilder)) { $fieldConfiguration['argsBuilder'] = ['builder' => $fieldMetadata->argsBuilder, 'config' => []]; @@ -598,7 +601,7 @@ private static function getTypeFieldConfigurationFromReflector(ReflectionClass $ } $fieldBuilder = self::getFirstMetadataMatching($metadatas, Metadata\FieldBuilder::class); if ($fieldBuilder) { - $fieldConfiguration['builder'] = $fieldBuilder->value; + $fieldConfiguration['builder'] = $fieldBuilder->name; $fieldConfiguration['builderConfig'] = $fieldBuilder->config; } elseif ($fieldMetadata->fieldBuilder) { if (is_string($fieldMetadata->fieldBuilder)) { diff --git a/tests/Config/Parser/AnnotationParserTest.php b/tests/Config/Parser/AnnotationParserTest.php index 835bb557c..5b5b2f3b5 100644 --- a/tests/Config/Parser/AnnotationParserTest.php +++ b/tests/Config/Parser/AnnotationParserTest.php @@ -21,8 +21,8 @@ public function formatMetadata(string $metadata): string public function testLegacyNestedAnnotations(): void { - $this->config = self::cleanConfig($this->parser('parse', new SplFileInfo(__DIR__.'/fixtures/annotations/Deprecated/Deprecated.php'), $this->containerBuilder, ['doctrine' => ['types_mapping' => []]])); - $this->expect('Deprecated', 'object', [ + $this->config = self::cleanConfig($this->parser('parse', new SplFileInfo(__DIR__.'/fixtures/annotations/Deprecated/DeprecatedNestedAnnotations.php'), $this->containerBuilder, ['doctrine' => ['types_mapping' => []]])); + $this->expect('DeprecatedNestedAnnotations', 'object', [ 'fields' => [ 'color' => ['type' => 'String!'], 'getList' => [ @@ -39,4 +39,17 @@ public function testLegacyNestedAnnotations(): void ], ]); } + + public function testLegacyFieldsBuilderAttributes(): void + { + $this->config = self::cleanConfig($this->parser('parse', new SplFileInfo(__DIR__.'/fixtures/annotations/Deprecated/DeprecatedBuilderAttributes.php'), $this->containerBuilder, ['doctrine' => ['types_mapping' => []]])); + $this->expect('DeprecatedBuilderAttributes', 'object', [ + 'fields' => [ + 'color' => ['type' => 'String!'], + ], + 'builders' => [ + ['builder' => 'MyFieldsBuilder', 'builderConfig' => ['param1' => 'val1']], + ], + ]); + } } diff --git a/tests/Config/Parser/MetadataParserTest.php b/tests/Config/Parser/MetadataParserTest.php index 6d0682069..42d0ef55f 100644 --- a/tests/Config/Parser/MetadataParserTest.php +++ b/tests/Config/Parser/MetadataParserTest.php @@ -204,22 +204,8 @@ public function testTypes(): void 'name' => ['type' => 'String!', 'description' => 'The name of the animal'], 'lives' => ['type' => 'Int!'], 'toys' => ['type' => '[String!]!'], - 'shortcut' => ['type' => 'String', 'resolve' => '@=value.field'], ], ]); - - // Test type with shortcut annotation - $this->expect('Doggy', 'object', [ - 'fields' => [ - 'toys' => ['type' => '[String!]!'], - 'catFights' => [ - 'type' => 'Int!', - 'resolve' => '@=call(value.getCountCatFights, arguments({}, args))', - 'argsBuilder' => ['builder' => 'MyArgsBuilder'], - ], - ], - 'builders' => [['builder' => 'MyFieldsBuilder']], - ]); } public function testInput(): void @@ -235,12 +221,6 @@ public function testInput(): void 'tags' => ['type' => '[String]!'], ], ]); - - $this->expect('StarPlanet', 'input-object', [ - 'fields' => [ - 'distance' => ['type' => 'Int!'], - ], - ]); } public function testInterfaces(): void @@ -262,13 +242,6 @@ public function testEnum(): void 'TWILEK' => ['value' => '4'], ], ]); - - $this->expect('Pets', 'enum', [ - 'values' => [ - 'DOGS' => ['value' => 'dog'], - 'CATS' => ['value' => 'cat'], - ], - ]); } public function testUnion(): void @@ -318,10 +291,6 @@ public function testScalar(): void 'parseLiteral' => ['Overblog\GraphQLBundle\Tests\Config\Parser\fixtures\\annotations\Scalar\GalaxyCoordinates', 'parseLiteral'], 'description' => 'The galaxy coordinates scalar', ]); - - $this->expect('ShortScalar', 'custom-scalar', [ - 'scalarType' => '@=type', - ]); } public function testProviders(): void diff --git a/tests/Config/Parser/fixtures/annotations/Deprecated/DeprecatedBuilderAttributes.php b/tests/Config/Parser/fixtures/annotations/Deprecated/DeprecatedBuilderAttributes.php new file mode 100644 index 000000000..e76300346 --- /dev/null +++ b/tests/Config/Parser/fixtures/annotations/Deprecated/DeprecatedBuilderAttributes.php @@ -0,0 +1,19 @@ +value = $value; - } -} diff --git a/tests/Config/Parser/fixtures/annotations/Enum/Race.php b/tests/Config/Parser/fixtures/annotations/Enum/Race.php index 4a83de04d..4a46ba5ec 100644 --- a/tests/Config/Parser/fixtures/annotations/Enum/Race.php +++ b/tests/Config/Parser/fixtures/annotations/Enum/Race.php @@ -9,18 +9,20 @@ /** * @GQL\Enum - * @GQL\EnumValue("CHISS", description="The Chiss race") + * @GQL\EnumValue(name="CHISS", description="The Chiss race") * @GQL\EnumValue(name="ZABRAK", deprecationReason="The Zabraks have been wiped out") * @GQL\Description("The list of races!") */ #[GQL\Enum] -#[GQL\EnumValue("CHISS", description: "The Chiss race")] -#[GQL\EnumValue(name: "ZABRAK", deprecationReason: "The Zabraks have been wiped out")] #[GQL\Description("The list of races!")] class Race { public const HUMAIN = 1; + + #[GQL\Description("The Chiss race")] public const CHISS = '2'; + + #[GQL\Deprecated("The Zabraks have been wiped out")] public const ZABRAK = '3'; public const TWILEK = Constants::TWILEK; diff --git a/tests/Config/Parser/fixtures/annotations/Input/Star.php b/tests/Config/Parser/fixtures/annotations/Input/Star.php deleted file mode 100644 index 4cf61c442..000000000 --- a/tests/Config/Parser/fixtures/annotations/Input/Star.php +++ /dev/null @@ -1,20 +0,0 @@ - "val1"])] +#[GQL\FieldsBuilder(name: "MyFieldsBuilder", config: ["param1" => "val1"])] class Crystal { /** diff --git a/tests/Config/Parser/fixtures/annotations/Type/Dog.php b/tests/Config/Parser/fixtures/annotations/Type/Dog.php deleted file mode 100644 index 22aea2b86..000000000 --- a/tests/Config/Parser/fixtures/annotations/Type/Dog.php +++ /dev/null @@ -1,35 +0,0 @@ - "value1"])] @@ -46,7 +46,7 @@ class Planet * type="Planet", * resolve="@=resolver('closest_planet', [args['filter']])" * ) - * @GQL\ArgsBuilder(value="PlanetFilterArgBuilder", config={"option2"="value2"}) + * @GQL\ArgsBuilder(name="PlanetFilterArgBuilder", config={"option2"="value2"}) */ #[GQL\Field(type: "Planet", resolve: "@=resolver('closest_planet', [args['filter']])")] #[GQL\ArgsBuilder("PlanetFilterArgBuilder", ["option2" => "value2"])] diff --git a/tests/Config/Parser/fixtures/annotations/Union/SearchResult.php b/tests/Config/Parser/fixtures/annotations/Union/SearchResult.php index 5c17463e7..e02d6e94c 100644 --- a/tests/Config/Parser/fixtures/annotations/Union/SearchResult.php +++ b/tests/Config/Parser/fixtures/annotations/Union/SearchResult.php @@ -7,7 +7,7 @@ use Overblog\GraphQLBundle\Annotation as GQL; /** - * @GQL\Union("ResultSearch", types={"Hero", "Droid", "Sith"}, resolveType="value.getType()") + * @GQL\Union(name="ResultSearch", types={"Hero", "Droid", "Sith"}, resolveType="value.getType()") * @GQL\Description("A search result") */ #[GQL\Union("ResultSearch", types: ["Hero", "Droid", "Sith"], resolveType: "value.getType()")] From 0940112608b0c0981bed9a2539ee5092f25aa166 Mon Sep 17 00:00:00 2001 From: Vincent Date: Wed, 13 Jan 2021 22:32:11 +0100 Subject: [PATCH 13/23] Test deprecated enum value & remove check for short syntax --- src/Annotation/Annotation.php | 7 ------- tests/Config/Parser/AnnotationParserTest.php | 11 +++++++++++ .../annotations/Deprecated/DeprecatedEnum.php | 19 +++++++++++++++++++ 3 files changed, 30 insertions(+), 7 deletions(-) create mode 100644 tests/Config/Parser/fixtures/annotations/Deprecated/DeprecatedEnum.php diff --git a/src/Annotation/Annotation.php b/src/Annotation/Annotation.php index 432faeb08..64140e7f9 100644 --- a/src/Annotation/Annotation.php +++ b/src/Annotation/Annotation.php @@ -4,13 +4,6 @@ namespace Overblog\GraphQLBundle\Annotation; -use Doctrine\Common\Annotations\AnnotationException; - abstract class Annotation { - protected function cumulatedAttributesException(string $attribute, string $value, string $attributeValue): void - { - $annotationName = str_replace('Overblog\GraphQLBundle\Annotation\\', '', get_class($this)); - throw new AnnotationException(sprintf('The @%s %s is defined by both the default attribute "%s" and the %s attribute "%s". Pick one.', $annotationName, $attribute, $value, $attribute, $attributeValue)); - } } diff --git a/tests/Config/Parser/AnnotationParserTest.php b/tests/Config/Parser/AnnotationParserTest.php index 5b5b2f3b5..088ffa11d 100644 --- a/tests/Config/Parser/AnnotationParserTest.php +++ b/tests/Config/Parser/AnnotationParserTest.php @@ -52,4 +52,15 @@ public function testLegacyFieldsBuilderAttributes(): void ], ]); } + + public function testLegacyEnumNestedValue(): void + { + $this->config = self::cleanConfig($this->parser('parse', new SplFileInfo(__DIR__.'/fixtures/annotations/Deprecated/DeprecatedEnum.php'), $this->containerBuilder, ['doctrine' => ['types_mapping' => []]])); + $this->expect('DeprecatedEnum', 'enum', [ + 'values' => [ + 'P1' => ['value' => 1, 'description' => 'P1 description'], + 'P2' => ['value' => 2, 'deprecationReason' => 'P2 deprecated'], + ], + ]); + } } diff --git a/tests/Config/Parser/fixtures/annotations/Deprecated/DeprecatedEnum.php b/tests/Config/Parser/fixtures/annotations/Deprecated/DeprecatedEnum.php new file mode 100644 index 000000000..7bea543c2 --- /dev/null +++ b/tests/Config/Parser/fixtures/annotations/Deprecated/DeprecatedEnum.php @@ -0,0 +1,19 @@ + Date: Thu, 14 Jan 2021 11:17:53 +0100 Subject: [PATCH 14/23] Add more tests for doctrine type guesser --- .../TypeGuesser/DoctrineTypeGuesser.php | 2 +- .../TypeGuesser/DoctrineTypeGuesserTest.php | 36 +++++++++++++++ tests/Config/Parser/MetadataParserTest.php | 6 +++ .../fixtures/annotations/Type/Lightsaber.php | 44 ++++++++++++++++++- 4 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 tests/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesserTest.php diff --git a/src/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php b/src/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php index f1957a62e..e757154ed 100644 --- a/src/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php +++ b/src/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesser.php @@ -47,7 +47,7 @@ public function supports(Reflector $reflector): bool public function guessType(ReflectionClass $reflectionClass, Reflector $reflector, array $filterGraphQLTypes = []): ?string { if (!$reflector instanceof ReflectionProperty) { - throw new TypeGuessingException('Doctrine type guesser only apply to properties'); + throw new TypeGuessingException('Doctrine type guesser only apply to properties.'); } /** @var Column|null $columnAnnotation */ $columnAnnotation = $this->getAnnotation($reflector, Column::class); diff --git a/tests/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesserTest.php b/tests/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesserTest.php new file mode 100644 index 000000000..b67a632ba --- /dev/null +++ b/tests/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesserTest.php @@ -0,0 +1,36 @@ +guessType($refClass, $refClass); + } catch (Exception $e) { + $this->assertInstanceOf(TypeGuessingException::class, $e); + $this->assertStringContainsString('Doctrine type guesser only apply to properties.', $e->getMessage()); + } + + try { + $docBlockGuesser->guessType($refClass, $refClass->getProperty('property')); + } catch (Exception $e) { + $this->assertInstanceOf(TypeGuessingException::class, $e); + $this->assertStringContainsString('No Doctrine ORM annotation found.', $e->getMessage()); + } + } +} diff --git a/tests/Config/Parser/MetadataParserTest.php b/tests/Config/Parser/MetadataParserTest.php index 42d0ef55f..1638b0c18 100644 --- a/tests/Config/Parser/MetadataParserTest.php +++ b/tests/Config/Parser/MetadataParserTest.php @@ -405,6 +405,12 @@ public function testDoctrineGuessing(): void 'battles' => ['type' => '[Battle]!'], 'currentHolder' => ['type' => 'Hero'], 'tags' => ['type' => '[String]!', 'deprecationReason' => 'No more tags on lightsabers'], + 'text' => ['type' => 'String!'], + 'string' => ['type' => 'String!'], + 'float' => ['type' => 'Float!'], + 'decimal' => ['type' => 'Float!'], + 'bool' => ['type' => 'Boolean!'], + 'boolean' => ['type' => 'Boolean!'], ], ]); } diff --git a/tests/Config/Parser/fixtures/annotations/Type/Lightsaber.php b/tests/Config/Parser/fixtures/annotations/Type/Lightsaber.php index f7f343125..7945ba5a4 100644 --- a/tests/Config/Parser/fixtures/annotations/Type/Lightsaber.php +++ b/tests/Config/Parser/fixtures/annotations/Type/Lightsaber.php @@ -19,7 +19,21 @@ class Lightsaber * @GQL\Field */ #[GQL\Field] - protected string $color; + protected $color; + + /** + * @ORM\Column(type="text") + * @GQL\Field + */ + #[GQL\Field] + protected $text; + + /** + * @ORM\Column(type="string") + * @GQL\Field + */ + #[GQL\Field] + protected $string; /** * @ORM\Column(type="integer", nullable=true) @@ -78,4 +92,32 @@ class Lightsaber #[GQL\Field] #[GQL\Deprecated("No more tags on lightsabers")] protected array $tags; + + /** + * @ORM\Column(type="float") + * @GQL\Field + */ + #[GQL\Field] + protected $float; + + /** + * @ORM\Column(type="decimal") + * @GQL\Field + */ + #[GQL\Field] + protected $decimal; + + /** + * @ORM\Column(type="bool") + * @GQL\Field + */ + #[GQL\Field] + protected $bool; + + /** + * @ORM\Column(type="boolean") + * @GQL\Field + */ + #[GQL\Field] + protected $boolean; } From 712b141d49bd243dd3eaaf95ca821ba1a4ed4a42 Mon Sep 17 00:00:00 2001 From: Vincent Date: Thu, 14 Jan 2021 11:21:58 +0100 Subject: [PATCH 15/23] Fix phpstan --- .../MetadataParser/TypeGuesser/DoctrineTypeGuesserTest.php | 2 ++ .../Config/Parser/fixtures/annotations/Type/Lightsaber.php | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/tests/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesserTest.php b/tests/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesserTest.php index b67a632ba..86fc1f856 100644 --- a/tests/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesserTest.php +++ b/tests/Config/Parser/MetadataParser/TypeGuesser/DoctrineTypeGuesserTest.php @@ -12,6 +12,7 @@ class DoctrineTypeGuesserTest extends TestCase { + // @phpstan-ignore-next-line protected $property; public function testGuessError(): void @@ -20,6 +21,7 @@ public function testGuessError(): void $docBlockGuesser = new DoctrineTypeGuesser(new ClassesTypesMap()); try { + // @phpstan-ignore-next-line $docBlockGuesser->guessType($refClass, $refClass); } catch (Exception $e) { $this->assertInstanceOf(TypeGuessingException::class, $e); diff --git a/tests/Config/Parser/fixtures/annotations/Type/Lightsaber.php b/tests/Config/Parser/fixtures/annotations/Type/Lightsaber.php index 7945ba5a4..90d24b0b7 100644 --- a/tests/Config/Parser/fixtures/annotations/Type/Lightsaber.php +++ b/tests/Config/Parser/fixtures/annotations/Type/Lightsaber.php @@ -19,6 +19,7 @@ class Lightsaber * @GQL\Field */ #[GQL\Field] + // @phpstan-ignore-next-line protected $color; /** @@ -26,6 +27,7 @@ class Lightsaber * @GQL\Field */ #[GQL\Field] + // @phpstan-ignore-next-line protected $text; /** @@ -33,6 +35,7 @@ class Lightsaber * @GQL\Field */ #[GQL\Field] + // @phpstan-ignore-next-line protected $string; /** @@ -98,6 +101,7 @@ class Lightsaber * @GQL\Field */ #[GQL\Field] + // @phpstan-ignore-next-line protected $float; /** @@ -105,6 +109,7 @@ class Lightsaber * @GQL\Field */ #[GQL\Field] + // @phpstan-ignore-next-line protected $decimal; /** @@ -112,6 +117,7 @@ class Lightsaber * @GQL\Field */ #[GQL\Field] + // @phpstan-ignore-next-line protected $bool; /** @@ -119,5 +125,6 @@ class Lightsaber * @GQL\Field */ #[GQL\Field] + // @phpstan-ignore-next-line protected $boolean; } From c32acb47496b2c18603c0e56b3bd4568d5589a63 Mon Sep 17 00:00:00 2001 From: Vincent Date: Thu, 14 Jan 2021 11:35:35 +0100 Subject: [PATCH 16/23] CI - Run coverage in php8 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 159e66934..326ec9620 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -190,7 +190,7 @@ jobs: - name: "Install PHP with coverage" uses: "shivammathur/setup-php@v2" with: - php-version: "7.4" + php-version: "8" ini-values: pcov.directory=. coverage: "pcov" From 253bd30e16a9752335aaee7d5a0938a27408343a Mon Sep 17 00:00:00 2001 From: Timur Murtukov Date: Fri, 15 Jan 2021 03:23:02 +0100 Subject: [PATCH 17/23] Update ci.yml Change PHP version for coverage --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 326ec9620..89350f5b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -190,7 +190,7 @@ jobs: - name: "Install PHP with coverage" uses: "shivammathur/setup-php@v2" with: - php-version: "8" + php-version: "8.0" ini-values: pcov.directory=. coverage: "pcov" From ba0d62fb3ebe654f395fc5a80f0389aefa29d128 Mon Sep 17 00:00:00 2001 From: Vincent Date: Fri, 15 Jan 2021 09:47:29 +0100 Subject: [PATCH 18/23] CI - Install Ocular with composer --- .github/workflows/ci.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 326ec9620..201271c4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: - php-version: 7.4 symfony-version: "5.2.*" - + - php-version: 8.0 symfony-version: "5.2.*" @@ -201,6 +201,9 @@ jobs: key: "php-${{ matrix.php-version }}-composer-locked-${{ hashFiles('composer.lock') }}" restore-keys: "php-${{ matrix.php-version }}-composer-locked-" + - name: "Install Ocular as depencies" + run: composer req "scrutinizer/ocular" --dev --no-update + - name: "Install dependencies" run: composer update --no-interaction --no-progress @@ -209,8 +212,7 @@ jobs: - name: "Upload coverage results to Scrutinizer" run: | - wget https://scrutinizer-ci.com/ocular.phar - php ocular.phar code-coverage:upload --format=php-clover build/logs/clover.xml + php vendor/bin/ocular code-coverage:upload --format=php-clover build/logs/clover.xml - name: "Upload coverage results to Coveralls" env: From a47ffc1851cdc3a7c7eaef1410ddf8aaf8fb4bfc Mon Sep 17 00:00:00 2001 From: Vincent Date: Fri, 15 Jan 2021 09:53:36 +0100 Subject: [PATCH 19/23] Fix command syntax --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d92d96a65..e38f83d70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -211,8 +211,7 @@ jobs: run: bin/phpunit --color=always -v --debug --coverage-clover=build/logs/clover.xml - name: "Upload coverage results to Scrutinizer" - run: | - php vendor/bin/ocular code-coverage:upload --format=php-clover build/logs/clover.xml + run: vendor/bin/ocular code-coverage:upload --format=php-clover build/logs/clover.xml - name: "Upload coverage results to Coveralls" env: From fc07090e0d5aa1a2885148018edef6e34b16e88b Mon Sep 17 00:00:00 2001 From: Vincent Date: Fri, 15 Jan 2021 09:58:40 +0100 Subject: [PATCH 20/23] Ci - Syntax update --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e38f83d70..67c1da7e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -211,7 +211,7 @@ jobs: run: bin/phpunit --color=always -v --debug --coverage-clover=build/logs/clover.xml - name: "Upload coverage results to Scrutinizer" - run: vendor/bin/ocular code-coverage:upload --format=php-clover build/logs/clover.xml + run: ./vendor/bin/ocular code-coverage:upload --format=php-clover build/logs/clover.xml - name: "Upload coverage results to Coveralls" env: From 805bd217be794b72569ba8929340bf4c04f1ca0a Mon Sep 17 00:00:00 2001 From: Timur Murtukov Date: Fri, 15 Jan 2021 10:31:12 +0100 Subject: [PATCH 21/23] Update ci.yml Fix the path to Ocular --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67c1da7e0..42b7f4771 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -211,7 +211,7 @@ jobs: run: bin/phpunit --color=always -v --debug --coverage-clover=build/logs/clover.xml - name: "Upload coverage results to Scrutinizer" - run: ./vendor/bin/ocular code-coverage:upload --format=php-clover build/logs/clover.xml + run: vendor/scrutinizer/ocular/bin/ocular code-coverage:upload --format=php-clover build/logs/clover.xml - name: "Upload coverage results to Coveralls" env: From f4c1d6975e17391635d897ffd5af492417d3f0fe Mon Sep 17 00:00:00 2001 From: Vincent Date: Fri, 15 Jan 2021 10:48:45 +0100 Subject: [PATCH 22/23] Exclude missing annotations from coverage --- src/Config/Parser/AnnotationParser.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Config/Parser/AnnotationParser.php b/src/Config/Parser/AnnotationParser.php index 5ab3d9102..f988d5411 100644 --- a/src/Config/Parser/AnnotationParser.php +++ b/src/Config/Parser/AnnotationParser.php @@ -37,7 +37,9 @@ protected static function getAnnotationReader(): AnnotationReader if (null === self::$annotationReader) { if (!class_exists(AnnotationReader::class) || !class_exists(AnnotationRegistry::class)) { + // @codeCoverageIgnoreStart throw new RuntimeException('In order to use graphql annotation, you need to require doctrine annotations'); + // @codeCoverageIgnoreEnd } AnnotationRegistry::registerLoader('class_exists'); From a93a0038d126d6c14fe230911a0f3156f9aab77e Mon Sep 17 00:00:00 2001 From: Vincent Date: Fri, 15 Jan 2021 10:54:21 +0100 Subject: [PATCH 23/23] Remove legacy class --- .../Parser/MetadataParser/_GraphClass.php | 94 ------------------- 1 file changed, 94 deletions(-) delete mode 100644 src/Config/Parser/MetadataParser/_GraphClass.php diff --git a/src/Config/Parser/MetadataParser/_GraphClass.php b/src/Config/Parser/MetadataParser/_GraphClass.php deleted file mode 100644 index ceb0ff324..000000000 --- a/src/Config/Parser/MetadataParser/_GraphClass.php +++ /dev/null @@ -1,94 +0,0 @@ -annotations = $annotationReader->getClassAnnotations($this); - - $reflection = $this; - do { - foreach ($reflection->getProperties() as $property) { - if (isset($this->propertiesExtended[$property->getName()])) { - continue; - } - $this->propertiesExtended[$property->getName()] = $property; - } - } while ($reflection = $reflection->getParentClass()); - } - - /** - * @return ReflectionProperty[] - */ - public function getPropertiesExtended() - { - return $this->propertiesExtended; - } - - /** - * @phpstan-param ReflectionMethod|ReflectionProperty|null $from - * - * @return array - * - * @throws AnnotationException - */ - public function getMetadatas(Reflector $from = null): array - { - switch (true) { - case null === $from: - return $this->annotations; - case $from instanceof ReflectionMethod: - // @phpstan-ignore-next-line - return self::getAnnotationReader()->getMethodAnnotations($from); - case $from instanceof ReflectionProperty: - // @phpstan-ignore-next-line - return self::getAnnotationReader()->getPropertyAnnotations($from); - default: - throw new AnnotationException(sprintf('Unable to retrieve annotations from object of class "%s".', get_class($from))); - } - } - - private static function getAnnotationReader(): AnnotationReader - { - if (null === self::$annotationReader) { - if (!class_exists(AnnotationReader::class) || - !class_exists(AnnotationRegistry::class)) { - throw new RuntimeException('In order to use graphql annotation, you need to require doctrine annotations'); - } - - AnnotationRegistry::registerLoader('class_exists'); - self::$annotationReader = new AnnotationReader(); - } - - return self::$annotationReader; - } -}